22 Commits

Author SHA1 Message Date
445728f636 0.3.5 2026-09-22 14:24:05 +02:00
4523083b20 0.3.5-rc.1 2026-09-22 12:00:49 +02:00
6ccdebb241 0.3.5-beta.2 2026-09-22 11:38:58 +02:00
48444dce7f 0.3.5-beta.1.fix.1 2026-09-22 09:17:10 +02:00
687b746381 0.3.5-beta.1 2026-09-22 09:01:57 +02:00
974ac28f35 0.3.5-alpha.10.fix.1 2026-09-22 08:53:36 +02:00
3f8693c929 0.3.5-alpha.10 2026-09-22 08:49:40 +02:00
c5806e4f78 0.3.5-alpha.9.fix.1 2026-09-22 08:35:53 +02:00
9f1a1c3d06 0.3.5-alpha.9 2026-09-22 08:32:36 +02:00
17d9e18dcd 0.3.5-alpha.8.fix.1 2026-09-22 07:37:57 +02:00
7f139f631e 0.3.5-alpha.8 2026-09-22 07:35:17 +02:00
401f00de13 0.3.5-alpha.7.fix.2 2026-09-22 06:52:32 +02:00
dfb41a79b6 0.3.5-alpha.7.fix.1 2026-09-22 06:42:46 +02:00
1764eb9bed 0.3.5-alpha.7 2026-09-22 06:36:54 +02:00
0ef5e1a233 0.3.5-alpha.6 2026-09-22 06:34:31 +02:00
bbffb7fa60 0.3.5-alpha.5 2026-09-22 06:17:39 +02:00
48de882f70 0.3.5-alpha.4.fix.1 2026-09-21 23:40:22 +02:00
ce3811f8ec 0.3.5-alpha.4 2026-09-21 23:37:18 +02:00
fd6ffecdf5 0.3.5-alpha.3 2026-09-21 22:55:21 +02:00
5b7939cbd8 0.3.5-alpha.2.fix.1 2026-09-21 22:40:28 +02:00
0077ea8b4d 0.3.5-alpha.2 2026-09-21 22:36:38 +02:00
3e24b7840c 0.3.5-alpha.1 2026-09-21 21:21:28 +02:00
83 changed files with 10477 additions and 33 deletions

View File

@@ -1,6 +1,10 @@
# file: .cargo/config.toml # file: .cargo/config.toml
# version: 1 # version: 2
[build] [build]
target-dir = "../builds/sasedev-games/target" # path of where to place generated artifacts target-dir = "../builds/sasedev-games/target" # path of where to place generated artifacts
build-dir = "../builds/sasedev-games/target" # path of where to place intermediate build artifacts build-dir = "../builds/sasedev-games/target" # path of where to place intermediate build artifacts
[target.wasm32-unknown-unknown]
rustflags = ["--cfg=web_sys_unstable_apis"]
rustdocflags = ["--cfg=web_sys_unstable_apis"]

View File

@@ -1,8 +1,33 @@
<!-- file: CHANGELOG.md --> <!-- file: CHANGELOG.md -->
<!-- version: 20 --> <!-- version: 22 -->
# Changelog # Changelog
## 0.3.5 — 2026-09-22
- publication stable du second backend realtime `game-realtime-webtransport-lib`, retenu comme implémentation WebTransport/QUIC supplémentaire tandis que `game-realtime-websocket-lib` reste la baseline et le fallback de référence ;
- établissement natif QUIC/HTTP3 avec TLS et pin SHA-256 exact, stream bidirectionnel fiable compatible `RealtimeConnection`, deadlines/cancellation bornées et client navigateur/WASM WebTransport réel validé contre le serveur Rust natif ;
- composition WebTransport-first validée sans manager générique : fallback WebSocket limité aux erreurs classifiées `Timeout`/`Io`, avec les erreurs ambiguës ou de configuration laissées visibles ;
- datagrams WebTransport conservés comme capacité backend-spécifique non fiable/non ordonnée, avec taille maximale négociée et sans extension du contrat fiable commun ;
- caractérisation loopback bornée de l'établissement, du RTT, de plusieurs patterns de throughput et des datagrams, documentée comme observation locale et non comme classement général ni prédiction Internet/mobile ;
- `0.3.5-rc.1` validée avec audits propres, `cargo check`, Clippy strict, **77 tests workspace**, quatre smokes natifs `PASS`, mesures release, builds WASM/frontend, smoke navigateur réel `PASS`, cross-compilation Android ARM64/API 21 et graphes de dépendances conformes ;
- documentation durable et architectures consolidées ; Android runtime WebTransport et plateformes Apple restent explicitement non validés, et aucun codec wire métier final, protocole session joueur/room, reconnect/resync, synchronisation gameplay, prediction/reconciliation, matchmaking ou simulation authoritative n'est introduit par `0.3.5`.
Les détails des phases `alpha`, `beta`, `rc` et de leurs correctifs restent dans `deltas/0.3.5/` et `history/0.3.5/`.
## 0.3.5-rc.1 — 2026-09-22
- gel de la seconde implémentation realtime autour de `game-realtime-webtransport-lib`, conservée comme backend WebTransport/QUIC supplémentaire tandis que `game-realtime-websocket-lib` reste la baseline et le fallback de référence ;
- établissement natif QUIC/HTTP3 avec TLS et pin SHA-256 exact, stream bidirectionnel fiable compatible `RealtimeConnection`, deadlines/cancellation bornées et client navigateur/WASM WebTransport réel validé contre le serveur Rust natif ;
- composition WebTransport-first validée sans manager générique : fallback WebSocket limité aux erreurs classifiées `Timeout`/`Io`, avec les erreurs ambiguës ou de configuration laissées visibles ;
- datagrams WebTransport conservés comme capacité backend-spécifique, non fiable et non ordonnée, avec taille maximale négociée et sans extension du contrat fiable commun ;
- caractérisation loopback bornée de l'établissement, du RTT, de plusieurs patterns de throughput et des datagrams ; ces mesures décrivent les wrappers locaux et ne constituent ni un classement général des transports ni une prédiction Internet/mobile ;
- validation large `beta.1` avec 77 tests workspace, smokes natifs WebSocket/WebTransport/datagram/fallback, builds WASM/frontend et smoke navigateur réel ; cross-compilation du backend WebTransport validée pour Android ARM64/API 21 avec NDK `28.2.13676358`, sans prétendre à un smoke runtime Android WebTransport ;
- consolidation `beta.2` des README/USAGE et architectures, décision **retained** figée pour WebTransport, et préparation du prompt `0.3.6` pour réconcilier les POC `0.3.x` avant Uroburas `0.4.x` ;
- aucun codec wire métier final, protocole session joueur/room, reconnect/resync, synchronisation gameplay, prediction/reconciliation, matchmaking ou simulation authoritative n'est introduit par `0.3.5`.
La RC n'ouvre aucun nouveau scope. Seuls les correctifs nécessaires à la publication selon `VER-RC-*` peuvent produire `0.3.5-rc.1.fix.N`.
## 0.3.4 — 2026-09-21 ## 0.3.4 — 2026-09-21
- publication stable de la première baseline realtime, avec `game-realtime-transport-lib` comme contrat binaire transport-neutral et `game-realtime-websocket-lib` comme backend Tokio/tokio-tungstenite ; - publication stable de la première baseline realtime, avec `game-realtime-transport-lib` comme contrat binaire transport-neutral et `game-realtime-websocket-lib` comme backend Tokio/tokio-tungstenite ;

View File

@@ -1,5 +1,5 @@
# file: Cargo.toml # file: Cargo.toml
# version: 94 # version: 113
[workspace] [workspace]
resolver = "3" resolver = "3"
@@ -9,13 +9,19 @@ members = [
"crates/engines/engine-v1-sdl", "crates/engines/engine-v1-sdl",
"crates/games/game-reflex-poc", "crates/games/game-reflex-poc",
"crates/games/game-snake-poc", "crates/games/game-snake-poc",
"crates/apps/game-realtime-transport-fallback-smoke",
"crates/apps/game-realtime-transport-measure",
"crates/apps/game-realtime-websocket-smoke", "crates/apps/game-realtime-websocket-smoke",
"crates/apps/game-realtime-webtransport-browser-smoke",
"crates/apps/game-realtime-webtransport-datagram-smoke",
"crates/apps/game-realtime-webtransport-smoke",
"crates/apps/game-reflex-poc-desktop", "crates/apps/game-reflex-poc-desktop",
"crates/apps/game-snake-poc-desktop", "crates/apps/game-snake-poc-desktop",
"crates/common/game-assets-lib", "crates/common/game-assets-lib",
"crates/common/game-logging-lib", "crates/common/game-logging-lib",
"crates/common/game-realtime-transport-lib", "crates/common/game-realtime-transport-lib",
"crates/common/game-realtime-websocket-lib", "crates/common/game-realtime-websocket-lib",
"crates/common/game-realtime-webtransport-lib",
"crates/apps/game-android-entrypoint", "crates/apps/game-android-entrypoint",
"crates/apps/game-reflex-poc-tauri", "crates/apps/game-reflex-poc-tauri",
"crates/apps/game-reflex-poc-wasm", "crates/apps/game-reflex-poc-wasm",
@@ -24,7 +30,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.3.4" version = "0.3.5"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/games" repository = "https://git.sasedev.com/Sasedev/games"
@@ -33,6 +39,8 @@ publish = false
[workspace.dependencies] [workspace.dependencies]
futures-util = { version = "0.3.34", default-features = false } futures-util = { version = "0.3.34", default-features = false }
gloo-timers = "0.4.0"
rcgen = { version = "0.14.10", default-features = false }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
sdl3 = "^0.20" sdl3 = "^0.20"
tracing = "0.1.44" tracing = "0.1.44"
@@ -44,7 +52,11 @@ tauri-build = "2"
tauri-plugin-tracing = "^0.3" tauri-plugin-tracing = "^0.3"
tokio = "1.53.1" tokio = "1.53.1"
tokio-tungstenite = { version = "0.30.0", default-features = false } tokio-tungstenite = { version = "0.30.0", default-features = false }
url = "2.5.8"
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-transport-quinn = { version = "0.12.1", default-features = false }
web-transport-wasm = "0.6.0"
[workspace.lints.rust] [workspace.lints.rust]
missing_docs = "warn" missing_docs = "warn"

View File

@@ -1,5 +1,5 @@
<!-- file: README.md --> <!-- file: README.md -->
<!-- version: 62 --> <!-- version: 83 -->
# games.sasedev # games.sasedev
@@ -25,11 +25,11 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale
## Baseline ## Baseline
Version stable de référence : `0.3.4`. Version stable de référence : `0.3.5`.
Prochaine version planifiée : `0.3.5-alpha.1`. `0.3.2` reste différée. Version active : `0.3.5`. Prochaine version planifiée : `0.3.6-alpha.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. La stable `0.3.5` ajoute le second backend WebTransport/QUIC retenu : chemins natif et navigateur, composition WebTransport-first avec fallback WebSocket strictement classifié, datagrams backend-spécifiques, caractérisation loopback bornée et cross-compilation Android ARM64/API 21. WebSocket reste la baseline/fallback de référence ; WebTransport est conservé comme second backend realtime. Cette décision ne prétend pas quun résultat loopback prédit les performances Internet/mobile et ne transforme pas les datagrams en capacité du contrat fiable commun.
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. 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 ## 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 dinté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`, second backend WebTransport/QUIC retenu dont le chemin natif couvre TLS/pinning, établissement de session et stream fiable principal, tandis que son chemin client WASM compile contre l'API WebTransport du navigateur avec le même framing fiable et le même contrat commun. Leurs responsabilités sont documentées dans leurs README/USAGE locaux lorsqu'un guide d'usage est justifié. `game-realtime-websocket-smoke` et `game-realtime-webtransport-smoke` fournissent les preuves runtime localhost hors harness des deux backends fiables natifs ; `game-realtime-webtransport-browser-smoke` et son host sous `Web/` portent la preuve navigateur WebTransport réelle sans couplage gameplay ; `game-realtime-transport-fallback-smoke` prouve séparément la composition WebTransport-first et le fallback WebSocket classifié sans introduire de manager générique ; `game-realtime-webtransport-datagram-smoke` exerce enfin la capacité datagram WebTransport native sans l'ajouter au contrat fiable commun. Les tests unitaires résident hors `src/` sous `unit_tests/`; les tests dintégration/environnement résident sous `tests/`.

View File

@@ -1,5 +1,5 @@
<!-- file: ROADMAP.md --> <!-- file: ROADMAP.md -->
<!-- version: 27 --> <!-- version: 28 -->
# Roadmap # Roadmap
@@ -50,7 +50,7 @@ Le détail historique des prereleases `0.1.0-*` reste dans `deltas/0.1.0/` et `h
- (d) `0.3.2` — Tauri Desktop + Snake est différé tant quune solution de monétisation desktop/WebView, notamment vidéo récompensée, nest pas démontrée techniquement et contractuellement ; ne pas créer cette distribution seulement pour réutiliser le host Web. - (d) `0.3.2` — Tauri Desktop + Snake est différé tant quune solution de monétisation desktop/WebView, notamment vidéo récompensée, nest pas démontrée techniquement et contractuellement ; ne pas créer cette distribution seulement pour réutiliser le host Web.
- (x) `0.3.3` — Android SDL3 natif multi-ABI livré avec Cargo/Gradle natifs, APK universal pour tests, AAB comme cible de distribution, `minSdk 21` fumé et compatibilité 16 KB 64 bits validée, sans script Python de build. - (x) `0.3.3` — Android SDL3 natif multi-ABI livré avec Cargo/Gradle natifs, APK universal pour tests, AAB comme cible de distribution, `minSdk 21` fumé et compatibilité 16 KB 64 bits validée, sans script Python de build.
- (x) `0.3.4` — API de transport realtime + WebSocket/tokio-tungstenite baseline livrée, sans serveur Uroburas Mode 3. - (x) `0.3.4` — API de transport realtime + WebSocket/tokio-tungstenite baseline livrée, sans serveur Uroburas Mode 3.
- ( ) `0.3.5` — POC WebTransport/QUIC sur le même protocole, avec comparaison mesurée et fallback WebSocket. - (x) `0.3.5` — POC WebTransport/QUIC sur le même protocole, avec comparaison mesurée et fallback WebSocket.
- ( ) `0.3.6` — consolidation des POC plateforme/réseau et préparation de la baseline `0.4.x`. - ( ) `0.3.6` — consolidation des POC plateforme/réseau et préparation de la baseline `0.4.x`.
La numérotation `0.3.1+` reste révisable à partir des résultats réels ; les lignes ci-dessus décrivent le planning actuel, pas une obligation de créer des versions artificielles. La numérotation `0.3.1+` reste révisable à partir des résultats réels ; les lignes ci-dessus décrivent le planning actuel, pas une obligation de créer des versions artificielles.

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>games.sasedev WebTransport browser smoke</title>
</head>
<body>
<main>
<h1>WebTransport browser smoke</h1>
<p id="environment-status">Checking browser environment…</p>
<label>
WebTransport endpoint
<input id="endpoint" type="text" size="72" autocomplete="off">
</label>
<br>
<label>
Certificate SHA-256
<input id="certificate-hash" type="text" size="72" autocomplete="off">
</label>
<br>
<button id="run" type="button">Run smoke</button>
<pre id="result">IDLE</pre>
</main>
<script type="module" src="./ts/main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
// file: Web/game-realtime-webtransport-browser-smoke/frontend/ts/main.ts
// version: 1
import init, { run_browser_smoke } from "@webtransport-browser-smoke-wasm";
function requireElement<T extends Element>(selector: string): T {
const element = document.querySelector<T>(selector);
if (element === null) {
throw new Error(`Required browser smoke element is missing: ${selector}`);
}
return element;
}
function populateFromQuery(endpoint: HTMLInputElement, certificateHash: HTMLInputElement): void {
const parameters = new URLSearchParams(window.location.search);
const endpointValue = parameters.get("endpoint");
const hashValue = parameters.get("sha256");
if (endpointValue !== null) {
endpoint.value = endpointValue;
}
if (hashValue !== null) {
certificateHash.value = hashValue;
}
}
async function main(): Promise<void> {
const environmentStatus = requireElement<HTMLParagraphElement>("#environment-status");
const endpoint = requireElement<HTMLInputElement>("#endpoint");
const certificateHash = requireElement<HTMLInputElement>("#certificate-hash");
const run = requireElement<HTMLButtonElement>("#run");
const result = requireElement<HTMLPreElement>("#result");
populateFromQuery(endpoint, certificateHash);
if (!window.isSecureContext) {
environmentStatus.textContent = "FAIL: page is not running in a secure context";
run.disabled = true;
return;
}
if (!("WebTransport" in window)) {
environmentStatus.textContent = "FAIL: this browser does not expose WebTransport";
run.disabled = true;
return;
}
await init();
environmentStatus.textContent = "Secure context and WebTransport available; WASM loaded";
run.addEventListener("click", () => {
run.disabled = true;
result.textContent = "RUNNING";
void run_browser_smoke(endpoint.value.trim(), certificateHash.value.trim())
.then(message => {
result.textContent = message;
})
.catch(caughtError => {
result.textContent = `game-realtime-webtransport-browser-smoke: FAIL: ${String(caughtError)}`;
})
.finally(() => {
run.disabled = false;
});
});
if (endpoint.value.length > 0 && certificateHash.value.length > 0) {
run.click();
}
}
void main().catch(caughtError => {
const result = document.querySelector<HTMLPreElement>("#result");
if (result !== null) {
result.textContent = `game-realtime-webtransport-browser-smoke: FAIL: ${String(caughtError)}`;
}
});

View File

@@ -0,0 +1,17 @@
{
"name": "game-realtime-webtransport-browser-smoke-web",
"private": true,
"version": "0.3.5",
"type": "module",
"scripts": {
"wasm:dev": "cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown && wasm-bindgen ../../../builds/sasedev-games/target/wasm32-unknown-unknown/debug/game_realtime_webtransport_browser_smoke.wasm --target web --out-dir ../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm --out-name game_realtime_webtransport_browser_smoke",
"wasm:build": "cargo build -p game-realtime-webtransport-browser-smoke --lib --release --target wasm32-unknown-unknown && wasm-bindgen ../../../builds/sasedev-games/target/wasm32-unknown-unknown/release/game_realtime_webtransport_browser_smoke.wasm --target web --out-dir ../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm --out-name game_realtime_webtransport_browser_smoke",
"dev": "npm run wasm:dev && vite",
"build": "npm run wasm:build && tsc && vite build"
},
"devDependencies": {
"@types/node": "^26.1",
"typescript": "^7.0",
"vite": "^8.2"
}
}

View File

@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"types": [
"vite/client",
"node"
],
"paths": {
"@webtransport-browser-smoke-wasm": [
"../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm/game_realtime_webtransport_browser_smoke.d.ts"
]
}
},
"include": [
"frontend",
"vite.config.ts"
]
}

View File

@@ -0,0 +1,50 @@
// file: Web/game-realtime-webtransport-browser-smoke/vite.config.ts
// version: 1
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { defineConfig, normalizePath } from "vite";
const appRoot = fileURLToPath(new URL(".", import.meta.url));
const repositoryRoot = normalizePath(resolve(appRoot, "../.."));
const frontendRoot = normalizePath(resolve(appRoot, "frontend"));
const externalBuildRoot = normalizePath(resolve(repositoryRoot, "../builds/sasedev-games/game-realtime-webtransport-browser-smoke"));
const wasmRoot = normalizePath(resolve(externalBuildRoot, "wasm"));
const wasmModule = normalizePath(resolve(wasmRoot, "game_realtime_webtransport_browser_smoke.js"));
const frontendDist = normalizePath(resolve(externalBuildRoot, "dist"));
const viteCacheDir = normalizePath(resolve(externalBuildRoot, "vite-cache"));
export default defineConfig({
base: "./",
cacheDir: viteCacheDir,
clearScreen: false,
root: frontendRoot,
publicDir: false,
input: {
main: normalizePath(resolve(frontendRoot, "main.html")),
},
resolve: {
alias: {
"@webtransport-browser-smoke-wasm": wasmModule,
},
},
build: {
outDir: frontendDist,
emptyOutDir: true,
minify: true,
sourcemap: false,
},
server: {
host: "127.0.0.1",
port: 1435,
strictPort: true,
fs: {
allow: [appRoot, repositoryRoot, externalBuildRoot],
},
},
preview: {
host: "127.0.0.1",
port: 4175,
strictPort: true,
},
});

View File

@@ -0,0 +1,22 @@
# file: crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml
# version: 1
[package]
name = "game-realtime-transport-fallback-smoke"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
game-logging-lib = { path = "../../common/game-logging-lib" }
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
tokio = { workspace = true, features = ["macros", "rt", "time"] }
tracing.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,325 @@
// file: crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Executable localhost smoke for WebTransport-first realtime composition with classified WebSocket fallback.
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
const CLIENT_PAYLOAD: &[u8] = b"games.sasedev-fallback-client-smoke";
const FORCED_FALLBACK_DETAIL: &str = "forced WebTransport unavailability for fallback smoke";
const NON_FALLBACK_ENDPOINT: &str = "https://127.0.0.1:9/non-fallback";
const SERVER_PAYLOAD: &[u8] = b"games.sasedev-fallback-server-smoke";
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const TRACING_TARGET: &str = "games::realtime::transport::fallback-smoke";
const UNUSED_WEBSOCKET_ENDPOINT: &str = "https://fallback-must-not-run.invalid/";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SelectedTransport {
WebSocket,
WebTransport,
}
enum SelectedConnection<WebTransportConnection, WebSocketConnection> {
WebSocket(WebSocketConnection),
WebTransport(WebTransportConnection),
}
impl<WebTransportConnection, WebSocketConnection> SelectedConnection<WebTransportConnection, WebSocketConnection> {
fn transport(&self) -> SelectedTransport {
return match self {
Self::WebSocket(_) => SelectedTransport::WebSocket,
Self::WebTransport(_) => SelectedTransport::WebTransport,
};
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::process::ExitCode {
let _logging_guard = match game_logging_lib::init_console_tracing() {
std::result::Result::Ok(guard) => guard,
std::result::Result::Err(error) => {
eprintln!("failed to initialize realtime fallback smoke tracing: {error}");
return std::process::ExitCode::FAILURE;
},
};
tracing::info!(target: TRACING_TARGET, "realtime transport fallback smoke started");
let result = tokio::time::timeout(SMOKE_TIMEOUT, run_smoke()).await;
return match result {
std::result::Result::Ok(std::result::Result::Ok(())) => {
tracing::info!(target: TRACING_TARGET, "realtime transport fallback smoke passed");
println!("game-realtime-transport-fallback-smoke: PASS");
std::process::ExitCode::SUCCESS
},
std::result::Result::Ok(std::result::Result::Err(error)) => {
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "realtime transport fallback smoke failed");
eprintln!("game-realtime-transport-fallback-smoke: FAIL: {error}");
std::process::ExitCode::FAILURE
},
std::result::Result::Err(_) => {
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "realtime transport fallback smoke timed out");
eprintln!("game-realtime-transport-fallback-smoke: FAIL: smoke timed out");
std::process::ExitCode::FAILURE
},
};
}
async fn run_smoke() -> std::result::Result<(), String> {
if let std::result::Result::Err(error) = run_webtransport_preferred_branch().await {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = run_websocket_fallback_branch().await {
return std::result::Result::Err(error);
}
if let std::result::Result::Err(error) = run_non_fallback_error_branch().await {
return std::result::Result::Err(error);
}
return std::result::Result::Ok(());
}
async fn run_webtransport_preferred_branch() -> std::result::Result<(), String> {
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred identity generation failed: {error}")),
};
let certificate_hash = identity.certificate_hash().clone();
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred listener bind failed: {error}")),
};
let endpoint = format!("https://{}/fallback-preferred", listener.local_addr());
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred client configuration failed: {error}")),
};
let server = async {
let session = match listener.accept().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return session.accept_primary_connection().await;
};
let client = select_preferred_transport(
|| {
return async {
let session = match game_realtime_webtransport_lib::connect(&client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return session.open_primary_connection().await;
};
},
|| {
return async {
return game_realtime_websocket_lib::connect(UNUSED_WEBSOCKET_ENDPOINT).await;
};
},
);
let (server_result, client_result) = tokio::join!(server, client);
let server_connection = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred server establishment failed: {error}")),
};
let client_connection = match client_result {
std::result::Result::Ok(SelectedConnection::WebTransport(value)) => value,
std::result::Result::Ok(SelectedConnection::WebSocket(_)) => {
return std::result::Result::Err(String::from("WebTransport preferred branch unexpectedly selected WebSocket"));
},
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport preferred selection failed: {error}")),
};
if let std::result::Result::Err(error) = exercise_round_trip(client_connection, server_connection).await {
return std::result::Result::Err(format!("WebTransport preferred round-trip failed: {error}"));
}
tracing::info!(target: TRACING_TARGET, selected = "webtransport", "preferred transport branch passed");
return std::result::Result::Ok(());
}
async fn run_websocket_fallback_branch() -> std::result::Result<(), String> {
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let listener = match game_realtime_websocket_lib::WebSocketListener::bind(bind_address).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback listener bind failed: {error}")),
};
let endpoint = format!("ws://{}/fallback", listener.local_addr());
let server = listener.accept();
let client = select_preferred_transport(
|| {
return async {
return std::result::Result::<game_realtime_webtransport_lib::WebTransportConnection, game_realtime_transport_lib::TransportError>::Err(
game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Timeout, FORCED_FALLBACK_DETAIL),
);
};
},
|| {
return async {
return game_realtime_websocket_lib::connect(endpoint.as_str()).await;
};
},
);
let (server_result, client_result) = tokio::join!(server, client);
let server_connection = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback server establishment failed: {error}")),
};
let client_connection = match client_result {
std::result::Result::Ok(SelectedConnection::WebSocket(value)) => value,
std::result::Result::Ok(SelectedConnection::WebTransport(_)) => {
return std::result::Result::Err(String::from("forced fallback branch unexpectedly selected WebTransport"));
},
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket fallback selection failed: {error}")),
};
if let std::result::Result::Err(error) = exercise_round_trip(client_connection, server_connection).await {
return std::result::Result::Err(format!("WebSocket fallback round-trip failed: {error}"));
}
tracing::info!(target: TRACING_TARGET, selected = "websocket", "classified fallback branch passed");
return std::result::Result::Ok(());
}
async fn run_non_fallback_error_branch() -> std::result::Result<(), String> {
let certificate_hash = game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256([0_u8; 32]);
let transport = game_realtime_webtransport_lib::WebTransportConfig::default().with_connect_timeout(std::time::Duration::ZERO);
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(NON_FALLBACK_ENDPOINT, certificate_hash) {
std::result::Result::Ok(value) => value.with_transport_config(transport),
std::result::Result::Err(error) => {
return std::result::Result::Err(format!("non-fallback WebTransport config construction failed unexpectedly: {error}"));
},
};
let result = select_preferred_transport(
|| {
return async {
let session = match game_realtime_webtransport_lib::connect(&client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
return session.open_primary_connection().await;
};
},
|| {
return async {
return game_realtime_websocket_lib::connect("ws://127.0.0.1:1/non-fallback-must-not-run").await;
};
},
)
.await;
return match result {
std::result::Result::Err(error)
if error.kind() == game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration
&& error.detail() == "connect_timeout must be greater than zero" =>
{
tracing::info!(target: TRACING_TARGET, kind = %error.kind(), "non-fallback error remained visible");
std::result::Result::Ok(())
},
std::result::Result::Err(error) => std::result::Result::Err(format!("non-fallback branch returned the wrong visible error: {error}")),
std::result::Result::Ok(selected) => std::result::Result::Err(format!("non-fallback branch unexpectedly selected {:?}", selected.transport())),
};
}
async fn select_preferred_transport<WebTransportConnection, WebSocketConnection, WebTransportAttempt, WebTransportFuture, WebSocketAttempt, WebSocketFuture>(
webtransport_attempt: WebTransportAttempt,
websocket_attempt: WebSocketAttempt,
) -> std::result::Result<SelectedConnection<WebTransportConnection, WebSocketConnection>, game_realtime_transport_lib::TransportError>
where
WebTransportAttempt: std::ops::FnOnce() -> WebTransportFuture,
WebTransportFuture: core::future::Future<Output = std::result::Result<WebTransportConnection, game_realtime_transport_lib::TransportError>>,
WebSocketAttempt: std::ops::FnOnce() -> WebSocketFuture,
WebSocketFuture: core::future::Future<Output = std::result::Result<WebSocketConnection, game_realtime_transport_lib::TransportError>>,
{
let primary_result = webtransport_attempt().await;
return match primary_result {
std::result::Result::Ok(connection) => std::result::Result::Ok(SelectedConnection::WebTransport(connection)),
std::result::Result::Err(primary_error) if is_fallback_eligible(primary_error.kind()) => {
tracing::warn!(
target: TRACING_TARGET,
primary_kind = %primary_error.kind(),
primary_detail = primary_error.detail(),
"WebTransport unavailable; attempting classified WebSocket fallback"
);
return match websocket_attempt().await {
std::result::Result::Ok(connection) => std::result::Result::Ok(SelectedConnection::WebSocket(connection)),
std::result::Result::Err(fallback_error) => std::result::Result::Err(game_realtime_transport_lib::TransportError::new(
fallback_error.kind(),
format!("WebTransport attempt failed ({primary_error}); WebSocket fallback failed ({fallback_error})"),
)),
};
},
std::result::Result::Err(error) => std::result::Result::Err(error),
};
}
fn is_fallback_eligible(kind: game_realtime_transport_lib::TransportErrorKind) -> bool {
return kind == game_realtime_transport_lib::TransportErrorKind::Timeout || kind == game_realtime_transport_lib::TransportErrorKind::Io;
}
async fn exercise_round_trip<ClientConnection, ServerConnection>(
client_connection: ClientConnection,
server_connection: ServerConnection,
) -> std::result::Result<(), String>
where
ClientConnection: game_realtime_transport_lib::RealtimeConnection,
ClientConnection::Receiver: game_realtime_transport_lib::RealtimeReceiver,
ClientConnection::Sender: game_realtime_transport_lib::RealtimeSender,
ServerConnection: game_realtime_transport_lib::RealtimeConnection,
ServerConnection::Receiver: game_realtime_transport_lib::RealtimeReceiver,
ServerConnection::Sender: game_realtime_transport_lib::RealtimeSender,
{
let (mut client_sender, mut client_receiver) = client_connection.split();
let (mut server_sender, mut server_receiver) = server_connection.split();
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(CLIENT_PAYLOAD.to_vec())).await {
return std::result::Result::Err(format!("client send failed: {error}"));
}
let server_received = match server_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("server receive failed: {error}")),
};
if !receive_matches(server_received, CLIENT_PAYLOAD) {
return std::result::Result::Err(String::from("server did not receive the expected client payload"));
}
if let std::result::Result::Err(error) = server_sender.send(game_realtime_transport_lib::TransportMessage::new(SERVER_PAYLOAD.to_vec())).await {
return std::result::Result::Err(format!("server send failed: {error}"));
}
let client_received = match client_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client receive failed: {error}")),
};
if !receive_matches(client_received, SERVER_PAYLOAD) {
return std::result::Result::Err(String::from("client did not receive the expected server payload"));
}
if let std::result::Result::Err(error) = client_sender.close().await {
return std::result::Result::Err(format!("client close failed: {error}"));
}
let server_close = match server_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("server close observation failed: {error}")),
};
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
return std::result::Result::Err(String::from("server did not observe the client close"));
}
if let std::result::Result::Err(error) = server_sender.close().await {
return std::result::Result::Err(format!("server close failed: {error}"));
}
let client_close = match client_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client close observation failed: {error}")),
};
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
return std::result::Result::Err(String::from("client did not observe the server close"));
}
return std::result::Result::Ok(());
}
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
return match receive {
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
game_realtime_transport_lib::TransportReceive::Closed => false,
};
}
#[cfg(test)]
#[path = "../unit_tests/fallback.rs"]
mod tests;

View File

@@ -0,0 +1,78 @@
// file: crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs
// version: 1
#[test]
fn fallback_policy_is_narrow_and_excludes_ambiguous_connect_failures() {
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration));
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Connect));
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Bind));
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Accept));
assert!(super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Timeout));
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::MessageTooLarge));
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Backpressure));
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Closed));
assert!(super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Io));
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Protocol));
assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Aborted));
}
#[tokio::test(flavor = "current_thread")]
async fn selector_prefers_webtransport_and_falls_back_only_for_classified_errors() {
let preferred = super::select_preferred_transport(
|| {
return async {
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(7);
};
},
|| {
return async {
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(test_error("fallback must not run"));
};
},
)
.await;
assert!(matches!(preferred, std::result::Result::Ok(super::SelectedConnection::WebTransport(7))), "WebTransport success must remain preferred");
let fallback = super::select_preferred_transport(
|| {
return async {
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(game_realtime_transport_lib::TransportError::new(
game_realtime_transport_lib::TransportErrorKind::Timeout,
"forced timeout",
));
};
},
|| {
return async {
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(9);
};
},
)
.await;
assert!(matches!(fallback, std::result::Result::Ok(super::SelectedConnection::WebSocket(9))), "classified timeout must select WebSocket fallback");
let non_fallback = super::select_preferred_transport(
|| {
return async {
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Err(test_error("visible primary error"));
};
},
|| {
return async {
return std::result::Result::<u8, game_realtime_transport_lib::TransportError>::Ok(11);
};
},
)
.await;
assert!(
matches!(
&non_fallback,
std::result::Result::Err(error)
if error.kind() == game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration
&& error.detail() == "visible primary error"
),
"non-fallback errors must remain visible"
);
}
fn test_error(detail: &str) -> game_realtime_transport_lib::TransportError {
return game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration, detail);
}

View File

@@ -0,0 +1,20 @@
# file: crates/apps/game-realtime-transport-measure/Cargo.toml
# version: 1
[package]
name = "game-realtime-transport-measure"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
tokio = { workspace = true, features = ["macros", "rt", "time"] }
[lints]
workspace = true

View File

@@ -0,0 +1,513 @@
// file: crates/apps/game-realtime-transport-measure/src/main.rs
// version: 2
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Bounded localhost characterization tool for WebSocket and WebTransport realtime backends.
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
const ACK_PAYLOAD: &[u8] = b"ack";
const DATAGRAM_PAYLOAD_BYTES: usize = 256;
const DATAGRAM_RECEIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(250);
const DATAGRAM_SAMPLES: usize = 64;
const ESTABLISHMENT_SAMPLES: usize = 8;
const IN_FLIGHT_MESSAGES: usize = 64;
const IN_FLIGHT_PAYLOAD_BYTES: usize = 1024;
const MEASUREMENT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
const RTT_PAYLOAD_BYTES: usize = 32;
const RTT_SAMPLES: usize = 128;
const RTT_WARMUP: usize = 16;
const THROUGHPUT_MESSAGES: usize = 128;
const THROUGHPUT_PAYLOAD_BYTES: usize = 64 * 1024;
#[derive(Clone, Copy)]
struct LatencySummary {
min_us: u128,
median_us: u128,
p95_us: u128,
max_us: u128,
}
struct TransferSummary {
elapsed: std::time::Duration,
messages: usize,
payload_bytes: usize,
}
struct DatagramSummary {
elapsed: std::time::Duration,
attempted: usize,
received: usize,
payload_bytes: usize,
client_max: usize,
server_max: usize,
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::process::ExitCode {
let result = tokio::time::timeout(MEASUREMENT_TIMEOUT, run_measurements()).await;
return match result {
std::result::Result::Ok(std::result::Result::Ok(())) => {
println!("CONCLUSION webtransport=retain scope=second-backend reason=reliable-browser-fallback-datagram-capabilities");
println!("game-realtime-transport-measure: PASS");
std::process::ExitCode::SUCCESS
},
std::result::Result::Ok(std::result::Result::Err(error)) => {
eprintln!("game-realtime-transport-measure: FAIL: {error}");
std::process::ExitCode::FAILURE
},
std::result::Result::Err(_) => {
eprintln!("game-realtime-transport-measure: FAIL: measurement timeout after {} ms", MEASUREMENT_TIMEOUT.as_millis());
std::process::ExitCode::FAILURE
},
};
}
async fn run_measurements() -> std::result::Result<(), String> {
let websocket_listener = match game_realtime_websocket_lib::WebSocketListener::bind(std::net::SocketAddr::from(([127, 0, 0, 1], 0))).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket measurement listener bind failed: {error}")),
};
let websocket_endpoint = format!("ws://{}/measure", websocket_listener.local_addr());
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport measurement 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 webtransport_listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport measurement listener bind failed: {error}")),
};
let webtransport_endpoint = format!("https://{}/measure", webtransport_listener.local_addr());
let webtransport_client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(webtransport_endpoint.as_str(), certificate_hash) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport measurement client configuration failed: {error}")),
};
let websocket_establishment = match measure_websocket_establishment(&websocket_listener, websocket_endpoint.as_str()).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_latency("websocket", "establishment", ESTABLISHMENT_SAMPLES, 0, websocket_establishment);
let webtransport_establishment = match measure_webtransport_establishment(&mut webtransport_listener, &webtransport_client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_latency("webtransport", "establishment", ESTABLISHMENT_SAMPLES, 0, webtransport_establishment);
let (websocket_client, websocket_server) = match establish_websocket_pair(&websocket_listener, websocket_endpoint.as_str()).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let websocket_rtt = match measure_rtt(websocket_client, websocket_server).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_latency("websocket", "rtt", RTT_SAMPLES, RTT_PAYLOAD_BYTES, websocket_rtt);
let (webtransport_client, webtransport_server) = match establish_webtransport_pair(&mut webtransport_listener, &webtransport_client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let webtransport_rtt = match measure_rtt(webtransport_client, webtransport_server).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_latency("webtransport", "rtt", RTT_SAMPLES, RTT_PAYLOAD_BYTES, webtransport_rtt);
let (websocket_client, websocket_server) = match establish_websocket_pair(&websocket_listener, websocket_endpoint.as_str()).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let websocket_throughput = match measure_transfer(websocket_client, websocket_server, THROUGHPUT_MESSAGES, THROUGHPUT_PAYLOAD_BYTES).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_transfer("websocket", "throughput", &websocket_throughput);
let (webtransport_client, webtransport_server) = match establish_webtransport_pair(&mut webtransport_listener, &webtransport_client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let webtransport_throughput = match measure_transfer(webtransport_client, webtransport_server, THROUGHPUT_MESSAGES, THROUGHPUT_PAYLOAD_BYTES).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_transfer("webtransport", "throughput", &webtransport_throughput);
let (websocket_client, websocket_server) = match establish_websocket_pair(&websocket_listener, websocket_endpoint.as_str()).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let websocket_in_flight = match measure_transfer(websocket_client, websocket_server, IN_FLIGHT_MESSAGES, IN_FLIGHT_PAYLOAD_BYTES).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_transfer("websocket", "in_flight_window", &websocket_in_flight);
let (webtransport_client, webtransport_server) = match establish_webtransport_pair(&mut webtransport_listener, &webtransport_client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let webtransport_in_flight = match measure_transfer(webtransport_client, webtransport_server, IN_FLIGHT_MESSAGES, IN_FLIGHT_PAYLOAD_BYTES).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_transfer("webtransport", "in_flight_window", &webtransport_in_flight);
let datagram = match measure_webtransport_datagrams(&mut webtransport_listener, &webtransport_client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
print_datagram(&datagram);
return std::result::Result::Ok(());
}
async fn measure_websocket_establishment(
listener: &game_realtime_websocket_lib::WebSocketListener,
endpoint: &str,
) -> std::result::Result<LatencySummary, String> {
let mut samples = Vec::with_capacity(ESTABLISHMENT_SAMPLES);
for _ in 0..ESTABLISHMENT_SAMPLES {
let started = std::time::Instant::now();
let (server, client) = tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint));
let _server = match server {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket establishment server failed: {error}")),
};
let _client = match client {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket establishment client failed: {error}")),
};
samples.push(started.elapsed());
}
return summarize_latency(samples.as_slice());
}
async fn measure_webtransport_establishment(
listener: &mut game_realtime_webtransport_lib::WebTransportListener,
client_config: &game_realtime_webtransport_lib::WebTransportClientConfig,
) -> std::result::Result<LatencySummary, String> {
let mut samples = Vec::with_capacity(ESTABLISHMENT_SAMPLES);
for _ in 0..ESTABLISHMENT_SAMPLES {
let started = std::time::Instant::now();
let _pair = match establish_webtransport_pair(listener, client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
samples.push(started.elapsed());
}
return summarize_latency(samples.as_slice());
}
async fn establish_websocket_pair(
listener: &game_realtime_websocket_lib::WebSocketListener,
endpoint: &str,
) -> std::result::Result<(game_realtime_websocket_lib::WebSocketConnection, game_realtime_websocket_lib::WebSocketConnection), String> {
let (server, client) = tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint));
let server = match server {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket server establishment failed: {error}")),
};
let client = match client {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebSocket client establishment failed: {error}")),
};
return std::result::Result::Ok((client, server));
}
async fn establish_webtransport_pair(
listener: &mut game_realtime_webtransport_lib::WebTransportListener,
client_config: &game_realtime_webtransport_lib::WebTransportClientConfig,
) -> std::result::Result<(game_realtime_webtransport_lib::WebTransportConnection, game_realtime_webtransport_lib::WebTransportConnection), String> {
let server = async {
let session = match listener.accept().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport server session establishment failed: {error}")),
};
return match session.accept_primary_connection().await {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(format!("WebTransport server primary stream establishment failed: {error}")),
};
};
let client = async {
let session = match game_realtime_webtransport_lib::connect(client_config).await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("WebTransport client session establishment failed: {error}")),
};
return match session.open_primary_connection().await {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(format!("WebTransport client primary stream establishment failed: {error}")),
};
};
let (server, client) = tokio::join!(server, client);
return match (client, server) {
(std::result::Result::Ok(client), std::result::Result::Ok(server)) => std::result::Result::Ok((client, server)),
(std::result::Result::Err(error), _) => std::result::Result::Err(error),
(_, std::result::Result::Err(error)) => std::result::Result::Err(error),
};
}
async fn measure_rtt<ClientConnection, ServerConnection>(client: ClientConnection, server: ServerConnection) -> std::result::Result<LatencySummary, String>
where
ClientConnection: game_realtime_transport_lib::RealtimeConnection,
ServerConnection: game_realtime_transport_lib::RealtimeConnection,
{
let total_messages = RTT_WARMUP + RTT_SAMPLES;
let payload = vec![0x52_u8; RTT_PAYLOAD_BYTES];
let (mut client_sender, mut client_receiver) = client.split();
let (mut server_sender, mut server_receiver) = server.split();
let server = async move {
for _ in 0..total_messages {
let message = match server_receiver.receive().await {
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Message(value)) => value,
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Closed) => {
return std::result::Result::Err(String::from("RTT server observed an early clean close"));
},
std::result::Result::Err(error) => return std::result::Result::Err(format!("RTT server receive failed: {error}")),
};
if let std::result::Result::Err(error) = server_sender.send(message).await {
return std::result::Result::Err(format!("RTT server echo failed: {error}"));
}
}
let client_close = match server_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("RTT server close observation failed: {error}")),
};
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
return std::result::Result::Err(String::from("RTT server did not observe the client close"));
}
if let std::result::Result::Err(error) = server_sender.close().await {
return std::result::Result::Err(format!("RTT server close failed: {error}"));
}
return std::result::Result::Ok(());
};
let client = async move {
let mut samples = Vec::with_capacity(RTT_SAMPLES);
for index in 0..total_messages {
let started = std::time::Instant::now();
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(payload.clone())).await {
return std::result::Result::Err(format!("RTT client send failed: {error}"));
}
let response = match client_receiver.receive().await {
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Message(value)) => value,
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Closed) => {
return std::result::Result::Err(String::from("RTT client observed an early clean close"));
},
std::result::Result::Err(error) => return std::result::Result::Err(format!("RTT client receive failed: {error}")),
};
if response.as_bytes() != payload.as_slice() {
return std::result::Result::Err(String::from("RTT echo payload mismatch"));
}
if index >= RTT_WARMUP {
samples.push(started.elapsed());
}
}
if let std::result::Result::Err(error) = client_sender.close().await {
return std::result::Result::Err(format!("RTT client close failed: {error}"));
}
let server_close = match client_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("RTT client close observation failed: {error}")),
};
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
return std::result::Result::Err(String::from("RTT client did not observe the server close"));
}
return summarize_latency(samples.as_slice());
};
let (server, client) = tokio::join!(server, client);
match server {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return client;
}
async fn measure_transfer<ClientConnection, ServerConnection>(
client: ClientConnection,
server: ServerConnection,
messages: usize,
payload_bytes: usize,
) -> std::result::Result<TransferSummary, String>
where
ClientConnection: game_realtime_transport_lib::RealtimeConnection,
ServerConnection: game_realtime_transport_lib::RealtimeConnection,
{
let payload = vec![0x54_u8; payload_bytes];
let (mut client_sender, mut client_receiver) = client.split();
let (mut server_sender, mut server_receiver) = server.split();
let server = async move {
for _ in 0..messages {
let received = match server_receiver.receive().await {
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Message(value)) => value,
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Closed) => {
return std::result::Result::Err(String::from("transfer server observed an early clean close"));
},
std::result::Result::Err(error) => return std::result::Result::Err(format!("transfer server receive failed: {error}")),
};
if received.len() != payload_bytes {
return std::result::Result::Err(format!("transfer server payload size mismatch: expected {payload_bytes}, got {}", received.len()));
}
}
if let std::result::Result::Err(error) = server_sender.send(game_realtime_transport_lib::TransportMessage::new(ACK_PAYLOAD.to_vec())).await {
return std::result::Result::Err(format!("transfer server ACK failed: {error}"));
}
let client_close = match server_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("transfer server close observation failed: {error}")),
};
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
return std::result::Result::Err(String::from("transfer server did not observe the client close"));
}
if let std::result::Result::Err(error) = server_sender.close().await {
return std::result::Result::Err(format!("transfer server close failed: {error}"));
}
return std::result::Result::Ok(());
};
let client = async move {
let started = std::time::Instant::now();
for _ in 0..messages {
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(payload.clone())).await {
return std::result::Result::Err(format!("transfer client send failed: {error}"));
}
}
let ack = match client_receiver.receive().await {
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Message(value)) => value,
std::result::Result::Ok(game_realtime_transport_lib::TransportReceive::Closed) => {
return std::result::Result::Err(String::from("transfer client observed close before ACK"));
},
std::result::Result::Err(error) => return std::result::Result::Err(format!("transfer client ACK receive failed: {error}")),
};
if ack.as_bytes() != ACK_PAYLOAD {
return std::result::Result::Err(String::from("transfer ACK payload mismatch"));
}
let elapsed = started.elapsed();
if let std::result::Result::Err(error) = client_sender.close().await {
return std::result::Result::Err(format!("transfer client close failed: {error}"));
}
let server_close = match client_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("transfer client close observation failed: {error}")),
};
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
return std::result::Result::Err(String::from("transfer client did not observe the server close"));
}
return std::result::Result::Ok(TransferSummary { elapsed, messages, payload_bytes });
};
let (server, client) = tokio::join!(server, client);
match server {
std::result::Result::Ok(()) => {},
std::result::Result::Err(error) => return std::result::Result::Err(error),
}
return client;
}
async fn measure_webtransport_datagrams(
listener: &mut game_realtime_webtransport_lib::WebTransportListener,
client_config: &game_realtime_webtransport_lib::WebTransportClientConfig,
) -> std::result::Result<DatagramSummary, String> {
let (server, client) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(client_config));
let server = match server {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("datagram measurement server session failed: {error}")),
};
let client = match client {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("datagram measurement client session failed: {error}")),
};
let client_max = client.max_datagram_size();
let server_max = server.max_datagram_size();
let negotiated_max = core::cmp::min(client_max, server_max);
if negotiated_max == 0 {
return std::result::Result::Err(String::from("datagram measurement negotiated a zero-byte payload bound"));
}
let payload_bytes = core::cmp::min(DATAGRAM_PAYLOAD_BYTES, negotiated_max);
let payload = vec![0x44_u8; payload_bytes];
let started = std::time::Instant::now();
for _ in 0..DATAGRAM_SAMPLES {
if let std::result::Result::Err(error) = client.send_datagram(payload.as_slice()) {
return std::result::Result::Err(format!("datagram measurement send failed: {error}"));
}
}
let mut received = 0_usize;
while received < DATAGRAM_SAMPLES {
let datagram = tokio::time::timeout(DATAGRAM_RECEIVE_TIMEOUT, server.receive_datagram()).await;
match datagram {
std::result::Result::Ok(std::result::Result::Ok(value)) => {
if value.len() != payload_bytes {
return std::result::Result::Err(format!("datagram measurement payload size mismatch: expected {payload_bytes}, got {}", value.len()));
}
received += 1;
},
std::result::Result::Ok(std::result::Result::Err(error)) => {
return std::result::Result::Err(format!("datagram measurement receive failed: {error}"));
},
std::result::Result::Err(_) => break,
}
}
return std::result::Result::Ok(DatagramSummary {
elapsed: started.elapsed(),
attempted: DATAGRAM_SAMPLES,
received,
payload_bytes,
client_max,
server_max,
});
}
fn summarize_latency(samples: &[std::time::Duration]) -> std::result::Result<LatencySummary, String> {
if samples.is_empty() {
return std::result::Result::Err(String::from("latency summary requires at least one sample"));
}
let mut values: Vec<u128> = samples.iter().map(std::time::Duration::as_micros).collect();
values.sort_unstable();
let min_us = match values.first() {
Some(value) => *value,
None => return std::result::Result::Err(String::from("latency summary unexpectedly lost all samples")),
};
let max_us = match values.last() {
Some(value) => *value,
None => return std::result::Result::Err(String::from("latency summary unexpectedly lost all samples")),
};
let median_us = match values.get(values.len() / 2) {
Some(value) => *value,
None => return std::result::Result::Err(String::from("latency median index is invalid")),
};
let p95_rank = values.len().saturating_mul(95).div_ceil(100);
let p95_index = p95_rank.saturating_sub(1);
let p95_us = match values.get(p95_index) {
Some(value) => *value,
None => return std::result::Result::Err(String::from("latency p95 index is invalid")),
};
return std::result::Result::Ok(LatencySummary { min_us, median_us, p95_us, max_us });
}
fn print_latency(transport: &str, metric: &str, samples: usize, payload_bytes: usize, summary: LatencySummary) {
println!(
"MEASURE transport={transport} metric={metric} samples={samples} payload_bytes={payload_bytes} min_us={} median_us={} p95_us={} max_us={}",
summary.min_us, summary.median_us, summary.p95_us, summary.max_us
);
}
fn print_transfer(transport: &str, metric: &str, summary: &TransferSummary) {
let elapsed_seconds = summary.elapsed.as_secs_f64();
let total_bytes = summary.messages.saturating_mul(summary.payload_bytes);
let mib_per_second = if elapsed_seconds > 0.0 { (total_bytes as f64 / (1024.0 * 1024.0)) / elapsed_seconds } else { 0.0 };
let messages_per_second = if elapsed_seconds > 0.0 { summary.messages as f64 / elapsed_seconds } else { 0.0 };
println!(
"MEASURE transport={transport} metric={metric} messages={} payload_bytes={} total_bytes={total_bytes} elapsed_us={} mib_per_s={mib_per_second:.3} messages_per_s={messages_per_second:.3}",
summary.messages,
summary.payload_bytes,
summary.elapsed.as_micros()
);
}
fn print_datagram(summary: &DatagramSummary) {
let ratio = if summary.attempted == 0 { 0.0 } else { summary.received as f64 / summary.attempted as f64 };
println!(
"MEASURE transport=webtransport-datagram metric=bounded_burst attempted={} received={} receive_ratio={ratio:.6} payload_bytes={} client_max={} server_max={} elapsed_us={}",
summary.attempted,
summary.received,
summary.payload_bytes,
summary.client_max,
summary.server_max,
summary.elapsed.as_micros()
);
}

View File

@@ -0,0 +1,30 @@
# file: crates/apps/game-realtime-webtransport-browser-smoke/Cargo.toml
# version: 1
[package]
name = "game-realtime-webtransport-browser-smoke"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish.workspace = true
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
game-logging-lib = { path = "../../common/game-logging-lib" }
tokio = { workspace = true, features = ["macros", "rt", "time"] }
tracing.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,96 @@
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/browser.rs
// version: 1
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
const CERTIFICATE_HASH_HEX_SIZE: usize = 64;
const CERTIFICATE_HASH_SIZE: usize = 32;
/// Runs one real-browser WebTransport round-trip against the supplied pinned endpoint.
#[wasm_bindgen::prelude::wasm_bindgen]
pub async fn run_browser_smoke(endpoint: String, certificate_sha256_hex: String) -> Result<String, wasm_bindgen::JsValue> {
let certificate_hash = match decode_certificate_hash(certificate_sha256_hex.as_str()) {
Ok(value) => game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256(value),
Err(error) => return Err(wasm_bindgen::JsValue::from_str(error.as_str())),
};
let transport = game_realtime_webtransport_lib::WebTransportConfig::default()
.with_connect_timeout(std::time::Duration::from_secs(5))
.with_primary_stream_timeout(std::time::Duration::from_secs(5))
.with_send_timeout(std::time::Duration::from_secs(5));
let config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
Ok(value) => value.with_transport_config(transport),
Err(error) => return Err(js_transport_error("browser client configuration failed", error)),
};
let session = match game_realtime_webtransport_lib::connect(&config).await {
Ok(value) => value,
Err(error) => return Err(js_transport_error("browser client connection failed", error)),
};
let connection = match session.open_primary_connection().await {
Ok(value) => value,
Err(error) => return Err(js_transport_error("browser primary stream open failed", error)),
};
let (mut sender, mut receiver) = connection.split();
if let Err(error) = sender.send(game_realtime_transport_lib::TransportMessage::new(crate::shared::BROWSER_PAYLOAD.to_vec())).await {
return Err(js_transport_error("browser send failed", error));
}
let received = match receiver.receive().await {
Ok(value) => value,
Err(error) => return Err(js_transport_error("browser receive failed", error)),
};
if !receive_matches(received, crate::shared::SERVER_PAYLOAD) {
return Err(wasm_bindgen::JsValue::from_str("browser did not receive the expected server payload"));
}
if let Err(error) = sender.close().await {
return Err(js_transport_error("browser close failed", error));
}
let remote_close = match receiver.receive().await {
Ok(value) => value,
Err(error) => return Err(js_transport_error("browser close observation failed", error)),
};
if remote_close != game_realtime_transport_lib::TransportReceive::Closed {
return Err(wasm_bindgen::JsValue::from_str("browser did not observe the server FIN"));
}
return Ok(String::from("game-realtime-webtransport-browser-smoke: PASS"));
}
fn decode_certificate_hash(value: &str) -> Result<[u8; CERTIFICATE_HASH_SIZE], String> {
let encoded = value.as_bytes();
if encoded.len() != CERTIFICATE_HASH_HEX_SIZE {
return Err(String::from("certificate SHA-256 hash must contain exactly 64 hexadecimal characters"));
}
let mut decoded = [0_u8; CERTIFICATE_HASH_SIZE];
for index in 0..CERTIFICATE_HASH_SIZE {
let high = match hex_nibble(encoded[index * 2]) {
Some(value) => value,
None => return Err(String::from("certificate SHA-256 hash contains a non-hexadecimal character")),
};
let low = match hex_nibble(encoded[index * 2 + 1]) {
Some(value) => value,
None => return Err(String::from("certificate SHA-256 hash contains a non-hexadecimal character")),
};
decoded[index] = (high << 4) | low;
}
return Ok(decoded);
}
fn hex_nibble(value: u8) -> Option<u8> {
return match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
};
}
fn js_transport_error(context: &str, error: game_realtime_transport_lib::TransportError) -> wasm_bindgen::JsValue {
return wasm_bindgen::JsValue::from_str(format!("{context}: {error}").as_str());
}
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
return match receive {
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
game_realtime_transport_lib::TransportReceive::Closed => false,
};
}

View File

@@ -0,0 +1,17 @@
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/lib.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! WebAssembly adapter for the real-browser WebTransport smoke.
#[cfg(target_arch = "wasm32")]
mod browser;
#[cfg(target_arch = "wasm32")]
mod shared;
#[cfg(target_arch = "wasm32")]
/// Re-export of the browser smoke entry point exposed through wasm-bindgen.
pub use self::browser::run_browser_smoke;

View File

@@ -0,0 +1,144 @@
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Native peer for the real-browser WebTransport smoke.
#[cfg(not(target_arch = "wasm32"))]
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
#[cfg(not(target_arch = "wasm32"))]
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
#[cfg(not(target_arch = "wasm32"))]
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
#[cfg(not(target_arch = "wasm32"))]
mod shared;
#[cfg(not(target_arch = "wasm32"))]
const BROWSER_HOST_URL: &str = "http://127.0.0.1:1435/main.html";
#[cfg(not(target_arch = "wasm32"))]
const BROWSER_SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
#[cfg(not(target_arch = "wasm32"))]
const TRACING_TARGET: &str = "games::realtime::webtransport::browser-smoke";
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::process::ExitCode {
let _logging_guard = match game_logging_lib::init_console_tracing() {
std::result::Result::Ok(guard) => guard,
std::result::Result::Err(error) => {
eprintln!("failed to initialize browser WebTransport smoke tracing: {error}");
return std::process::ExitCode::FAILURE;
},
};
let prepared = match prepare_listener() {
Ok(value) => value,
Err(error) => {
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "browser WebTransport smoke preparation failed");
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: {error}");
return std::process::ExitCode::FAILURE;
},
};
let (mut listener, endpoint, certificate_hash_hex) = prepared;
let browser_url = format!("{BROWSER_HOST_URL}?endpoint={endpoint}&sha256={certificate_hash_hex}");
tracing::info!(target: TRACING_TARGET, endpoint = endpoint.as_str(), "browser WebTransport smoke peer ready");
println!("game-realtime-webtransport-browser-smoke: ENDPOINT={endpoint}");
println!("game-realtime-webtransport-browser-smoke: CERT_SHA256={certificate_hash_hex}");
println!("game-realtime-webtransport-browser-smoke: OPEN={browser_url}");
let result = tokio::time::timeout(BROWSER_SMOKE_TIMEOUT, serve_browser(&mut listener)).await;
return match result {
Ok(Ok(())) => {
tracing::info!(target: TRACING_TARGET, "browser WebTransport smoke passed");
println!("game-realtime-webtransport-browser-smoke: PASS");
std::process::ExitCode::SUCCESS
},
Ok(Err(error)) => {
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "browser WebTransport smoke failed");
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: {error}");
std::process::ExitCode::FAILURE
},
Err(_) => {
tracing::error!(target: TRACING_TARGET, timeout_ms = BROWSER_SMOKE_TIMEOUT.as_millis(), "browser WebTransport smoke timed out");
eprintln!("game-realtime-webtransport-browser-smoke: FAIL: browser did not complete the smoke before the launcher deadline");
std::process::ExitCode::FAILURE
},
};
}
#[cfg(target_arch = "wasm32")]
fn main() {
return;
}
#[cfg(not(target_arch = "wasm32"))]
fn prepare_listener() -> Result<(game_realtime_webtransport_lib::WebTransportListener, String, String), String> {
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
Ok(value) => value,
Err(error) => return Err(format!("loopback identity generation failed: {error}")),
};
let certificate_hash_hex = encode_hex(identity.certificate_hash().as_bytes());
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
let listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
Ok(value) => value,
Err(error) => return Err(format!("listener bind failed: {error}")),
};
let endpoint = format!("https://{}/browser-smoke", listener.local_addr());
return Ok((listener, endpoint, certificate_hash_hex));
}
#[cfg(not(target_arch = "wasm32"))]
async fn serve_browser(listener: &mut game_realtime_webtransport_lib::WebTransportListener) -> Result<(), String> {
let session = match listener.accept().await {
Ok(value) => value,
Err(error) => return Err(format!("server accept failed: {error}")),
};
let connection = match session.accept_primary_connection().await {
Ok(value) => value,
Err(error) => return Err(format!("server primary stream accept failed: {error}")),
};
let (mut sender, mut receiver) = connection.split();
let browser_received = match receiver.receive().await {
Ok(value) => value,
Err(error) => return Err(format!("server receive failed: {error}")),
};
if !receive_matches(browser_received, shared::BROWSER_PAYLOAD) {
return Err(String::from("server did not receive the expected browser payload"));
}
if let Err(error) = sender.send(game_realtime_transport_lib::TransportMessage::new(shared::SERVER_PAYLOAD.to_vec())).await {
return Err(format!("server send failed: {error}"));
}
let browser_close = match receiver.receive().await {
Ok(value) => value,
Err(error) => return Err(format!("server close observation failed: {error}")),
};
if browser_close != game_realtime_transport_lib::TransportReceive::Closed {
return Err(String::from("server did not observe the browser FIN"));
}
if let Err(error) = sender.close().await {
return Err(format!("server close failed: {error}"));
}
return Ok(());
}
#[cfg(not(target_arch = "wasm32"))]
fn encode_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(bytes.len() * 2);
for byte in bytes {
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
return encoded;
}
#[cfg(not(target_arch = "wasm32"))]
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
return match receive {
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
game_realtime_transport_lib::TransportReceive::Closed => false,
};
}

View File

@@ -0,0 +1,7 @@
// file: crates/apps/game-realtime-webtransport-browser-smoke/src/shared.rs
// version: 1
/// Binary payload sent from the real browser to the native smoke peer.
pub(crate) const BROWSER_PAYLOAD: &[u8] = b"games.sasedev-webtransport-browser-client-smoke";
/// Binary payload sent from the native smoke peer back to the real browser.
pub(crate) const SERVER_PAYLOAD: &[u8] = b"games.sasedev-webtransport-browser-server-smoke";

View File

@@ -0,0 +1,20 @@
# file: crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml
# version: 1
[package]
name = "game-realtime-webtransport-datagram-smoke"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
game-logging-lib = { path = "../../common/game-logging-lib" }
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
tokio = { workspace = true, features = ["macros", "rt", "time"] }
tracing.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,101 @@
// file: crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Executable localhost smoke for the backend-specific WebTransport datagram capability.
const CLIENT_DATAGRAM: &[u8] = b"games.sasedev-webtransport-client-datagram";
const DATAGRAM_RECEIVE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
const SERVER_DATAGRAM: &[u8] = b"games.sasedev-webtransport-server-datagram";
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const TRACING_TARGET: &str = "games::realtime::webtransport::datagram-smoke";
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::process::ExitCode {
let _logging_guard = match game_logging_lib::init_console_tracing() {
std::result::Result::Ok(guard) => guard,
std::result::Result::Err(error) => {
eprintln!("failed to initialize WebTransport datagram smoke tracing: {error}");
return std::process::ExitCode::FAILURE;
},
};
tracing::info!(target: TRACING_TARGET, "WebTransport datagram smoke started");
let result = tokio::time::timeout(SMOKE_TIMEOUT, run_smoke()).await;
return match result {
std::result::Result::Ok(std::result::Result::Ok(())) => {
tracing::info!(target: TRACING_TARGET, "WebTransport datagram smoke passed");
println!("game-realtime-webtransport-datagram-smoke: PASS");
std::process::ExitCode::SUCCESS
},
std::result::Result::Ok(std::result::Result::Err(error)) => {
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "WebTransport datagram smoke failed");
eprintln!("game-realtime-webtransport-datagram-smoke: FAIL: {error}");
std::process::ExitCode::FAILURE
},
std::result::Result::Err(_) => {
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "WebTransport datagram smoke timed out");
eprintln!("game-realtime-webtransport-datagram-smoke: FAIL: smoke timed out");
std::process::ExitCode::FAILURE
},
};
}
async fn run_smoke() -> std::result::Result<(), String> {
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("loopback identity generation failed: {error}")),
};
let certificate_hash = identity.certificate_hash().clone();
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("listener bind failed: {error}")),
};
let endpoint = format!("https://{}/datagram-smoke", listener.local_addr());
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client configuration failed: {error}")),
};
let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
let server_session = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("server accept failed: {error}")),
};
let client_session = match client_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client connect failed: {error}")),
};
let client_max = client_session.max_datagram_size();
let server_max = server_session.max_datagram_size();
if CLIENT_DATAGRAM.len() > client_max || SERVER_DATAGRAM.len() > server_max {
return std::result::Result::Err(format!("negotiated datagram capacity too small: client_max={client_max}, server_max={server_max}"));
}
tracing::info!(target: TRACING_TARGET, client_max, server_max, "WebTransport datagram capacities negotiated");
if let std::result::Result::Err(error) = client_session.send_datagram(CLIENT_DATAGRAM) {
return std::result::Result::Err(format!("client datagram send failed: {error}"));
}
let server_received = match tokio::time::timeout(DATAGRAM_RECEIVE_TIMEOUT, server_session.receive_datagram()).await {
std::result::Result::Ok(std::result::Result::Ok(value)) => value,
std::result::Result::Ok(std::result::Result::Err(error)) => return std::result::Result::Err(format!("server datagram receive failed: {error}")),
std::result::Result::Err(_) => return std::result::Result::Err(String::from("server datagram receive timed out; datagram delivery is not guaranteed")),
};
if server_received.as_slice() != CLIENT_DATAGRAM {
return std::result::Result::Err(String::from("server received an unexpected datagram payload"));
}
if let std::result::Result::Err(error) = server_session.send_datagram(SERVER_DATAGRAM) {
return std::result::Result::Err(format!("server datagram send failed: {error}"));
}
let client_received = match tokio::time::timeout(DATAGRAM_RECEIVE_TIMEOUT, client_session.receive_datagram()).await {
std::result::Result::Ok(std::result::Result::Ok(value)) => value,
std::result::Result::Ok(std::result::Result::Err(error)) => return std::result::Result::Err(format!("client datagram receive failed: {error}")),
std::result::Result::Err(_) => return std::result::Result::Err(String::from("client datagram receive timed out; datagram delivery is not guaranteed")),
};
if client_received.as_slice() != SERVER_DATAGRAM {
return std::result::Result::Err(String::from("client received an unexpected datagram payload"));
}
return std::result::Result::Ok(());
}

View File

@@ -0,0 +1,21 @@
# file: crates/apps/game-realtime-webtransport-smoke/Cargo.toml
# version: 1
[package]
name = "game-realtime-webtransport-smoke"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
game-logging-lib = { path = "../../common/game-logging-lib" }
game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }
game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }
tokio = { workspace = true, features = ["macros", "rt", "time"] }
tracing.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,134 @@
// file: crates/apps/game-realtime-webtransport-smoke/src/main.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Executable localhost smoke for the public games.sasedev native WebTransport realtime path.
use game_realtime_transport_lib::RealtimeConnection; // rust-rules: trait-import
use game_realtime_transport_lib::RealtimeReceiver; // rust-rules: trait-import
use game_realtime_transport_lib::RealtimeSender; // rust-rules: trait-import
const CLIENT_PAYLOAD: &[u8] = b"games.sasedev-webtransport-client-smoke";
const SERVER_PAYLOAD: &[u8] = b"games.sasedev-webtransport-server-smoke";
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const TRACING_TARGET: &str = "games::realtime::webtransport::smoke";
#[tokio::main(flavor = "current_thread")]
async fn main() -> std::process::ExitCode {
let _logging_guard = match game_logging_lib::init_console_tracing() {
std::result::Result::Ok(guard) => guard,
std::result::Result::Err(error) => {
eprintln!("failed to initialize realtime WebTransport smoke tracing: {error}");
return std::process::ExitCode::FAILURE;
},
};
tracing::info!(target: TRACING_TARGET, "realtime WebTransport smoke started");
let result = tokio::time::timeout(SMOKE_TIMEOUT, run_smoke()).await;
return match result {
std::result::Result::Ok(std::result::Result::Ok(())) => {
tracing::info!(target: TRACING_TARGET, "realtime WebTransport smoke passed");
println!("game-realtime-webtransport-smoke: PASS");
std::process::ExitCode::SUCCESS
},
std::result::Result::Ok(std::result::Result::Err(error)) => {
tracing::error!(target: TRACING_TARGET, detail = error.as_str(), "realtime WebTransport smoke failed");
eprintln!("game-realtime-webtransport-smoke: FAIL: {error}");
std::process::ExitCode::FAILURE
},
std::result::Result::Err(_) => {
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "realtime WebTransport smoke timed out");
eprintln!("game-realtime-webtransport-smoke: FAIL: smoke timed out");
std::process::ExitCode::FAILURE
},
};
}
async fn run_smoke() -> std::result::Result<(), String> {
let identity = match game_realtime_webtransport_lib::WebTransportServerIdentity::generate_loopback() {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("loopback identity generation failed: {error}")),
};
let certificate_hash = identity.certificate_hash().clone();
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let server_config = game_realtime_webtransport_lib::WebTransportServerConfig::new(bind_address, identity);
let mut listener = match game_realtime_webtransport_lib::WebTransportListener::bind(server_config) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("listener bind failed: {error}")),
};
let endpoint = format!("https://{}/smoke", listener.local_addr());
let client_config = match game_realtime_webtransport_lib::WebTransportClientConfig::new(endpoint.as_str(), certificate_hash) {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client configuration failed: {error}")),
};
tracing::info!(target: TRACING_TARGET, endpoint = endpoint.as_str(), "loopback endpoint bound with exact certificate pin");
let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_webtransport_lib::connect(&client_config));
let server_session = match server_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("server accept failed: {error}")),
};
let client_session = match client_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client connect failed: {error}")),
};
let client_connection = match client_session.open_primary_connection().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client primary stream open failed: {error}")),
};
let server_connection = match server_session.accept_primary_connection().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("server primary stream accept failed: {error}")),
};
let (mut server_sender, mut server_receiver) = server_connection.split();
let (mut client_sender, mut client_receiver) = client_connection.split();
if let std::result::Result::Err(error) = client_sender.send(game_realtime_transport_lib::TransportMessage::new(CLIENT_PAYLOAD.to_vec())).await {
return std::result::Result::Err(format!("client send failed: {error}"));
}
let server_received = match server_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("server receive failed: {error}")),
};
if !receive_matches(server_received, CLIENT_PAYLOAD) {
return std::result::Result::Err(String::from("server did not receive the expected client payload"));
}
if let std::result::Result::Err(error) = server_sender.send(game_realtime_transport_lib::TransportMessage::new(SERVER_PAYLOAD.to_vec())).await {
return std::result::Result::Err(format!("server send failed: {error}"));
}
let client_received = match client_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client receive failed: {error}")),
};
if !receive_matches(client_received, SERVER_PAYLOAD) {
return std::result::Result::Err(String::from("client did not receive the expected server payload"));
}
if let std::result::Result::Err(error) = client_sender.close().await {
return std::result::Result::Err(format!("client close failed: {error}"));
}
let server_close = match server_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("server close observation failed: {error}")),
};
if server_close != game_realtime_transport_lib::TransportReceive::Closed {
return std::result::Result::Err(String::from("server did not observe the client FIN"));
}
if let std::result::Result::Err(error) = server_sender.close().await {
return std::result::Result::Err(format!("server close failed: {error}"));
}
let client_close = match client_receiver.receive().await {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(format!("client close observation failed: {error}")),
};
if client_close != game_realtime_transport_lib::TransportReceive::Closed {
return std::result::Result::Err(String::from("client did not observe the server FIN"));
}
return std::result::Result::Ok(());
}
fn receive_matches(receive: game_realtime_transport_lib::TransportReceive, expected: &[u8]) -> bool {
return match receive {
game_realtime_transport_lib::TransportReceive::Message(message) => message.as_bytes() == expected,
game_realtime_transport_lib::TransportReceive::Closed => false,
};
}

View File

@@ -1,5 +1,5 @@
<!-- file: crates/common/game-realtime-transport-lib/README.md --> <!-- file: crates/common/game-realtime-transport-lib/README.md -->
<!-- version: 1 --> <!-- version: 2 -->
# game-realtime-transport-lib # game-realtime-transport-lib
@@ -39,4 +39,4 @@ Une fermeture distante propre est représentée par `TransportReceive::Closed`.
Cette crate ne dépend d'aucun backend realtime. Les implémentations concrètes dépendent d'elle, jamais l'inverse. Cette crate ne dépend d'aucun backend realtime. Les implémentations concrètes dépendent d'elle, jamais l'inverse.
Le premier backend de référence est `game-realtime-websocket-lib`. Le POC WebTransport/QUIC prévu ensuite doit d'abord challenger cette même frontière avant toute généralisation supplémentaire. Les deux implémentations concrètes retenues à lissue de `0.3.5` sont `game-realtime-websocket-lib`, baseline/fallback de référence, et `game-realtime-webtransport-lib`, second backend fiable. Les datagrams WebTransport restent volontairement hors de cette crate parce quils ne partagent ni la fiabilité ni lordre garantis par `RealtimeConnection`.

View File

@@ -0,0 +1,32 @@
# file: crates/common/game-realtime-webtransport-lib/Cargo.toml
# version: 4
[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" }
tracing.workspace = true
url.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rcgen = { workspace = true, features = ["ring"] }
tokio = { workspace = true, features = ["time"] }
web-transport-quinn = { workspace = true, features = ["ring"] }
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-util = { workspace = true, features = ["std"] }
gloo-timers = { workspace = true, features = ["futures"] }
web-transport-wasm.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time"] }
[lints]
workspace = true

View File

@@ -0,0 +1,137 @@
<!-- file: crates/common/game-realtime-webtransport-lib/README.md -->
<!-- version: 7 -->
# game-realtime-webtransport-lib
Backend WebTransport/QUIC retenu comme second backend realtime de `games.sasedev`, aux côtés de WebSocket qui reste la baseline/fallback de référence.
## Responsabilité
La crate possède le transport WebTransport concret sans introduire de sémantique gameplay, room, joueur, tick ou snapshot. Le chemin natif repose sur `web-transport-quinn`; le chemin client `wasm32-unknown-unknown` repose sur `web-transport-wasm` et l'API WebTransport du navigateur. Les deux conservent les erreurs publiques dans `game-realtime-transport-lib`.
La frontière fiable 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 sur les chemins natif et navigateur pour la connexion, l'ouverture/accept du stream primaire et un envoi complet ;
- adaptation `RealtimeConnection` / `RealtimeSender` / `RealtimeReceiver` ;
- FIN propre via `RealtimeSender::close()` ;
- reset/STOP_SENDING backend-spécifiques via `WebTransportSender::abort(...)` et `WebTransportReceiver::abort(...)` ;
- 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`;
- client WASM avec endpoint HTTPS, hash certificat SHA-256 explicite, établissement de session navigateur et ouverture du stream bidirectionnel primaire;
- adaptation WASM du framing `u32` big-endian et des traits realtime, y compris FIN, reset/STOP et réception incrémentale.
- capacité datagram WebTransport backend-spécifique sur les sessions native et navigateur, sans extension de `RealtimeConnection`.
## Chemin navigateur/WASM
Pour `wasm32-unknown-unknown`, la crate remplace les dépendances natives Quinn/Tokio/rcgen par `web-transport-wasm`. Le build final reçoit `--cfg=web_sys_unstable_apis` uniquement pour cette cible via `.cargo/config.toml`, conformément à l'exigence actuelle des bindings WebTransport de `web-sys`.
L'API client garde les mêmes noms de surface que le client natif : `WebTransportCertificateHash`, `WebTransportClientConfig`, `connect`, `WebTransportSession`, `WebTransportConnection`, `WebTransportSender` et `WebTransportReceiver`. Le navigateur ouvre toujours le stream bidirectionnel primaire côté client ; le serveur reste le backend Rust natif.
Le pin SHA-256 est transmis à `WebTransportOptions.serverCertificateHashes`; aucune variante navigateur sans validation TLS n'est ajoutée. Le framing applicatif reste strictement identique au natif.
À partir de `alpha.7`, les deadlines `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont aussi matérialisées côté navigateur par des timers WASM. Une deadline navigateur doit tenir dans la plage `u32` millisecondes ; une valeur supérieure est rejetée comme `InvalidConfiguration`. Une réception idle reste volontairement sans timeout implicite, comme sur le chemin natif. Le smoke navigateur technique séparé prouve ensuite cette surface avec un vrai navigateur sans fallback WebSocket.
## Datagrams backend-spécifiques
`WebTransportSession` expose `max_datagram_size()`, `send_datagram(...)` et `receive_datagram()` sur les chemins natif et navigateur. Cette surface reste volontairement propre au backend WebTransport : un datagram n'est ni fiable ni ordonné et ne peut donc pas satisfaire le contrat `RealtimeConnection`.
La taille maximale dépend de la session et du chemin réseau. Le backend rejette localement un payload supérieur à `max_datagram_size()` avec `TransportErrorKind::MessageTooLarge`; cette valeur peut évoluer entre sessions et ne doit pas être traitée comme une constante produit.
`receive_datagram()` n'impose aucun timeout. Le consommateur doit borner l'attente selon son scénario. Le smoke `game-realtime-webtransport-datagram-smoke` utilise une deadline locale précisément parce que la perte d'un datagram est un résultat autorisé par le protocole. Il vérifie un échange loopback dans les deux sens sans en déduire de garantie de livraison ou d'ordre.
Le chemin navigateur compile la même capacité via `web-transport-wasm`; `alpha.9` ne réouvre pas le smoke navigateur interactif et limite la preuve runtime datagram au backend natif.
## TLS de développement
`WebTransportServerIdentity::generate_loopback()` crée une identité en mémoire. La clé privée n'est ni écrite ni versionnée. Le certificat est valide sept jours, avec une petite marge de clock skew, et son SHA-256 est exposé à travers `WebTransportCertificateHash` afin que le client puisse utiliser le pinning fourni par `web-transport-quinn`.
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.
Sur les chemins natif et navigateur, les deadlines configurables couvrent :
- connexion client et réponse finale à une requête WebTransport déjà surfacée côté serveur ;
- ouverture ou accept du stream bidirectionnel principal ;
- é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. Côté navigateur, le timer est porté par `gloo-timers` et la future WebTransport abandonnée reste traitée selon la sémantique cancel-safe du wrapper amont.
## Lifecycle, abort et cancellation
`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` sur les chemins natif et navigateur.
Une longueur entrante hors limite ou un framing tronqué provoque aussi l'arrêt de la direction de réception afin d'éviter de poursuivre sur un flux désynchronisé.
## Frontières actuelles
La crate ne possède toujours pas :
- d'API datagram transport-neutral ;
- de serveur WebTransport WASM ;
- de fallback WebSocket dans le backend ;
- de sélection dynamique de transport ;
- de protocole wire/session ou de synchronisation gameplay.
Le fallback WebSocket est prouvé au niveau composition par `game-realtime-transport-fallback-smoke`. La caractérisation comparative est portée par `game-realtime-transport-measure` et documentée dans `docs/studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md`; elle ne fait pas partie de la responsabilité de cette crate.
Le chemin natif s'exécute sous un runtime Tokio fourni par le consommateur ; la crate ne crée ni runtime ni thread privé. Les dépendances navigateur sont target-specific et ne sont donc pas tirées par le backend natif. Réciproquement, `web-transport-quinn`, Tokio et rcgen ne sont pas requis pour construire la crate en `wasm32-unknown-unknown`.
## Portabilité validée en 0.3.5
La validation de la version couvre Linux natif client/server, le client navigateur/WASM réel et la cross-compilation du backend pour `aarch64-linux-android` avec API 21. Cette dernière preuve est une preuve de compilation, pas un smoke réseau sur appareil Android. Les plateformes Apple et les autres ABI Android ne sont pas déclarées validées par `0.3.5`.

View File

@@ -0,0 +1,202 @@
<!-- file: crates/common/game-realtime-webtransport-lib/USAGE.md -->
<!-- version: 6 -->
# Utilisation de game-realtime-webtransport-lib
Ce guide décrit les chemins fiables natif et client navigateur/WASM exposés par `game-realtime-webtransport-lib`, ainsi que la capacité datagram backend-spécifique. Il ne décrit ni gameplay ni protocole wire métier.
## Configuration transport
`WebTransportConfig` porte les limites et deadlines du chemin fiable. La configuration par défaut garde une limite de 1 MiB par message. Les deadlines sont appliquées sur les chemins natif et navigateur pour la connexion, la sélection du stream primaire et chaque envoi complet.
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`. Côté navigateur, les deadlines doivent également tenir dans la plage `u32` millisecondes imposée par le timer WASM.
## 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.
## Client navigateur/WASM
Pour `wasm32-unknown-unknown`, les mêmes noms `WebTransportCertificateHash`, `WebTransportClientConfig`, `connect` et `WebTransportSession::open_primary_connection()` sont disponibles. Le serveur reste natif ; le navigateur est uniquement client.
Exemple de séquence Rust côté WASM :
```rust
let certificate_hash = game_realtime_webtransport_lib::WebTransportCertificateHash::from_sha256(server_sha256);
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 build workspace fournit `web_sys_unstable_apis` uniquement à `wasm32-unknown-unknown`. Le hash SHA-256 est transmis au navigateur comme `serverCertificateHashes`. La limite de message configurée est appliquée au framing WASM. `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont réalisés par des timers WASM ; une expiration retourne `TransportErrorKind::Timeout`, et un send expiré reset le stream comme sur le chemin natif.
Le host Vite et l'adapter `wasm-bindgen` de `game-realtime-webtransport-browser-smoke` servent uniquement de preuve runtime. Ils ne sont pas nécessaires à un consommateur qui intègre déjà la bibliothèque dans son propre frontend.
## Cross-compilation Android ARM64
`0.3.5` a validé la cross-compilation de la crate pour `aarch64-linux-android` avec le NDK `28.2.13676358` et `minSdk`/API 21. Le NDK moderne expose un Clang dont le nom contient le niveau API ; `ring`/`cc-rs` doit recevoir explicitement ce compilateur si le wrapper générique `aarch64-linux-android-clang` n'est pas présent dans le `PATH`.
Configuration de validation portable à partir du SDK déjà déclaré dans l'environnement :
```bash
ANDROID_SDK="${ANDROID_HOME:-$ANDROID_SDK_ROOT}"
export ANDROID_NDK_HOME="$ANDROID_SDK/ndk/28.2.13676358"
export TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64"
export CC_aarch64_linux_android="$TOOLCHAIN/bin/aarch64-linux-android21-clang"
export CXX_aarch64_linux_android="$TOOLCHAIN/bin/aarch64-linux-android21-clang++"
export AR_aarch64_linux_android="$TOOLCHAIN/bin/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TOOLCHAIN/bin/aarch64-linux-android21-clang"
cargo check -p game-realtime-webtransport-lib --target aarch64-linux-android
```
Ces variables décrivent une commande de cross-compilation et ne doivent pas être remplacées dans le dépôt par un chemin absolu propre à une machine. Cette preuve ne construit pas d'APK/AAB et ne remplace pas un futur smoke WebTransport sur appareil Android si cette cible devient un chemin produit réel.
## 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. Sur les chemins natif et navigateur, un send qui dépasse sa deadline est considéré terminal : le stream est reset et le même sender ne doit pas être réutilisé.
## Fermeture et abort
`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.
## Datagrams WebTransport
Les datagrams sont une capacité propre à `WebTransportSession`; ils ne passent pas par `RealtimeConnection`. Toujours consulter la taille courante avant émission :
```rust
let max_payload = session.max_datagram_size();
if payload.len() <= max_payload {
session.send_datagram(payload)?;
}
```
La réception est asynchrone :
```rust
let payload = session.receive_datagram().await?;
```
Ces appels ne donnent aucune garantie de livraison ni d'ordre. Une perte n'est pas une violation du transport datagram. Toute deadline de réception appartient donc au scénario consommateur ; elle n'est pas injectée dans `WebTransportConfig`.
Le chemin navigateur expose les mêmes noms, avec `send_datagram(...).await`. La surface reste backend-spécifique afin de ne pas imposer une sémantique non fiable à WebSocket ou au contrat fiable commun.

View File

@@ -0,0 +1,109 @@
// file: crates/common/game-realtime-webtransport-lib/src/config.rs
// version: 2
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 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;

View File

@@ -0,0 +1,68 @@
// file: crates/common/game-realtime-webtransport-lib/src/lib.rs
// version: 4
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! WebTransport/QUIC backend candidate for the transport-neutral realtime contract.
mod config;
#[cfg(not(target_arch = "wasm32"))]
mod webtransport;
#[cfg(target_arch = "wasm32")]
mod webtransport_wasm;
/// Re-export of product-facing limits and operation deadlines for the reliable WebTransport path.
pub use self::config::WebTransportConfig;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of the pinned SHA-256 certificate fingerprint used by the native client.
pub use self::webtransport::WebTransportCertificateHash;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of native WebTransport client configuration.
pub use self::webtransport::WebTransportClientConfig;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of an established WebTransport connection adapted to the transport-neutral realtime contract.
pub use self::webtransport::WebTransportConnection;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of the bound native WebTransport listener.
pub use self::webtransport::WebTransportListener;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of the receive half of the primary reliable WebTransport stream.
pub use self::webtransport::WebTransportReceiver;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of the send half of the primary reliable WebTransport stream.
pub use self::webtransport::WebTransportSender;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of native WebTransport server configuration.
pub use self::webtransport::WebTransportServerConfig;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of native WebTransport server TLS identity material.
pub use self::webtransport::WebTransportServerIdentity;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of an established native WebTransport session.
pub use self::webtransport::WebTransportSession;
#[cfg(not(target_arch = "wasm32"))]
/// Re-export of the native WebTransport client establishment function.
pub use self::webtransport::connect;
#[cfg(target_arch = "wasm32")]
/// Re-export of the pinned SHA-256 certificate fingerprint used by the browser WebTransport client.
pub use self::webtransport_wasm::WebTransportCertificateHash;
#[cfg(target_arch = "wasm32")]
/// Re-export of browser WebTransport client configuration.
pub use self::webtransport_wasm::WebTransportClientConfig;
#[cfg(target_arch = "wasm32")]
/// Re-export of an established browser WebTransport connection adapted to the transport-neutral realtime contract.
pub use self::webtransport_wasm::WebTransportConnection;
#[cfg(target_arch = "wasm32")]
/// Re-export of the receive half of the browser primary reliable WebTransport stream.
pub use self::webtransport_wasm::WebTransportReceiver;
#[cfg(target_arch = "wasm32")]
/// Re-export of the send half of the browser primary reliable WebTransport stream.
pub use self::webtransport_wasm::WebTransportSender;
#[cfg(target_arch = "wasm32")]
/// Re-export of an established browser WebTransport session.
pub use self::webtransport_wasm::WebTransportSession;
#[cfg(target_arch = "wasm32")]
/// Re-export of the browser WebTransport client establishment function.
pub use self::webtransport_wasm::connect;

View File

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

View File

@@ -0,0 +1,641 @@
// file: crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs
// version: 3
const CERTIFICATE_HASH_SIZE: usize = 32;
const FRAME_PROTOCOL_ERROR_CODE: u32 = 0x10;
const FRAME_TOO_LARGE_ERROR_CODE: u32 = 0x11;
const PRIMARY_FRAME_HEADER_SIZE: usize = 4;
const SEND_FAILURE_ERROR_CODE: u32 = 0x12;
const SEND_TIMEOUT_ERROR_CODE: u32 = 0x13;
const STREAM_CANCELLED_ERROR_CODE: u32 = 0x14;
const TRACING_TARGET: &str = "games::realtime::webtransport";
/// SHA-256 fingerprint of one certificate accepted by the browser 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;
}
}
/// Browser WebTransport 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.
///
/// The browser path applies the message-size limit and operation deadlines to connect, primary-stream open and send.
#[must_use]
pub fn with_transport_config(mut self, transport: crate::WebTransportConfig) -> Self {
self.transport = transport;
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;
}
}
/// Established browser WebTransport session before the primary application stream is selected.
pub struct WebTransportSession {
inner: web_transport_wasm::Session,
transport: crate::WebTransportConfig,
}
impl WebTransportSession {
fn new(inner: web_transport_wasm::Session, transport: crate::WebTransportConfig) -> Self {
return Self { inner, 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 timeout_millis = match browser_timeout_millis(timeout, "primary_stream_timeout") {
Ok(value) => value,
Err(error) => return Err(error),
};
let opened = await_with_timeout(self.inner.open_bi(), timeout_millis).await;
let (sender, receiver) = match opened {
Some(Ok(value)) => value,
Some(Err(error)) => {
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, error.to_string());
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "browser WebTransport primary bidirectional stream open failed");
return Err(mapped);
},
None => {
let mapped = timeout_error("browser WebTransport primary bidirectional stream open", timeout);
tracing::warn!(target: TRACING_TARGET, timeout_ms = timeout.as_millis(), "browser WebTransport primary bidirectional stream open timed out");
return Err(mapped);
},
};
tracing::debug!(target: TRACING_TARGET, endpoint = self.inner.url().as_str(), "browser WebTransport primary bidirectional stream opened");
return Ok(WebTransportConnection::new(self.inner, sender, receiver, self.transport));
}
/// Returns the endpoint URL backing this browser WebTransport session.
#[must_use]
pub fn endpoint(&self) -> &str {
return self.inner.url().as_str();
}
/// Returns the maximum payload size accepted by the browser WebTransport datagram path.
#[must_use]
pub fn max_datagram_size(&self) -> usize {
return self.inner.max_datagram_size();
}
/// Sends one browser WebTransport datagram without reliability or ordering guarantees.
///
/// This capability is intentionally not part of `RealtimeConnection`.
pub async fn send_datagram(&self, payload: &[u8]) -> Result<(), game_realtime_transport_lib::TransportError> {
let max_datagram_size = self.max_datagram_size();
if payload.len() > max_datagram_size {
return Err(datagram_too_large(payload.len(), max_datagram_size));
}
return match self.inner.send_datagram(payload.to_vec().into()).await {
Ok(()) => Ok(()),
Err(error) => Err(map_write_error(error)),
};
}
/// Receives one browser WebTransport datagram without reliability or ordering guarantees.
///
/// The caller is responsible for applying any operation deadline required by its use case.
pub async fn receive_datagram(&self) -> Result<Vec<u8>, game_realtime_transport_lib::TransportError> {
return match self.inner.recv_datagram().await {
Ok(payload) => Ok(payload.to_vec()),
Err(error) => Err(map_read_error(error)),
};
}
}
/// Established browser WebTransport connection carrying the single reliable primary stream.
pub struct WebTransportConnection {
receiver: web_transport_wasm::RecvStream,
sender: web_transport_wasm::SendStream,
session: web_transport_wasm::Session,
transport: crate::WebTransportConfig,
}
impl WebTransportConnection {
fn new(
session: web_transport_wasm::Session,
sender: web_transport_wasm::SendStream,
receiver: web_transport_wasm::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 browser primary reliable WebTransport stream.
pub struct WebTransportReceiver {
inner: web_transport_wasm::RecvStream,
_session: web_transport_wasm::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, "browser WebTransport receiver is already terminal"));
}
self.inner.stop(code);
self.terminal = true;
tracing::debug!(target: TRACING_TARGET, code = code, "browser WebTransport primary receive stream aborted");
return Ok(());
}
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,
"browser WebTransport receiver is unavailable after a terminal stream failure or abort",
));
}
loop {
if self.header_read < PRIMARY_FRAME_HEADER_SIZE {
let remaining = PRIMARY_FRAME_HEADER_SIZE - self.header_read;
let read = self.inner.read(remaining).await;
match read {
Ok(Some(chunk)) => {
if chunk.is_empty() {
return self.fail_protocol("browser WebTransport primary stream returned an empty read in the middle of a frame header");
}
let end = self.header_read + chunk.len();
self.header[self.header_read..end].copy_from_slice(chunk.as_ref());
self.header_read = end;
continue;
},
Ok(None) => {
if self.header_read == 0 {
self.clean_closed = true;
tracing::debug!(target: TRACING_TARGET, "remote browser WebTransport primary stream closed cleanly");
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
}
return self.fail_protocol("browser 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 browser 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 remaining = self.payload.len() - self.payload_read;
let read = self.inner.read(remaining).await;
match read {
Ok(Some(chunk)) => {
if chunk.is_empty() {
return self.fail_protocol("browser WebTransport primary stream returned an empty read in the middle of a frame payload");
}
let end = self.payload_read + chunk.len();
self.payload[self.payload_read..end].copy_from_slice(chunk.as_ref());
self.payload_read = end;
if self.payload_read < self.payload.len() {
continue;
}
},
Ok(None) => return self.fail_protocol("browser 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 browser 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_wasm::Error,
) -> 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(), "browser 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) {
self.inner.stop(code);
self.terminal = true;
}
}
impl Drop for WebTransportReceiver {
fn drop(&mut self) {
if !self.clean_closed && !self.terminal {
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 browser primary reliable WebTransport stream.
pub struct WebTransportSender {
inner: web_transport_wasm::SendStream,
_session: web_transport_wasm::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, "browser WebTransport sender is already terminal"));
}
self.inner.reset(code);
self.terminal = true;
tracing::debug!(target: TRACING_TARGET, code = code, "browser WebTransport primary send stream aborted");
return Ok(());
}
}
impl Drop for WebTransportSender {
fn drop(&mut self) {
if !self.terminal {
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, "browser WebTransport sender is already terminal"));
}
self.terminal = true;
return match self.inner.finish() {
Ok(()) => {
tracing::debug!(target: TRACING_TARGET, "local browser WebTransport primary stream close initiated");
Ok(())
},
Err(error) => {
let mapped = map_write_error(error);
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "browser 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,
"browser 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 timeout_millis = match browser_timeout_millis(send_timeout, "send_timeout") {
Ok(value) => value,
Err(error) => return Err(error),
};
let mut guard = SendOperationGuard::new(&mut self.inner, &mut self.terminal);
let operation = guard.write_frame(&frame_header, message.as_bytes());
let result = await_with_timeout(operation, timeout_millis).await;
return match result {
Some(Ok(())) => {
guard.complete();
tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "framed browser WebTransport payload sent");
Ok(())
},
Some(Err(error)) => {
guard.abort(SEND_FAILURE_ERROR_CODE);
let mapped = map_write_error(error);
tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, kind = %mapped.kind(), detail = mapped.detail(), "browser WebTransport framed send failed");
Err(mapped)
},
None => {
guard.abort(SEND_TIMEOUT_ERROR_CODE);
let mapped = timeout_error("browser WebTransport framed send", send_timeout);
tracing::warn!(target: TRACING_TARGET, payload_len = payload_len, timeout_ms = send_timeout.as_millis(), "browser WebTransport framed send timed out under flow control/backpressure");
Err(mapped)
},
};
});
}
}
struct SendOperationGuard<'a> {
inner: &'a mut web_transport_wasm::SendStream,
terminal: &'a mut bool,
armed: bool,
}
impl<'a> SendOperationGuard<'a> {
fn new(inner: &'a mut web_transport_wasm::SendStream, terminal: &'a mut bool) -> Self {
return Self { inner, terminal, armed: true };
}
fn abort(&mut self, code: u32) {
if self.armed {
self.inner.reset(code);
*self.terminal = true;
self.armed = false;
}
}
fn complete(&mut self) {
self.armed = false;
}
async fn write_frame(&mut self, frame_header: &[u8; PRIMARY_FRAME_HEADER_SIZE], payload: &[u8]) -> Result<(), web_transport_wasm::Error> {
match self.inner.write(frame_header).await {
Ok(()) => {},
Err(error) => return Err(error),
}
if !payload.is_empty() {
match self.inner.write(payload).await {
Ok(()) => {},
Err(error) => return Err(error),
}
}
return Ok(());
}
}
impl Drop for SendOperationGuard<'_> {
fn drop(&mut self) {
self.abort(STREAM_CANCELLED_ERROR_CODE);
}
}
/// Establishes one browser 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);
}
if let Err(error) = validate_browser_deadlines(config.transport) {
return Err(error);
}
let client = web_transport_wasm::ClientBuilder::new()
.with_pooling(false)
.with_unreliable(true)
.with_server_certificate_hashes(vec![config.certificate_hash.as_bytes().to_vec()]);
let timeout = config.transport.connect_timeout();
let timeout_millis = match browser_timeout_millis(timeout, "connect_timeout") {
Ok(value) => value,
Err(error) => return Err(error),
};
let connected = await_with_timeout(client.connect(config.endpoint.clone()), timeout_millis).await;
let session = match connected {
Some(Ok(value)) => value,
Some(Err(error)) => {
let mapped = transport_error(game_realtime_transport_lib::TransportErrorKind::Connect, error.to_string());
tracing::warn!(
target: TRACING_TARGET,
endpoint = config.endpoint.as_str(),
detail = mapped.detail(),
"browser WebTransport client connection failed"
);
return Err(mapped);
},
None => {
let mapped = timeout_error("browser WebTransport client connect", timeout);
tracing::warn!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), timeout_ms = timeout.as_millis(), "browser WebTransport client connection timed out");
return Err(mapped);
},
};
tracing::info!(target: TRACING_TARGET, endpoint = config.endpoint.as_str(), "browser WebTransport client connected");
return Ok(WebTransportSession::new(session, config.transport));
}
async fn await_with_timeout<F>(operation: F, timeout_millis: u32) -> Option<F::Output>
where
F: core::future::Future,
{
let operation = Box::pin(operation);
let timeout = Box::pin(gloo_timers::future::TimeoutFuture::new(timeout_millis));
return match futures_util::future::select(operation, timeout).await {
futures_util::future::Either::Left((output, _)) => Some(output),
futures_util::future::Either::Right(((), _)) => None,
};
}
fn browser_timeout_millis(duration: std::time::Duration, name: &str) -> Result<u32, game_realtime_transport_lib::TransportError> {
return match u32::try_from(duration.as_millis()) {
Ok(value) if value > 0 => Ok(value),
Ok(_) => Err(invalid_configuration(format!("{name} must resolve to at least one browser timer millisecond"))),
Err(_) => Err(invalid_configuration(format!("{name} exceeds the browser timer range of u32 milliseconds"))),
};
}
fn timeout_error(operation: &str, timeout: std::time::Duration) -> game_realtime_transport_lib::TransportError {
return transport_error(
game_realtime_transport_lib::TransportErrorKind::Timeout,
format!("{operation} exceeded configured deadline of {} ms", timeout.as_millis()),
);
}
fn validate_browser_deadlines(config: crate::WebTransportConfig) -> Result<(), game_realtime_transport_lib::TransportError> {
if let Err(error) = browser_timeout_millis(config.connect_timeout(), "connect_timeout") {
return Err(error);
}
if let Err(error) = browser_timeout_millis(config.primary_stream_timeout(), "primary_stream_timeout") {
return Err(error);
}
if let Err(error) = browser_timeout_millis(config.send_timeout(), "send_timeout") {
return Err(error);
}
return Ok(());
}
fn frame_header(payload_len: usize, max_message_size: usize) -> Result<[u8; PRIMARY_FRAME_HEADER_SIZE], game_realtime_transport_lib::TransportError> {
if payload_len > max_message_size {
return Err(message_too_large(payload_len, max_message_size));
}
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_wasm::Error) -> game_realtime_transport_lib::TransportError {
let kind = match &error {
web_transport_wasm::Error::Closed => game_realtime_transport_lib::TransportErrorKind::Closed,
web_transport_wasm::Error::Session { .. } => game_realtime_transport_lib::TransportErrorKind::Closed,
web_transport_wasm::Error::Stream(_) => game_realtime_transport_lib::TransportErrorKind::Aborted,
web_transport_wasm::Error::Unknown(_) => game_realtime_transport_lib::TransportErrorKind::Io,
};
return transport_error(kind, error.to_string());
}
fn map_write_error(error: web_transport_wasm::Error) -> game_realtime_transport_lib::TransportError {
let kind = match &error {
web_transport_wasm::Error::Closed => game_realtime_transport_lib::TransportErrorKind::Closed,
web_transport_wasm::Error::Session { .. } => game_realtime_transport_lib::TransportErrorKind::Closed,
web_transport_wasm::Error::Stream(_) => game_realtime_transport_lib::TransportErrorKind::Aborted,
web_transport_wasm::Error::Unknown(_) => game_realtime_transport_lib::TransportErrorKind::Io,
};
return transport_error(kind, error.to_string());
}
fn datagram_too_large(payload_len: usize, max_datagram_size: usize) -> game_realtime_transport_lib::TransportError {
return transport_error(
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
format!("WebTransport datagram payload size {payload_len} exceeds current session maximum {max_datagram_size} bytes"),
);
}
fn message_too_large(payload_len: usize, max_message_size: usize) -> game_realtime_transport_lib::TransportError {
return transport_error(
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
format!("WebTransport framed payload length {payload_len} exceeds configured maximum {max_message_size}"),
);
}
fn protocol_error(detail: impl Into<String>) -> game_realtime_transport_lib::TransportError {
return transport_error(game_realtime_transport_lib::TransportErrorKind::Protocol, detail);
}
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);
}

View File

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

View File

@@ -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(_))));
}

View File

@@ -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}");
}
}

View 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"),
};
}

View File

@@ -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),
}
}
}

View File

@@ -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
View 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`.

View File

@@ -0,0 +1,108 @@
<!-- file: deltas/0.3.5/alpha.10.fix.1.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.10.fix.1
## Base requise
`0.3.5-alpha.10`.
La gate utilisateur du 2026-09-22 valide :
- fmt et les trois audits ;
- `cargo check --workspace` ;
- les suites ciblées `game-realtime-transport-lib`, `game-realtime-websocket-lib` et `game-realtime-webtransport-lib` ;
- l'exécution release complète de `game-realtime-transport-measure` ;
- toutes les lignes `MEASURE ...`, la conclusion `webtransport=retain` et `game-realtime-transport-measure: PASS` ;
- les graphes de dépendances attendus.
La gate complète échoue uniquement sur Clippy workspace à cause d'un import inutilisé dans `game-realtime-transport-measure`.
`alpha.10` n'est donc pas historisée comme validée avant ce correctif.
## Cause
Le launcher importe encore :
```text
use game_realtime_transport_lib::RealtimeConnection;
```
alors que ses helpers utilisent les types génériques et les traits `RealtimeSender` / `RealtimeReceiver` sans appeler de méthode nécessitant que `RealtimeConnection` soit explicitement en scope.
`cargo check` le signale comme warning, puis :
```text
cargo clippy --workspace --all-targets --all-features -- -D warnings
```
le transforme en erreur `unused-imports`.
## Correction
La version technique passe à :
```text
0.3.5-alpha.10.fix.1
```
Le correctif retire uniquement l'import inutilisé et ne change :
- ni les paramètres de mesure ;
- ni les nombres d'échantillons ;
- ni les tailles de payload ;
- ni les chemins WebSocket/WebTransport fiables ;
- ni le POC datagram ;
- ni les backends ;
- ni les dépendances ;
- ni la sortie `MEASURE ...` ;
- ni la conclusion technique provisoire `webtransport=retain`.
## Fichiers modifiés
```text
Cargo.toml
README.md
crates/apps/game-realtime-transport-measure/src/main.rs
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
```
Nouveau fichier :
```text
deltas/0.3.5/alpha.10.fix.1.md
```
Aucun `history/0.3.5/alpha.10.md` n'est créé avant une gate entièrement verte.
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p game-realtime-transport-lib --all-targets --all-features
cargo test -p game-realtime-websocket-lib --all-targets --all-features
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo run --release -p game-realtime-transport-measure
```
Le launcher doit encore terminer par :
```text
game-realtime-transport-measure: PASS
```
Les lignes `MEASURE ...` doivent rester présentes afin d'enregistrer les résultats de caractérisation dans l'historique après validation.
## Après validation
Créer `history/0.3.5/alpha.10.fix.1.md` avec les sorties réellement fournies et les mesures observées, puis ouvrir `0.3.5-beta.1` pour la validation large du workspace.

177
deltas/0.3.5/alpha.10.md Normal file
View File

@@ -0,0 +1,177 @@
<!-- file: deltas/0.3.5/alpha.10.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.10
## Base requise
`0.3.5-alpha.9.fix.1`, validée par l'utilisateur le 2026-09-22.
La gate finale confirme fmt, audits, `cargo check --workspace`, Clippy strict, `22/22` tests WebTransport, smoke datagram natif `PASS` et check/Clippy/build WASM. La preuve est enregistrée dans `history/0.3.5/alpha.9.fix.1.md`.
## Objectif
Caractériser de manière bornée les backends realtime déjà retenus sans introduire de nouveau contrat produit :
- établissement WebSocket vs WebTransport fiable ;
- RTT applicatif ;
- throughput fiable ;
- fenêtre bornée de messages sans ACK intermédiaire ;
- datagram WebTransport mesuré séparément ;
- méthode et limites documentées ;
- conclusion technique provisoire `retain` sans classement automatique.
## Version
La version workspace passe à :
```text
0.3.5-alpha.10
```
## Nouveau launcher
Nouvelle application technique :
```text
crates/apps/game-realtime-transport-measure
```
Elle dépend uniquement :
```text
game-realtime-transport-lib
game-realtime-websocket-lib
game-realtime-webtransport-lib
tokio
```
Elle ne dépend d'aucun engine ni gameplay et ne modifie aucune crate backend.
## Mesures fiables
La comparaison utilise uniquement `RealtimeConnection` pour les deux backends.
### Établissement
```text
8 échantillons
WebSocket : handshake jusqu'à connexion prête
WebTransport : session + stream primaire jusqu'à connexion fiable prête
```
### RTT
```text
16 warmups
128 échantillons
32 octets par ping/pong
```
### Throughput
```text
128 messages
64 KiB par message
8 MiB utiles
ACK final après drainage serveur
```
### Fenêtre applicative en vol
```text
64 messages
1 KiB par message
aucun ACK applicatif intermédiaire
ACK unique après drainage
```
Cette dernière mesure ne prétend pas mesurer directement le nombre de paquets réseau en vol ; elle caractérise une fenêtre applicative bornée de messages non acquittés individuellement.
## Datagram séparé
WebTransport est également caractérisé hors `RealtimeConnection` :
```text
64 datagrams
payload = min(256, client_max, server_max)
réception jusqu'à 64 ou 250 ms sans nouveau datagram
```
Le launcher rapporte `attempted`, `received`, `receive_ratio`, les bornes négociées et le temps écoulé. Il n'interprète pas une réception complète comme une garantie de fiabilité.
## Sortie
Chaque résultat est imprimé sous une forme stable :
```text
MEASURE transport=<...> metric=<...> ...
```
La fin normale est :
```text
CONCLUSION webtransport=retain scope=second-backend reason=reliable-browser-fallback-datagram-capabilities
game-realtime-transport-measure: PASS
```
Le `retain` signifie uniquement conserver WebTransport comme second backend à côté de WebSocket. Aucun seuil de victoire et aucune affirmation « plus rapide » n'est codé dans l'outil.
## Documentation
La méthode et ses limites sont figées dans :
```text
docs/studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md
```
Les valeurs de la machine de validation seront enregistrées dans `history/0.3.5/alpha.10.md` après la gate utilisateur plutôt que figées comme vérité générale dans la documentation durable.
## Fichiers principaux
Nouveaux :
```text
crates/apps/game-realtime-transport-measure/Cargo.toml
crates/apps/game-realtime-transport-measure/src/main.rs
deltas/0.3.5/alpha.10.md
docs/studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md
history/0.3.5/alpha.9.fix.1.md
```
Modifiés :
```text
Cargo.toml
README.md
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
scripts/audit_distribution_layout.py
```
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p game-realtime-transport-lib --all-targets --all-features
cargo test -p game-realtime-websocket-lib --all-targets --all-features
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo run --release -p game-realtime-transport-measure
cargo tree -p game-realtime-transport-measure --edges normal
cargo tree -i game-realtime-websocket-lib --workspace --edges normal
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
```
## Après validation
Enregistrer les lignes `MEASURE ...` réelles dans `history/0.3.5/alpha.10.md`, puis passer à `0.3.5-beta.1` pour la validation large. Un défaut fermé de `alpha.10` doit produire `alpha.10.fix.N` avant `beta.1`.

View 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 dinté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 lassertion dadresse distante :
```text
left: [::ffff:127.0.0.1]:54430
right: 127.0.0.1:54430
```
Quinn expose ici ladresse IPv4 loopback sous forme IPv4-mapped IPv6. Les deux valeurs désignent la même IP et le même port ; lassertion 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` nest pas historisée comme validée : le test dinté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.
Lassertion 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 derreur ou API publique nest 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` nest 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 à ladaptation `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 nest requis : le graphe de dépendances na pas changé et la gate `alpha.2` la 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
View 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
View 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`.

View 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
View 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`.

126
deltas/0.3.5/alpha.5.md Normal file
View File

@@ -0,0 +1,126 @@
<!-- file: deltas/0.3.5/alpha.5.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.5
## Base requise
`0.3.5-alpha.4.fix.1`, validée par l'utilisateur le 2026-09-21 avec fmt, audits, `cargo check`, Clippy strict, les sept tests du contrat realtime commun et les vingt tests WebTransport.
La preuve est enregistrée dans `history/0.3.5/alpha.4.fix.1.md`.
## Objectif
Fermer la preuve runtime native WebTransport hors harness de test sans ajouter de fonctionnalité à la bibliothèque.
Cette tranche doit démontrer avec les APIs publiques réellement distribuées que le chemin localhost complet fonctionne depuis un exécutable normal : TLS local, pinning, session WebTransport, stream fiable principal, contrat realtime commun, round-trip bidirectionnel et fermeture propre.
## Version
La version workspace passe à :
```text
0.3.5-alpha.5
```
## Launcher technique
Nouvelle crate :
```text
crates/apps/game-realtime-webtransport-smoke
```
Elle dépend uniquement de :
```text
game-logging-lib
game-realtime-transport-lib
game-realtime-webtransport-lib
tokio
tracing
```
Aucune nouvelle dépendance externe n'est introduite.
Le launcher :
1. initialise le tracing commun ;
2. génère une identité TLS loopback ECDSA P-256 courte durée en mémoire ;
3. dérive le hash SHA-256 exact exposé par cette identité ;
4. bind le listener WebTransport sur `127.0.0.1:0` ;
5. construit le client `https://<adresse>/smoke` avec ce hash pin ;
6. établit simultanément les sessions client et serveur ;
7. ouvre/accepte le stream bidirectionnel primaire ;
8. échange un payload client puis un payload serveur via le contrat `RealtimeConnection` ;
9. ferme proprement le sender client et vérifie `Closed` côté serveur ;
10. ferme proprement le sender serveur et vérifie `Closed` côté client ;
11. imprime exactement :
```text
game-realtime-webtransport-smoke: PASS
```
Une deadline globale de 10 secondes empêche le smoke de rester bloqué indéfiniment.
## Frontière de dépendances
Le backend WebTransport est désormais consommé par un launcher technique public, ce qui est intentionnel. Il doit toujours rester absent des crates engine et gameplay.
Le graphe inverse attendu peut donc contenir :
```text
game-realtime-webtransport-lib
game-realtime-webtransport-smoke
```
mais aucune crate `engine-v1-*`, `game-snake-poc` ou `game-reflex-poc`.
## Fichiers modifiés
```text
Cargo.toml
README.md
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
```
Nouveaux fichiers :
```text
crates/apps/game-realtime-webtransport-smoke/Cargo.toml
crates/apps/game-realtime-webtransport-smoke/src/main.rs
deltas/0.3.5/alpha.5.md
history/0.3.5/alpha.4.fix.1.md
```
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p game-realtime-transport-lib --all-targets --all-features
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo run -p game-realtime-webtransport-smoke
cargo tree -p game-realtime-webtransport-smoke --edges normal
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
```
Le smoke doit se terminer avec :
```text
game-realtime-webtransport-smoke: PASS
```
## Après validation
Créer `history/0.3.5/alpha.5.md` à partir des sorties réellement fournies, puis ouvrir `0.3.5-alpha.6` pour le chemin WASM compilable. Le smoke navigateur/TLS réel reste réservé à `alpha.7`.

155
deltas/0.3.5/alpha.6.md Normal file
View File

@@ -0,0 +1,155 @@
<!-- file: deltas/0.3.5/alpha.6.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.6
## Base requise
`0.3.5-alpha.5`, validée par l'utilisateur le 2026-09-21 avec fmt, audits, `cargo check`, Clippy strict, les sept tests du contrat realtime commun, les vingt tests WebTransport, le smoke natif hors harness avec verdict `PASS` et les graphes de dépendances attendus.
La preuve est enregistrée dans `history/0.3.5/alpha.5.md`.
## Objectif
Fermer la preuve de compilation du client WebTransport Rust en `wasm32-unknown-unknown` sans encore créer de host navigateur ni attribuer un smoke runtime.
Cette tranche doit conserver le backend natif intact tout en donnant au navigateur le même contrat fiable : pin SHA-256 explicite, session WebTransport, stream bidirectionnel primaire, framing `u32` big-endian + payload et `RealtimeConnection`.
## Version
La version workspace passe à :
```text
0.3.5-alpha.6
```
## Dépendances et cfg
La contrainte workspace suivante est ajoutée :
```text
web-transport-wasm = 0.6.0
```
Dans `game-realtime-webtransport-lib` :
- `rcgen`, Tokio et `web-transport-quinn` deviennent des dépendances uniquement non-WASM ;
- `web-transport-wasm` est tirée uniquement pour `target_arch = "wasm32"` ;
- les dev-dependencies Tokio de la suite native restent elles aussi non-WASM.
`.cargo/config.toml` fournit uniquement à `wasm32-unknown-unknown` :
```text
--cfg=web_sys_unstable_apis
```
Le flag est déclaré pour rustc et rustdoc ; aucun build natif ne le reçoit.
## Client WASM
Le module WASM expose sous le même nom que le client natif :
```text
WebTransportCertificateHash
WebTransportClientConfig
connect
WebTransportSession
WebTransportConnection
WebTransportSender
WebTransportReceiver
```
Les types serveur natifs restent absents du target WASM.
Le client navigateur :
1. valide un endpoint `https://` ;
2. transmet le hash SHA-256 exact via `serverCertificateHashes` ;
3. exige le chemin WebTransport/QUIC sans fallback TCP implicite du wrapper ;
4. établit une session navigateur ;
5. ouvre le stream bidirectionnel primaire ;
6. adapte le stream au contrat `game-realtime-transport-lib`.
Le framing reste :
```text
u32 big-endian payload length
payload bytes
```
La limite configurée est contrôlée avant écriture et avant allocation. Le sender applique FIN et reset ; le receiver applique STOP_SENDING et conserve un parseur incrémental compatible avec l'abandon/reprise d'une future de réception.
Les types navigateur sont `!Send`, mais le contrat `RealtimeConnection` n'impose volontairement aucun bound `Send`; aucune modification du contrat commun n'est donc nécessaire.
## Différence runtime explicitement différée
`WebTransportConfig` continue de valider `connect_timeout`, `primary_stream_timeout` et `send_timeout` afin de ne pas accepter une configuration incohérente. Dans `alpha.6`, ces deadlines ne sont cependant pas encore matérialisées par un timer côté navigateur.
Le wrapper `web-transport-wasm` conserve ses opérations Web API abandonnées dans son état de polling ; la tranche `alpha.7` doit fixer puis prouver la politique de timeout/runtime avec un vrai navigateur avant de déclarer la parité comportementale avec le chemin natif.
Cette différence est documentée et n'est pas masquée comme une validation runtime.
## Fichiers modifiés
```text
.cargo/config.toml
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/config.rs
crates/common/game-realtime-webtransport-lib/src/lib.rs
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
```
Nouveaux fichiers :
```text
crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs
deltas/0.3.5/alpha.6.md
history/0.3.5/alpha.5.md
```
## Validation attendue
Gate native de non-régression :
```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 run -p game-realtime-webtransport-smoke
```
Gate WASM ciblée :
```bash
cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings
cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo tree -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --edges normal
cargo tree -i web-transport-wasm --workspace --target wasm32-unknown-unknown --edges normal
```
Le build WASM ne doit tirer ni `web-transport-quinn`, ni rcgen, ni le runtime Tokio du backend natif.
Le smoke natif doit toujours se terminer par :
```text
game-realtime-webtransport-smoke: PASS
```
## Après validation
Créer `history/0.3.5/alpha.6.md` à partir des sorties réellement fournies, puis ouvrir `0.3.5-alpha.7` pour le smoke navigateur/TLS local réel et la fermeture de la politique de deadlines navigateur.

View File

@@ -0,0 +1,128 @@
<!-- file: deltas/0.3.5/alpha.7.fix.1.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.7.fix.1
## Base requise
`0.3.5-alpha.7`, candidate dont la gate utilisateur du 2026-09-22 a validé fmt, les trois audits, `cargo check --workspace`, Clippy strict, les sept tests du contrat realtime commun, les vingt tests WebTransport, le smoke natif `PASS`, puis check/Clippy/build réels du backend en `wasm32-unknown-unknown` ainsi que check/Clippy et build WASM de `game-realtime-webtransport-browser-smoke`.
La gate échoue uniquement pendant le sous-script `wasm:build` de `npm run build`, après une compilation Rust release réussie :
```text
error: failed reading '../../builds/sasedev-games/target/wasm32-unknown-unknown/release/game_realtime_webtransport_browser_smoke.wasm'
Caused by:
No such file or directory (os error 2)
```
`alpha.7` n'est donc pas historisée comme validée avant ce correctif.
## Cause
Le workspace configure son target-dir Cargo dans `.cargo/config.toml` :
```text
../builds/sasedev-games/target
```
Ce chemin est extérieur au repository, donc depuis :
```text
Web/game-realtime-webtransport-browser-smoke
```
l'artefact se trouve sous :
```text
../../../builds/sasedev-games/target/...
```
et non sous `../../builds/...`.
Le `vite.config.ts` était déjà correct : il dérive `repositoryRoot`, puis `../builds/sasedev-games/...`. Le défaut est limité aux chemins littéraux des scripts npm et à l'ancre correspondante de l'audit de distribution.
## Correction
La version technique passe à :
```text
0.3.5-alpha.7.fix.1
```
Dans `Web/game-realtime-webtransport-browser-smoke/package.json`, `wasm:dev` et `wasm:build` utilisent désormais :
```text
../../../builds/sasedev-games/target/...
../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm
```
`DIST-LAYOUT-078` est aligné sur ce chemin externe réel.
Aucun code Rust, protocole WebTransport, framing, deadline, certificat, pin SHA-256, API publique ou dépendance n'est modifié.
## Fichiers modifiés
```text
Cargo.toml
README.md
Web/game-realtime-webtransport-browser-smoke/package.json
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
scripts/audit_distribution_layout.py
```
Nouveau fichier :
```text
deltas/0.3.5/alpha.7.fix.1.md
```
Aucun fichier `history/0.3.5/alpha.7.md` n'est créé avant une gate entièrement verte du correctif.
## Plan
Le forecast fonctionnel ne change pas. Après validation du fix et du smoke navigateur réel, `0.3.5-alpha.8` reste la tranche du fallback WebSocket au niveau composition.
## Validation attendue
La correction touche build/config et version ; relancer d'abord la gate statique et les builds concernés :
```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 build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
```
Puis exécuter le smoke runtime réel avec deux terminaux.
Terminal A :
```bash
cargo run -p game-realtime-webtransport-browser-smoke
```
Terminal B :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm run dev)
```
Ouvrir l'URL `OPEN=...` imprimée par le Terminal A. Le résultat attendu dans la page puis dans le Terminal A reste :
```text
game-realtime-webtransport-browser-smoke: PASS
```
## Après validation
Créer `history/0.3.5/alpha.7.fix.1.md` à partir des résultats réellement fournis, puis ouvrir directement `0.3.5-alpha.8`.

View File

@@ -0,0 +1,123 @@
<!-- file: deltas/0.3.5/alpha.7.fix.2.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.7.fix.2
## Base requise
`0.3.5-alpha.7.fix.1`, dont la gate utilisateur du 2026-09-22 valide fmt, les trois audits, `cargo check --workspace`, Clippy strict et le build WASM de `game-realtime-webtransport-browser-smoke`.
Le premier correctif permet aussi au sous-script `wasm:build` de terminer : la compilation release et `wasm-bindgen` trouvent désormais l'artefact Cargo et génèrent les bindings dans le build externe. La gate échoue ensuite uniquement pendant `tsc` :
```text
frontend/ts/main.ts:4:41 - error TS2307: Cannot find module '@webtransport-browser-smoke-wasm' or its corresponding type declarations.
frontend/ts/main.ts:49:19 - error TS7006: Parameter 'message' implicitly has an 'any' type.
frontend/ts/main.ts:52:20 - error TS7006: Parameter 'caughtError' implicitly has an 'any' type.
```
`alpha.7.fix.1` n'est donc pas historisée comme validée avant ce correctif.
## Cause
Le chemin littéral corrigé dans `package.json` n'avait pas été réconcilié avec le mapping TypeScript. Depuis :
```text
Web/game-realtime-webtransport-browser-smoke
```
les déclarations générées par `wasm-bindgen` se trouvent sous :
```text
../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm/game_realtime_webtransport_browser_smoke.d.ts
```
mais `tsconfig.json` cherchait encore :
```text
../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm/game_realtime_webtransport_browser_smoke.d.ts
```
L'import aliasé ne pouvant pas être résolu, TypeScript perdait aussi la signature générée de `run_browser_smoke`; les paramètres des callbacks de Promise apparaissaient alors comme `any` implicites.
## Correction
La version technique passe à :
```text
0.3.5-alpha.7.fix.2
```
Le mapping `paths` de `@webtransport-browser-smoke-wasm` utilise désormais le même build root externe réel que `package.json` et `vite.config.ts` :
```text
../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm/game_realtime_webtransport_browser_smoke.d.ts
```
`DIST-LAYOUT-086` ancre ce chemin TypeScript pour empêcher une régression silencieuse.
Aucun code Rust, TypeScript applicatif, protocole WebTransport, framing, deadline, certificat, pin SHA-256, API publique ou dépendance n'est modifié.
## Fichiers modifiés
```text
Cargo.toml
README.md
Web/game-realtime-webtransport-browser-smoke/package.json
Web/game-realtime-webtransport-browser-smoke/tsconfig.json
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
scripts/audit_distribution_layout.py
```
Nouveau fichier :
```text
deltas/0.3.5/alpha.7.fix.2.md
```
Aucun fichier `history/0.3.5/alpha.7.fix.1.md` n'est créé avant une gate entièrement verte.
## Plan
Le forecast fonctionnel ne change pas. Après validation du build frontend puis du smoke navigateur réel, `0.3.5-alpha.8` reste la tranche du fallback WebSocket au niveau composition.
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
```
Puis exécuter le smoke runtime réel avec deux terminaux.
Terminal A :
```bash
cargo run -p game-realtime-webtransport-browser-smoke
```
Terminal B :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm run dev)
```
Ouvrir l'URL `OPEN=...` imprimée par le Terminal A. Le résultat attendu dans la page puis dans le Terminal A reste :
```text
game-realtime-webtransport-browser-smoke: PASS
```
## Après validation
Créer `history/0.3.5/alpha.7.fix.2.md` à partir des résultats réellement fournis, puis ouvrir directement `0.3.5-alpha.8`.

243
deltas/0.3.5/alpha.7.md Normal file
View File

@@ -0,0 +1,243 @@
<!-- file: deltas/0.3.5/alpha.7.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.7
## Base requise
`0.3.5-alpha.6`, validée par l'utilisateur le 2026-09-22 avec fmt, audits, `cargo check`, Clippy strict, les sept tests du contrat realtime commun, les vingt tests WebTransport, le smoke natif `PASS`, puis check/Clippy/build réels de `game-realtime-webtransport-lib` en `wasm32-unknown-unknown`.
Le graphe WASM confirmé ne tire ni `web-transport-quinn`, ni rcgen, ni Tokio. La preuve est enregistrée dans `history/0.3.5/alpha.6.md`.
## Objectif
Fermer l'interop navigateur réelle du POC WebTransport sans introduire encore le fallback WebSocket :
- appliquer réellement les deadlines `WebTransportConfig` côté browser/WASM ;
- charger le client Rust/WASM dans un host Vite technique isolé ;
- connecter un navigateur récent à un serveur Rust WebTransport local ;
- transmettre explicitement le hash SHA-256 du certificat court P-256 ;
- effectuer un round-trip binaire bidirectionnel via le stream primaire ;
- fermer proprement les deux directions ;
- produire un verdict `PASS` reproductible côté navigateur et côté serveur.
## Version
La version workspace passe à :
```text
0.3.5-alpha.7
```
## Deadlines navigateur
Le target `wasm32` de `game-realtime-webtransport-lib` ajoute uniquement :
```text
gloo-timers 0.4.0
futures-util 0.3.34 avec feature std
```
Les dépendances restent target-specific et ne changent pas le backend natif.
Les opérations navigateur suivantes sont maintenant bornées :
```text
connect_timeout
primary_stream_timeout
send_timeout
```
La politique reste identique au natif :
- `connect` expiré -> `TransportErrorKind::Timeout` ;
- ouverture du stream primaire expirée -> `Timeout` ;
- send expiré -> `Timeout` puis reset terminal du stream ;
- `receive()` idle reste sans timeout implicite ;
- cancellation de `receive()` reste reprenable grâce au parseur incrémental.
Le timer browser prend des millisecondes `u32`. Une deadline qui devient zéro après conversion ou dépasse cette plage est rejetée comme `InvalidConfiguration` avant établissement.
## Smoke navigateur
Nouveau package technique :
```text
crates/apps/game-realtime-webtransport-browser-smoke
```
Le même package possède :
- un binary natif qui génère l'identité loopback P-256 en mémoire, bind un serveur WebTransport sur port éphémère, affiche endpoint + SHA-256 et attend exactement le round-trip navigateur ;
- une lib `cdylib`/`rlib` dont l'export `run_browser_smoke(...)` compile en WASM et consomme réellement `game-realtime-webtransport-lib`.
Nouveau host direct :
```text
Web/game-realtime-webtransport-browser-smoke
```
Il utilise Vite + TypeScript, génère les bindings avec `wasm-bindgen` hors du repository et sert la page sur :
```text
http://127.0.0.1:1435/main.html
```
Le loopback HTTP est un secure context de navigateur. La page vérifie néanmoins `window.isSecureContext` et la présence de `WebTransport` avant d'exécuter le WASM.
Le transport cible toujours :
```text
https://127.0.0.1:<port-ephemere>/browser-smoke
```
avec `serverCertificateHashes` SHA-256 ; aucune vérification TLS n'est désactivée.
## Round-trip prouvé par le smoke
La séquence attendue est :
```text
browser WASM
-> connect WebTransport avec pin SHA-256
-> open primary bidirectional stream
-> send browser binary payload
native Rust peer
-> receive exact payload
-> send server binary payload
browser WASM
-> receive exact payload
-> FIN send direction
native Rust peer
-> observe Closed
-> FIN send direction
browser WASM
-> observe Closed
```
Le navigateur doit afficher :
```text
game-realtime-webtransport-browser-smoke: PASS
```
et le binary serveur doit ensuite terminer avec la même ligne.
Aucun fallback WebSocket n'existe dans cette preuve ; un échec WebTransport doit rester visible.
## Audit de distribution
`scripts/audit_distribution_layout.py` possède désormais le host technique et son adapter Rust. Il vérifie notamment :
- génération WASM hors repository ;
- host Vite strictement loopback sur le port 1435 ;
- contrôle secure-context/WebTransport côté page ;
- appel réel à `run_browser_smoke(...)` ;
- dépendance de l'adapter vers `game-realtime-webtransport-lib` ;
- verdict `PASS` déterministe du peer natif.
## 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/webtransport_wasm.rs
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
scripts/audit_distribution_layout.py
```
Nouveaux fichiers :
```text
crates/apps/game-realtime-webtransport-browser-smoke/Cargo.toml
crates/apps/game-realtime-webtransport-browser-smoke/src/browser.rs
crates/apps/game-realtime-webtransport-browser-smoke/src/lib.rs
crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs
crates/apps/game-realtime-webtransport-browser-smoke/src/shared.rs
Web/game-realtime-webtransport-browser-smoke/package.json
Web/game-realtime-webtransport-browser-smoke/tsconfig.json
Web/game-realtime-webtransport-browser-smoke/vite.config.ts
Web/game-realtime-webtransport-browser-smoke/frontend/main.html
Web/game-realtime-webtransport-browser-smoke/frontend/ts/main.ts
deltas/0.3.5/alpha.7.md
history/0.3.5/alpha.6.md
```
## Validation attendue
Gate Rust native de non-régression :
```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 run -p game-realtime-webtransport-smoke
```
Gate WASM :
```bash
cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings
cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo check -p game-realtime-webtransport-browser-smoke
cargo clippy -p game-realtime-webtransport-browser-smoke --all-targets --all-features -- -D warnings
cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown
```
Build du host Vite direct :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
```
Smoke runtime réel, avec deux terminaux :
Terminal A :
```bash
cargo run -p game-realtime-webtransport-browser-smoke
```
Le launcher affiche une ligne `OPEN=...` contenant l'endpoint éphémère et le hash SHA-256.
Terminal B :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm run dev)
```
Ouvrir dans un navigateur récent l'URL `OPEN=...` imprimée par le Terminal A. Si le navigateur demande l'autorisation d'accès loopback/local, l'accorder pour cette preuve locale.
Résultat attendu dans la page puis dans le Terminal A :
```text
game-realtime-webtransport-browser-smoke: PASS
```
Vérifier enfin le graphe cible :
```bash
cargo tree -p game-realtime-webtransport-browser-smoke --target wasm32-unknown-unknown --edges normal
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
cargo tree -i gloo-timers --workspace --target wasm32-unknown-unknown --edges normal
```
Le backend navigateur doit rester indépendant de Quinn/rcgen/Tokio ; `gloo-timers` ne doit apparaître que sur la cible WASM.
## Après validation
Créer `history/0.3.5/alpha.7.md` à partir des sorties réellement fournies, puis ouvrir `0.3.5-alpha.8` pour le fallback WebSocket au niveau composition avec branches WebTransport, fallback classifié et erreur non-fallback visible.

View File

@@ -0,0 +1,123 @@
<!-- file: deltas/0.3.5/alpha.8.fix.1.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.8.fix.1
## Base requise
`0.3.5-alpha.8`.
La gate utilisateur du 2026-09-22 valide :
- fmt et les trois audits ;
- `cargo check --workspace` ;
- les suites `game-realtime-transport-lib`, `game-realtime-websocket-lib` et `game-realtime-webtransport-lib` ;
- les deux tests du launcher fallback ;
- le smoke WebSocket ;
- le smoke WebTransport ;
- le smoke de composition fallback, terminé par `game-realtime-transport-fallback-smoke: PASS` ;
- les graphes de dépendances attendus.
La gate complète échoue uniquement sur Clippy strict dans `game-realtime-transport-fallback-smoke` :
```text
unused import: game_realtime_transport_lib::RealtimeConnection
missing return statement
```
Les erreurs `implicit-return` portent uniquement sur des closures qui retournent une future `async`.
`alpha.8` n'est donc pas historisée comme validée avant ce correctif.
## Cause
Le launcher importe explicitement `RealtimeConnection` alors que ses bounds utilisent le chemin qualifié complet : l'import n'est jamais consommé.
En parallèle, la règle workspace `clippy::implicit-return` s'applique aussi à la valeur de retour des closures. Les formes :
```rust
|| async { ... }
```
doivent donc être écrites :
```rust
|| return async { ... }
```
même lorsque le corps `async` contient déjà ses propres `return` explicites.
## Correction
La version technique passe à :
```text
0.3.5-alpha.8.fix.1
```
Le correctif :
- supprime l'import inutilisé `RealtimeConnection` de `src/main.rs` ;
- rend explicite le `return` de toutes les closures async du sélecteur dans `src/main.rs` ;
- applique la même forme aux closures async des tests unitaires.
Aucun comportement runtime, backend, contrat, mapping d'erreur, endpoint, framing, TLS/pinning ou dépendance n'est modifié.
La politique de fallback reste strictement :
```text
Timeout -> fallback autorisé
Io -> fallback autorisé
Connect -> fallback interdit
```
et toutes les autres catégories restent non-fallback.
## Fichiers modifiés
```text
Cargo.toml
README.md
crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
```
Nouveau fichier :
```text
deltas/0.3.5/alpha.8.fix.1.md
```
Aucun fichier `history/0.3.5/alpha.8.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.9` reste la tranche suivante pour le POC datagram isolé si elle demeure justifiée par l'API/backend réellement disponibles.
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p game-realtime-transport-fallback-smoke --all-targets --all-features
cargo run -p game-realtime-transport-fallback-smoke
```
Le smoke doit terminer par :
```text
game-realtime-transport-fallback-smoke: PASS
```
## Après validation
Créer `history/0.3.5/alpha.8.fix.1.md` à partir des résultats réellement fournis, puis ouvrir directement `0.3.5-alpha.9` si le POC datagram reste pertinent.

212
deltas/0.3.5/alpha.8.md Normal file
View File

@@ -0,0 +1,212 @@
<!-- file: deltas/0.3.5/alpha.8.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.8
## Base requise
`0.3.5-alpha.7.fix.2`, validée par l'utilisateur le 2026-09-22.
La gate finale confirme fmt, audits, `cargo check --workspace`, Clippy strict, build WASM de l'adapter browser, génération `wasm-bindgen`, build TypeScript/Vite et smoke WebTransport navigateur réel terminé par :
```text
game-realtime-webtransport-browser-smoke: PASS
```
La preuve complète est enregistrée dans `history/0.3.5/alpha.7.fix.2.md`.
## Objectif
Prouver le fallback WebSocket au niveau composition sans modifier le contrat transport-neutral ni masquer les erreurs sensibles :
- essayer WebTransport en premier ;
- conserver WebTransport lorsqu'il est disponible ;
- basculer vers WebSocket uniquement pour des erreurs explicitement classifiées ;
- forcer une branche fallback déterministe ;
- garder une erreur non-fallback visible ;
- ne créer ni manager générique, ni registry, ni couplage gameplay.
## Version
La version workspace passe à :
```text
0.3.5-alpha.8
```
## Launcher de composition
Nouveau package technique :
```text
crates/apps/game-realtime-transport-fallback-smoke
```
Il dépend directement des deux backends candidats et du contrat commun, mais d'aucun engine ni gameplay.
Le sélecteur reste privé au launcher :
```text
WebTransport attempt
-> success -> WebTransport
-> classified Timeout/Io -> WebSocket attempt
-> any other error -> visible error
```
Aucune API n'est ajoutée à `game-realtime-transport-lib`, `game-realtime-websocket-lib` ou `game-realtime-webtransport-lib`.
## Classification conservative
Le backend WebTransport natif mappe actuellement les erreurs amont d'établissement sous une catégorie `Connect` trop large pour distinguer de manière sûre une indisponibilité réseau d'un problème TLS/pinning.
Pour éviter un downgrade silencieux, `alpha.8` autorise le fallback uniquement pour :
```text
Timeout
Io
```
et refuse explicitement le fallback pour :
```text
InvalidConfiguration
Connect
Bind
Accept
MessageTooLarge
Backpressure
Closed
Protocol
Aborted
```
Cette politique est volontairement plus restrictive qu'un fallback générique sur toute erreur de connexion. Une évolution future pourra élargir la matrice uniquement si le backend expose une classification suffisamment précise pour préserver les erreurs de sécurité/protocole.
## Smoke runtime
Le launcher exécute trois scénarios séquentiels sous une deadline globale de 10 s.
### 1. WebTransport disponible
- serveur WebTransport loopback réel sur port éphémère ;
- identité P-256 en mémoire et pin SHA-256 exact ;
- sélection WebTransport-first ;
- fallback WebSocket volontairement inutilisable si appelé ;
- round-trip binaire dans les deux sens ;
- fermeture propre dans les deux sens.
### 2. Fallback WebSocket forcé
- erreur WebTransport `Timeout` injectée explicitement au point de composition ;
- listener WebSocket loopback réel sur port éphémère ;
- sélection de la branche fallback ;
- round-trip binaire dans les deux sens ;
- fermeture propre dans les deux sens.
L'injection ne simule pas le backend WebSocket : elle force uniquement la cause WebTransport classifiée afin que la branche de sélection soit déterministe et rapide. La connexion fallback et son échange restent réels.
### 3. Erreur non-fallback visible
- vraie `WebTransportClientConfig` avec `connect_timeout = 0` ;
- `game-realtime-webtransport-lib::connect()` retourne `InvalidConfiguration` avant I/O ;
- la branche WebSocket ne doit pas être utilisée ;
- l'erreur exacte `connect_timeout must be greater than zero` doit rester visible.
Le verdict global attendu est :
```text
game-realtime-transport-fallback-smoke: PASS
```
## Tests unitaires
Le binary contient également des tests ciblés qui vérifient :
- la matrice `TransportErrorKind` complète ;
- `Connect` explicitement non éligible ;
- succès WebTransport prioritaire ;
- `Timeout` déclenchant la valeur WebSocket ;
- `InvalidConfiguration` restant inchangée et visible.
## Audit de distribution
`scripts/audit_distribution_layout.py` ajoute les chemins du launcher et vérifie :
- dépendance directe aux deux backends ;
- aucune dépendance engine/gameplay ;
- classification limitée à `Timeout`/`Io` ;
- exclusion explicite de `Connect` ;
- verdict `PASS` déterministe.
## Fichiers modifiés
```text
Cargo.toml
README.md
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
scripts/audit_distribution_layout.py
```
Nouveaux fichiers :
```text
crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml
crates/apps/game-realtime-transport-fallback-smoke/src/main.rs
crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs
deltas/0.3.5/alpha.8.md
history/0.3.5/alpha.7.fix.2.md
```
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p game-realtime-transport-lib --all-targets --all-features
cargo test -p game-realtime-websocket-lib --all-targets --all-features
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo test -p game-realtime-transport-fallback-smoke --all-targets --all-features
cargo run -p game-realtime-websocket-smoke
cargo run -p game-realtime-webtransport-smoke
cargo run -p game-realtime-transport-fallback-smoke
cargo tree -p game-realtime-transport-fallback-smoke --edges normal
cargo tree -i game-realtime-websocket-lib --workspace --edges normal
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
```
Le nouveau smoke doit terminer par :
```text
game-realtime-transport-fallback-smoke: PASS
```
Les graphes inverses peuvent contenir les launchers techniques, mais aucun engine ni crate gameplay ne doit dépendre des backends concrets.
## Contrôles statiques avant livraison
Le candidat a été vérifié côté générateur sans attribuer de résultat Cargo non exécuté :
```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 (61 required path(s), 8 forbidden path(s) absent)
Cargo.toml parse: clean
```
La compilation, Clippy, les tests et les smokes restent à exécuter dans la gate utilisateur ci-dessus.
## Après validation
Créer `history/0.3.5/alpha.8.md` à partir des sorties réellement fournies, puis ouvrir `0.3.5-alpha.9` pour le POC datagram isolé si cette tranche reste justifiée par l'API/backend réellement disponibles.

View File

@@ -0,0 +1,103 @@
<!-- file: deltas/0.3.5/alpha.9.fix.1.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.9.fix.1
## Base requise
`0.3.5-alpha.9`.
La gate utilisateur du 2026-09-22 valide :
- fmt et les trois audits ;
- `cargo check --workspace` ;
- les tests `game-realtime-webtransport-lib`, y compris les `2/2` tests datagram ;
- le smoke datagram natif, terminé par `game-realtime-webtransport-datagram-smoke: PASS` ;
- `cargo check`, Clippy et build de `game-realtime-webtransport-lib` sur `wasm32-unknown-unknown` ;
- les graphes de dépendances attendus.
La gate complète échoue uniquement sur Clippy workspace avec `missing_docs` pour le crate dintégration `crates/common/game-realtime-webtransport-lib/tests/datagrams.rs`. Les tests et le smoke datagram eux-mêmes sont verts.
`alpha.9` n'est donc pas historisée comme validée avant ce correctif.
## Cause
Chaque fichier sous `tests/` est compilé comme un crate dintégration autonome. La règle workspace :
```text
missing_docs = "warn"
```
devient bloquante sous :
```text
cargo clippy --workspace --all-targets --all-features -- -D warnings
```
Le nouveau `tests/datagrams.rs` documente ses tests mais ne possédait pas de rustdoc `//!` au niveau crate.
## Correction
La version technique passe à :
```text
0.3.5-alpha.9.fix.1
```
Le correctif ajoute uniquement une documentation de niveau crate à `tests/datagrams.rs`. Il n'ajoute aucune exception `allow`, ne modifie aucun test et ne change :
- ni la surface datagram de `WebTransportSession` ;
- ni le framing fiable ;
- ni `RealtimeConnection` ;
- ni les backends natif/WASM ;
- ni les deadlines ;
- ni les dépendances ;
- ni le smoke datagram.
## Fichiers modifiés
```text
Cargo.toml
README.md
crates/common/game-realtime-webtransport-lib/tests/datagrams.rs
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
```
Nouveau fichier :
```text
deltas/0.3.5/alpha.9.fix.1.md
```
Aucun `history/0.3.5/alpha.9.md` n'est créé avant une gate entièrement verte.
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo run -p game-realtime-webtransport-datagram-smoke
cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings
cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
```
Le smoke doit terminer par :
```text
game-realtime-webtransport-datagram-smoke: PASS
```
## Après validation
Créer `history/0.3.5/alpha.9.fix.1.md` à partir des sorties réellement fournies, puis ouvrir `0.3.5-alpha.10` pour les mesures comparatives bornées WebSocket / WebTransport fiable, avec les datagrams mesurés séparément.

180
deltas/0.3.5/alpha.9.md Normal file
View File

@@ -0,0 +1,180 @@
<!-- file: deltas/0.3.5/alpha.9.md -->
<!-- version: 1 -->
# Delta 0.3.5-alpha.9
## Base requise
`0.3.5-alpha.8.fix.1`, validée par l'utilisateur le 2026-09-22.
La gate finale confirme fmt, audits, `cargo check --workspace`, Clippy strict, les deux tests du launcher fallback et le smoke réel terminé par :
```text
game-realtime-transport-fallback-smoke: PASS
```
La preuve est enregistrée dans `history/0.3.5/alpha.8.fix.1.md`.
## Objectif
Challenger les datagrams WebTransport comme capacité backend-spécifique sans modifier le contrat fiable transport-neutral :
- émission/réception native réelle ;
- surface équivalente compilable côté navigateur/WASM ;
- taille maximale de datagram obtenue depuis la session ;
- rejet local des payloads trop grands ;
- preuve runtime locale bornée ;
- aucune garantie de livraison ou d'ordre ajoutée ;
- aucune API datagram dans `game-realtime-transport-lib`.
## Version
La version workspace passe à :
```text
0.3.5-alpha.9
```
## Capacité backend-spécifique
`WebTransportSession` expose désormais sur les chemins natif et navigateur :
```text
max_datagram_size()
send_datagram(...)
receive_datagram()
```
Cette surface reste propre à `game-realtime-webtransport-lib`.
Les datagrams WebTransport sont non fiables et non ordonnés. Ils ne satisfont donc pas les invariants de `RealtimeConnection`, qui reste le chemin fiable ordonné commun à WebSocket et WebTransport.
Le backend vérifie la taille avant émission et retourne `TransportErrorKind::MessageTooLarge` lorsque le payload dépasse `max_datagram_size()` pour la session courante.
`receive_datagram()` n'introduit aucune deadline implicite. Une attente bornée relève du consommateur/POC, car la perte d'un datagram est un résultat valide et ne doit pas être transformée en erreur de protocole commune.
## Natif
Le chemin natif utilise directement les primitives de `web-transport-quinn 0.12.1` :
```text
Session::max_datagram_size
Session::send_datagram
Session::read_datagram
```
Un test d'intégration couvre :
- session loopback avec pin SHA-256 exact ;
- datagram client -> serveur ;
- datagram serveur -> client ;
- attente bornée côté test ;
- rejet local d'un payload supérieur à la taille négociée.
## WASM
Le chemin `wasm32-unknown-unknown` expose la même capacité via `web-transport-wasm 0.6.0` :
```text
Session::max_datagram_size
Session::send_datagram
Session::recv_datagram
```
`send_datagram(...)` est asynchrone côté navigateur conformément à la surface du wrapper.
La tranche ne réouvre pas le smoke navigateur interactif : `alpha.7.fix.2` reste la preuve navigateur du chemin fiable. La gate WASM de `alpha.9` vérifie que la nouvelle capacité compile sur la cible navigateur.
## Smoke datagram natif
Nouveau launcher technique :
```text
crates/apps/game-realtime-webtransport-datagram-smoke
```
Le smoke :
1. génère une identité P-256 loopback en mémoire ;
2. bind un serveur WebTransport sur port éphémère ;
3. établit client et serveur avec pin SHA-256 exact ;
4. lit les tailles maximales datagram des deux sessions ;
5. envoie un datagram client -> serveur ;
6. attend sa réception sous une deadline locale de 2 s ;
7. envoie un datagram serveur -> client ;
8. attend sa réception sous la même deadline ;
9. termine par un verdict déterministe.
Résultat attendu :
```text
game-realtime-webtransport-datagram-smoke: PASS
```
Le smoke ne teste pas l'ordre et ne prétend pas garantir la livraison. Un timeout de réception est reporté comme échec du smoke local, avec un message rappelant explicitement que la livraison datagram n'est pas garantie.
## Frontières
Le delta ne modifie pas :
- `game-realtime-transport-lib` ;
- `RealtimeConnection` / `RealtimeSender` / `RealtimeReceiver` ;
- `game-realtime-websocket-lib` ;
- le fallback `alpha.8` ;
- les engines ;
- les crates gameplay.
La décision de `alpha.9` est donc : **capacité WebTransport backend-spécifique utile à conserver pour expérimentation/mesure, sans promotion au contrat commun**.
## Fichiers principaux
Nouveaux :
```text
crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml
crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs
crates/common/game-realtime-webtransport-lib/tests/datagrams.rs
deltas/0.3.5/alpha.9.md
history/0.3.5/alpha.8.fix.1.md
```
Modifiés :
```text
Cargo.toml
README.md
crates/common/game-realtime-webtransport-lib/README.md
crates/common/game-realtime-webtransport-lib/USAGE.md
crates/common/game-realtime-webtransport-lib/src/webtransport.rs
crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
scripts/audit_distribution_layout.py
```
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo run -p game-realtime-webtransport-datagram-smoke
cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings
cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo tree -p game-realtime-webtransport-datagram-smoke --edges normal
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
```
## Après validation
Créer `history/0.3.5/alpha.9.md` à partir des sorties réellement fournies puis ouvrir `0.3.5-alpha.10` pour les mesures comparatives bornées WebSocket / WebTransport fiable, avec datagram uniquement comme capacité WebTransport séparée.

View File

@@ -0,0 +1,69 @@
<!-- file: deltas/0.3.5/beta.1.fix.1.md -->
<!-- version: 1 -->
# Delta 0.3.5-beta.1.fix.1
## Base requise
`0.3.5-beta.1`.
## Objet
Fermer le défaut de cohérence de version découvert pendant la gate `beta.1` : le host frontend du smoke navigateur restait déclaré en `0.3.5-alpha.7.fix.2` alors que sa crate Rust et le workspace étaient en `0.3.5-beta.1`.
La version technique devient :
```text
0.3.5-beta.1.fix.1
```
## Modifications
- synchroniser `Web/game-realtime-webtransport-browser-smoke/package.json` avec `0.3.5-beta.1.fix.1` ;
- ajouter `DIST-LAYOUT-114` afin que laudit compare désormais cette version frontend à `[workspace.package].version` ;
- mettre à jour le README et le plan de version ;
- ne modifier aucun backend realtime, contrat, smoke, mesure, engine ou gameplay.
La tentative Android ARM64 de `beta.1` reste classée comme **preuve non exécutée pour cause denvironnement** : `ring` a échoué avant compilation du backend car `aarch64-linux-android-clang` nétait pas disponible. Ce point ne constitue pas un défaut de code démontré et reste à documenter comme plateforme non validée dans `beta.2`.
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
```
Puis vérifier le smoke navigateur réel :
Terminal 1 :
```bash
cargo run -p game-realtime-webtransport-browser-smoke
```
Terminal 2 :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm run dev)
```
Ouvrir lURL `OPEN=...` et attendre :
```text
game-realtime-webtransport-browser-smoke: PASS
```
Le log npm doit annoncer `game-realtime-webtransport-browser-smoke-web@0.3.5-beta.1.fix.1`.
## Après validation
Créer `history/0.3.5/beta.1.fix.1.md`, puis ouvrir `0.3.5-beta.2`.

105
deltas/0.3.5/beta.1.md Normal file
View File

@@ -0,0 +1,105 @@
<!-- file: deltas/0.3.5/beta.1.md -->
<!-- version: 1 -->
# Delta 0.3.5-beta.1
## Base requise
`0.3.5-alpha.10.fix.1` validée.
## Objet
Ouvrir la validation large de `0.3.5` sans nouvelle fonctionnalité. Toutes les capacités alpha sont déjà fermées : WebSocket baseline, WebTransport natif fiable, navigateur/WASM, fallback classifié, datagrams backend-spécifiques et mesures loopback bornées.
La version technique devient :
```text
0.3.5-beta.1
```
## Modifications
- enregistrer `history/0.3.5/alpha.10.fix.1.md` avec la gate et les mesures réellement observées ;
- synchroniser la version workspace et le README vers `0.3.5-beta.1` ;
- activer dans le plan la gate beta full-workspace et les preuves runtime/multiplateformes retenues ;
- ne modifier aucun backend, contrat realtime, engine, gameplay, frontend ou dépendance.
`CHANGELOG.md` reste inchangé : sa consolidation est réservée à `beta.2`, conformément aux contrats documentaires et au plan.
## Validation attendue — gate automatisable
```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 --workspace --all-targets --all-features
cargo run -p game-realtime-websocket-smoke
cargo run -p game-realtime-webtransport-smoke
cargo run -p game-realtime-webtransport-datagram-smoke
cargo run -p game-realtime-transport-fallback-smoke
cargo run --release -p game-realtime-transport-measure
cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings
cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
cargo tree -p game-realtime-websocket-lib --edges normal
cargo tree -p game-realtime-webtransport-lib --edges normal
cargo tree -i game-realtime-websocket-lib --workspace --edges normal
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
```
Si la cible Android/NDK déjà utilisée par le checkout est disponible, ajouter :
```bash
cargo check -p game-realtime-webtransport-lib --target aarch64-linux-android
```
Cette commande est une preuve de portabilité du backend uniquement ; aucun APK/AAB, Gradle ou JNI n'est demandé dans `beta.1` puisque ce périmètre n'a pas été modifié.
## Validation navigateur réelle
Après le build frontend, lancer dans un premier terminal :
```bash
cargo run -p game-realtime-webtransport-browser-smoke
```
Puis dans un second :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm run dev)
```
Ouvrir l'URL `OPEN=...` imprimée par le premier terminal. La preuve attendue est :
```text
game-realtime-webtransport-browser-smoke: PASS
```
## Critères beta
La gate doit confirmer :
- aucun test workspace en échec ;
- aucun warning Clippy ;
- tous les smokes realtime retenus en `PASS` ;
- build/smoke navigateur toujours opérationnels ;
- le graphe inverse des backends ne contient aucun `engine-v1-*` ni `game-*-poc` gameplay ;
- la conclusion `webtransport=retain` reste une décision de trajectoire, pas une affirmation de supériorité de performance.
Un défaut fermé produit `0.3.5-beta.1.fix.N`. Une capacité fonctionnelle majeure réellement manquante imposerait de rouvrir une alpha conformément au plan.
## Après validation
Créer `history/0.3.5/beta.1.md`, puis ouvrir `0.3.5-beta.2` pour la consolidation pré-RC : documentation durable, conclusion finale `retained/deferred/rejected`, plateformes validées/non validées, `CHANGELOG.md`, `ROADMAP.md` si nécessaire et prompt `0.3.6`.

93
deltas/0.3.5/beta.2.md Normal file
View File

@@ -0,0 +1,93 @@
<!-- file: deltas/0.3.5/beta.2.md -->
<!-- version: 1 -->
# Delta 0.3.5-beta.2
## Base requise
`0.3.5-beta.1.fix.1` validée.
## Objet
Fermer la consolidation pré-RC de `0.3.5` sans nouvelle fonctionnalité. La gate précédente confirme le workspace complet, les smokes realtime, le chemin navigateur/WASM, les mesures, la cohérence du host frontend et la cross-compilation Android ARM64/API 21 du backend WebTransport.
La version technique devient :
```text
0.3.5-beta.2
```
La décision finale préparée pour la RC est :
```text
WebSocket -> baseline/fallback de référence
WebTransport -> second backend realtime retenu
Datagrams -> capacité WebTransport backend-spécifique
```
## Modifications
- enregistrer `history/0.3.5/beta.1.fix.1.md` avec les preuves réellement obtenues ;
- synchroniser la version workspace et le host navigateur sur `0.3.5-beta.2` ;
- réconcilier `README.md` avec la décision `retained` ;
- mettre à jour `game-realtime-transport-lib/README.md` afin qu'il ne présente plus WebTransport comme un POC futur ;
- mettre à jour `game-realtime-webtransport-lib/README.md` afin de qualifier le backend comme second backend retenu et d'expliciter les plateformes réellement prouvées ;
- documenter dans `game-realtime-webtransport-lib/USAGE.md` la cross-compilation Android ARM64/API 21 avec NDK `28.2.13676358`, sans chemin machine absolu ;
- promouvoir la décision réseau dans `docs/architecture/013-NETWORK_AND_SERVER_ARCHITECTURE.md` ;
- réconcilier `docs/architecture/015-PLATFORM_POC_ARCHITECTURE.md` afin de distinguer transport validé et couches session/synchronisation encore futures ;
- figer la conclusion `retained` et ses limites dans l'étude de mesure `027` ;
- réconcilier les index documentaires concernés ;
- mettre à jour le plan actif `0.3.5` avec la fermeture de `beta.1.fix.1` et le scope exact de `beta.2` ;
- créer `prompts/007-V0_3_6_START_PROMPT.md` pour la consolidation des POC `0.3.x` et la préparation de la baseline `0.4.x`.
Aucun fichier Rust, backend, dépendance, protocole, fallback, datagram, engine ou gameplay n'est modifié.
`CHANGELOG.md` reste volontairement inchangé : l'entrée candidate est réservée à `rc.1`. `ROADMAP.md` reste également inchangé : `0.3.5` n'est pas encore stable et aucun statut macroscopique ne change dans cette tranche.
## Plateformes consolidées
La documentation de `beta.2` distingue explicitement :
```text
Linux natif runtime client/server validé
Navigateur/WASM client réel validé contre serveur Rust natif
Android ARM64 / API 21 cross-compilation validée
Android runtime non validé pour WebTransport par 0.3.5
Apple non validé par 0.3.5
```
La preuve Android de compilation ne doit pas être transformée en affirmation de compatibilité runtime non testée.
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
```
Aucun full workspace test n'est répété dans `beta.2` : `beta.1` l'a déjà validé avec 77 tests et cette tranche ne modifie aucun code Rust ou comportement. Le full workspace est réservé de nouveau à `rc.1` conformément au plan.
## Critères de fermeture
La tranche est propre si :
- les audits restent verts ;
- Cargo check/Clippy restent sans warning ;
- le host navigateur porte bien `0.3.5-beta.2` et son build reste propre ;
- aucune documentation durable ne décrit encore WebTransport comme seulement un candidat futur ;
- aucune documentation ne prétend que les datagrams satisfont `RealtimeConnection` ;
- aucune plateforme non testée n'est présentée comme validée ;
- le prompt `0.3.6` part de la future stable/tag `v0.3.5` sans présenter cette publication future comme déjà acquise.
## Après validation
Créer `history/0.3.5/beta.2.md`, puis ouvrir `0.3.5-rc.1`. La RC reste fonctionnellement gelée, porte l'entrée `CHANGELOG.md` candidate, revalide le workspace complet et vérifie le prompt `0.3.6` avant promotion stable.

121
deltas/0.3.5/rc.1.md Normal file
View File

@@ -0,0 +1,121 @@
<!-- file: deltas/0.3.5/rc.1.md -->
<!-- version: 1 -->
# Delta 0.3.5-rc.1
## Base requise
`0.3.5-beta.2` validée.
## Objet
Geler la candidate de publication `0.3.5` sans rouvrir le scope fonctionnel. La RC porte pour la première fois l'entrée candidate dans `CHANGELOG.md`, enregistre la consolidation beta validée et repasse la gate complète de publication.
La version technique devient :
```text
0.3.5-rc.1
```
La décision candidate reste :
```text
WebSocket -> baseline/fallback de référence
WebTransport -> second backend realtime retained
Datagrams -> capacité WebTransport backend-spécifique
```
## Modifications
- enregistrer `history/0.3.5/beta.2.md` avec la gate documentaire réellement validée ;
- synchroniser `workspace.package.version` et le host navigateur sur `0.3.5-rc.1` ;
- ajouter l'entrée `0.3.5-rc.1` dans `CHANGELOG.md` conformément à `DOC-CHG-003/004` ;
- mettre à jour `README.md` et le plan actif pour refléter la candidate gelée ;
- conserver `ROADMAP.md` inchangé : la version n'est pas encore stable et aucun statut macroscopique nouveau n'est acquis ;
- conserver `prompts/007-V0_3_6_START_PROMPT.md` inchangé s'il reste correct ; sa formulation doit continuer à distinguer la future stable `v0.3.5` de l'état RC actuel.
Aucun code Rust, backend, dépendance, protocole, fallback, datagram, engine ou gameplay n'est modifié par ce delta.
## Validation attendue
```bash
cargo fmt --all
cargo fmt --all -- --check
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates Android Web deltas history
python3 scripts/audit_distribution_layout.py
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-targets --all-features
cargo run -p game-realtime-websocket-smoke
cargo run -p game-realtime-webtransport-smoke
cargo run -p game-realtime-webtransport-datagram-smoke
cargo run -p game-realtime-transport-fallback-smoke
cargo run --release -p game-realtime-transport-measure
cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings
cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
cargo tree -p game-realtime-websocket-lib --edges normal
cargo tree -p game-realtime-webtransport-lib --edges normal
cargo tree -i game-realtime-websocket-lib --workspace --edges normal
cargo tree -i game-realtime-webtransport-lib --workspace --edges normal
```
La preuve Android ARM64/API 21 doit aussi être repassée avec le NDK déjà retenu, sans chemin machine absolu :
```bash
ANDROID_SDK="${ANDROID_HOME:-$ANDROID_SDK_ROOT}"
export ANDROID_NDK_HOME="$ANDROID_SDK/ndk/28.2.13676358"
export TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64"
export CC_aarch64_linux_android="$TOOLCHAIN/bin/aarch64-linux-android21-clang"
export CXX_aarch64_linux_android="$TOOLCHAIN/bin/aarch64-linux-android21-clang++"
export AR_aarch64_linux_android="$TOOLCHAIN/bin/llvm-ar"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TOOLCHAIN/bin/aarch64-linux-android21-clang"
cargo check -p game-realtime-webtransport-lib --target aarch64-linux-android
```
Le smoke navigateur réel est également une gate RC. Terminal 1 :
```bash
cargo run -p game-realtime-webtransport-browser-smoke
```
Terminal 2 :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm run dev)
```
Ouvrir ensuite l'URL `OPEN=...` et obtenir :
```text
game-realtime-webtransport-browser-smoke: PASS
```
## Contrôles documentaires RC
Vérifier explicitement que :
- `CHANGELOG.md` contient l'entrée candidate `0.3.5-rc.1` mais aucune entrée stable `0.3.5` ;
- `ROADMAP.md` n'est pas artificiellement clôturé avant la stable ;
- le host navigateur annonce `0.3.5-rc.1` ;
- `prompts/007-V0_3_6_START_PROMPT.md` demande de partir de `v0.3.5` tout en rappelant que cette stable/tag n'existe pas encore pendant la RC ;
- aucun engine/gameplay ne dépend directement d'un backend realtime concret ;
- aucune documentation ne transforme les datagrams en capacité `RealtimeConnection` ;
- aucune preuve de cross-compilation Android n'est décrite comme smoke runtime Android WebTransport.
## Après validation
Si toutes les gates sont vertes, créer `history/0.3.5/rc.1.md` dans le delta stable `0.3.5`, promouvoir mécaniquement les versions vers `0.3.5`, convertir l'entrée changelog candidate en entrée stable correspondante, clôturer le plan/ROADMAP si nécessaire et ajuster mécaniquement le prompt `0.3.6` pour une base désormais réellement publiée/taggée.
Tout défaut nécessaire à la publication produit `0.3.5-rc.1.fix.N` conformément à `VER-RC-*`; tout nouveau scope fonctionnel abandonne la RC et revient à une phase adaptée.

81
deltas/0.3.5/rel.001.md Normal file
View File

@@ -0,0 +1,81 @@
<!-- file: deltas/0.3.5/rel.001.md -->
<!-- version: 1 -->
# Delta 0.3.5 — release stable
## Base
Base validée : `0.3.5-rc.1`.
## Objet
Promouvoir mécaniquement la candidate validée vers `0.3.5` sans introduire de nouveau comportement.
## Changements
La release stable :
- passe `workspace.package.version` de `0.3.5-rc.1` à `0.3.5` ;
- synchronise le host `Web/game-realtime-webtransport-browser-smoke` sur `0.3.5` ;
- positionne `README.md` sur `0.3.5` comme stable de référence et `0.3.6-alpha.1` comme prochaine version planifiée ;
- marque `0.3.5` terminée dans `ROADMAP.md` ;
- ajoute l'entrée stable `0.3.5` dans `CHANGELOG.md` ;
- enregistre la validation effective de la RC dans `history/0.3.5/rc.1.md` ;
- clôt `docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md` et ses deux entrées d'index ;
- ajuste mécaniquement `prompts/007-V0_3_6_START_PROMPT.md` pour qu'il soit utilisé après publication effective du tag stable `v0.3.5`.
## Frontière de release
Aucun fichier Rust, test Rust, manifeste de crate realtime, dépendance tierce, Android/Gradle, gameplay, moteur, comportement WebSocket/WebTransport, fallback, datagram, framing, limite ou timeout n'est modifié après la RC.
La stable conserve notamment :
```text
WebSocket -> baseline/fallback de référence
WebTransport -> second backend realtime retained
Datagrams -> capacité WebTransport backend-spécifique, hors RealtimeConnection
```
Les preuves de plateforme restent bornées : Linux natif et navigateur/WASM ont une preuve runtime ; Android ARM64/API 21 a une preuve de cross-compilation du backend ; Android runtime WebTransport et Apple restent non validés.
## Validation proportionnelle
La RC fonctionnellement identique a déjà validé audits, `cargo check`, Clippy strict, **77 tests workspace**, quatre smokes natifs, mesures release, gates WASM/frontend, smoke navigateur réel, cross-compilation Android ARM64/API 21 et graphes de dépendances.
La promotion stable demande uniquement les contrôles proportionnels aux changements de version et de documentation/build-metadata :
```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
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
```
Clippy strict, full tests, smokes natifs/browser et cross-compilation Android ne sont pas répétés par cérémonie puisque cette promotion ne modifie aucun code ni comportement. Tout échec de la gate ci-dessus doit néanmoins être corrigé avant publication.
## Contrôles documentaires de release
Vérifier explicitement que :
- `CHANGELOG.md` contient une entrée stable `0.3.5` au-dessus de l'entrée `0.3.5-rc.1` ;
- `ROADMAP.md` marque `0.3.5` `(x)` et laisse `0.3.6` planifiée ;
- le host navigateur annonce `0.3.5` ;
- le plan `0.3.5` et ses index sont clôturés ;
- `prompts/007-V0_3_6_START_PROMPT.md` demande `v0.3.5` comme base et précise qu'il ne doit être utilisé qu'après publication effective de ce tag ;
- aucune documentation ne transforme les datagrams en capacité `RealtimeConnection` ;
- aucune preuve de cross-compilation Android n'est décrite comme smoke runtime Android WebTransport.
## Publication
Après validation de ce delta :
- commit de release ;
- tag stable unique `v0.3.5` ;
- archive ZIP téléchargée depuis ce tag utilisée comme baseline autoritaire de la session suivante ;
- démarrage de `0.3.6` uniquement depuis `v0.3.5` avec `prompts/007-V0_3_6_START_PROMPT.md` ;
- première tranche suivante : `0.3.6-alpha.1`, audit/consolidation des POC `0.3.x` et création du plan actif `0.3.6`.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/000-README.md --> <!-- file: docs/000-README.md -->
<!-- version: 34 --> <!-- version: 38 -->
# Documentation games.sasedev # Documentation games.sasedev
@@ -11,6 +11,8 @@
- [`ideas/000-README.md`](ideas/000-README.md) — rôle des idées non engagées et règles de maturation. - [`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/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.
- [`studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md`](studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md) — méthode et conclusion consolidée de caractérisation loopback WebSocket/WebTransport fiable, avec datagram séparé.
## Plans ## Plans
@@ -19,6 +21,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/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/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/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 clôturé de `0.3.5`, second backend WebTransport/QUIC retenu, navigateur, fallback WebSocket et mesures comparatives.
## Architecture ## Architecture
@@ -31,9 +34,9 @@
- [`architecture/010-RUNTIME_PROVENANCE.md`](architecture/010-RUNTIME_PROVENANCE.md) — provenance plateforme/device/runtime/input pour sessions, scores et analytics. - [`architecture/010-RUNTIME_PROVENANCE.md`](architecture/010-RUNTIME_PROVENANCE.md) — provenance plateforme/device/runtime/input pour sessions, scores et analytics.
- [`architecture/011-POST_0_1_0_DIRECTION.md`](architecture/011-POST_0_1_0_DIRECTION.md) — décisions de transition entre le POC stable 0.1.0 et la conception modulaire 0.2.0. - [`architecture/011-POST_0_1_0_DIRECTION.md`](architecture/011-POST_0_1_0_DIRECTION.md) — décisions de transition entre le POC stable 0.1.0 et la conception modulaire 0.2.0.
- [`architecture/012-MODULAR_LAYERING_AND_OWNERSHIP.md`](architecture/012-MODULAR_LAYERING_AND_OWNERSHIP.md) — architecture durable kernel/capabilities/game-systems/games/adapters/providers/services/tooling. - [`architecture/012-MODULAR_LAYERING_AND_OWNERSHIP.md`](architecture/012-MODULAR_LAYERING_AND_OWNERSHIP.md) — architecture durable kernel/capabilities/game-systems/games/adapters/providers/services/tooling.
- [`architecture/013-NETWORK_AND_SERVER_ARCHITECTURE.md`](architecture/013-NETWORK_AND_SERVER_ARCHITECTURE.md) — Web/API, realtime, transports, asset delivery auto-hébergé et frontières serveur. - [`architecture/013-NETWORK_AND_SERVER_ARCHITECTURE.md`](architecture/013-NETWORK_AND_SERVER_ARCHITECTURE.md) — Web/API, baseline WebSocket, second backend WebTransport retenu, asset delivery auto-hébergé et frontières serveur.
- [`architecture/014-UROBURAS_TARGET_ARCHITECTURE.md`](architecture/014-UROBURAS_TARGET_ARCHITECTURE.md) — ownership cible des besoins Uroburas et ordre Mode 1 → Mode 3 → Mode 2. - [`architecture/014-UROBURAS_TARGET_ARCHITECTURE.md`](architecture/014-UROBURAS_TARGET_ARCHITECTURE.md) — ownership cible des besoins Uroburas et ordre Mode 1 → Mode 3 → Mode 2.
- [`architecture/015-PLATFORM_POC_ARCHITECTURE.md`](architecture/015-PLATFORM_POC_ARCHITECTURE.md) — rôle des POC `0.3.x`, Snake comme sonde et critères d'extraction. - [`architecture/015-PLATFORM_POC_ARCHITECTURE.md`](architecture/015-PLATFORM_POC_ARCHITECTURE.md) — rôle des POC `0.3.x`, Snake comme sonde, décisions plateforme/réseau retenues et critères d'extraction.
- [`architecture/016-V0_2_0_CONSOLIDATED_BASELINE.md`](architecture/016-V0_2_0_CONSOLIDATED_BASELINE.md) — synthèse de la baseline destinée au gel RC `0.2.0`. - [`architecture/016-V0_2_0_CONSOLIDATED_BASELINE.md`](architecture/016-V0_2_0_CONSOLIDATED_BASELINE.md) — synthèse de la baseline destinée au gel RC `0.2.0`.
- [`architecture/017-V0_2_0_RC_REVIEW.md`](architecture/017-V0_2_0_RC_REVIEW.md) — checklist de cohérence et scope gelé de la candidate `0.2.0-3-rc.1`. - [`architecture/017-V0_2_0_RC_REVIEW.md`](architecture/017-V0_2_0_RC_REVIEW.md) — checklist de cohérence et scope gelé de la candidate `0.2.0-3-rc.1`.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/architecture/013-NETWORK_AND_SERVER_ARCHITECTURE.md --> <!-- file: docs/architecture/013-NETWORK_AND_SERVER_ARCHITECTURE.md -->
<!-- version: 1 --> <!-- version: 2 -->
# Architecture réseau et serveur # Architecture réseau et serveur
@@ -21,22 +21,19 @@ Actix Web porte le Web/API, l'authentification, les comptes, Hall of Fame, metad
Le realtime est séparé du Web/API classique. Le realtime est séparé du Web/API classique.
Baseline : Backends retenus :
```text ```text
Tokio
tokio-tungstenite
WebSocket WebSocket
``` Tokio + tokio-tungstenite
baseline/fallback de référence
Candidat à évaluer :
```text
WebTransport WebTransport
QUIC QUIC + HTTP/3
second backend retenu
``` ```
La simulation authoritative ne dépend directement d'aucun de ces transports. La simulation authoritative ne dépend directement d'aucun de ces transports. WebTransport est retenu à l'issue du POC `0.3.5` pour son chemin fiable natif/navigateur et sa capacité datagram optionnelle, sans remplacer WebSocket ni élargir artificiellement le contrat commun.
## Frontière de transport ## Frontière de transport
@@ -52,7 +49,7 @@ synchronization
authoritative simulation authoritative simulation
``` ```
Le client utilise une realtime transport API. WebSocket reste le fallback de référence. WebTransport est évalué lorsqu'il apporte un bénéfice mesuré. Le client utilise une realtime transport API fiable/ordonnée commune. WebSocket reste le fallback de référence. WebTransport peut satisfaire cette même frontière via son stream bidirectionnel primaire ; ses datagrams restent backend-spécifiques car leur sémantique non fiable/non ordonnée nappartient pas à `RealtimeConnection`. Le choix WebTransport-first/fallback WebSocket appartient à la composition ou à un adapter de plateforme, jamais au gameplay.
## gRPC ## gRPC
@@ -83,3 +80,13 @@ L'architecture permet ensuite de séparer Web/API, Assets, Realtime, Database et
Debian Stable est la cible opérationnelle préférée. Debian Stable est la cible opérationnelle préférée.
Le choix futur d'un edge/reverse proxy HTTP/3, stockage ou composant d'infrastructure reste reporté aux POC correspondants et doit tenir compte de la disponibilité/maturité sur Debian Stable. Le choix futur d'un edge/reverse proxy HTTP/3, stockage ou composant d'infrastructure reste reporté aux POC correspondants et doit tenir compte de la disponibilité/maturité sur Debian Stable.
## Portabilité démontrée par le POC WebTransport 0.3.5
La preuve `0.3.5` couvre :
- Linux natif client/server WebTransport en loopback ;
- client navigateur/WASM réel contre le serveur Rust natif ;
- cross-compilation du backend pour Android ARM64 `aarch64-linux-android`, API 21, avec NDK `28.2.13676358`.
La preuve Android est une preuve de compilation et ne vaut pas smoke runtime réseau sur appareil. Les plateformes Apple ne sont pas déclarées validées par cette version. Les résultats de caractérisation loopback sont documentés séparément et ne sont pas extrapolés à Internet/mobile.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/architecture/015-PLATFORM_POC_ARCHITECTURE.md --> <!-- file: docs/architecture/015-PLATFORM_POC_ARCHITECTURE.md -->
<!-- version: 5 --> <!-- version: 6 -->
# Architecture des POC plateforme et réseau # Architecture des POC plateforme et réseau
@@ -32,9 +32,9 @@ Cette décision ninterdit pas Tauri Desktop. Le Desktop reste une distributio
## POC réseau ## POC réseau
Avant le Mode 3, comparer au minimum WebSocket/tokio-tungstenite, WebTransport/QUIC, fallback automatique, charge, reconnect/resync, backpressure, snapshots/deltas et mobilité réseau. `0.3.4` puis `0.3.5` ont fermé la question du transport de base : WebSocket/tokio-tungstenite reste la baseline/fallback de référence et WebTransport/QUIC est retenu comme second backend. Les deux partagent le contrat fiable/ordonné minimal de `game-realtime-transport-lib`; les datagrams WebTransport restent une capacité backend-spécifique. Le fallback est possédé par la composition, pas par le gameplay.
Le même protocole métier doit pouvoir être exercé sur plusieurs transports. Les questions reconnect/resync, snapshots/deltas, mobilité réseau et protocole de session restent des couches supérieures distinctes. Elles ne doivent pas être présentées comme déjà validées par le POC transport `0.3.5`. Le même futur protocole métier doit pouvoir être exercé sur plusieurs transports lorsque cette couche sera réellement introduite.
## Build ## Build

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/000-README.md --> <!-- file: docs/plans/000-README.md -->
<!-- version: 6 --> <!-- version: 8 -->
# Plans de versions games.sasedev # 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. - [`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. - [`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. - [`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 clôturé de `0.3.5`, second backend WebTransport/QUIC retenu, navigateur, fallback WebSocket et comparaison mesurée.

View File

@@ -0,0 +1,712 @@
<!-- file: docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md -->
<!-- version: 22 -->
# Plan 0.3.5 — POC WebTransport/QUIC et fallback WebSocket
## Statut
Plan clôturé avec la promotion stable `0.3.5` du 2026-09-22. Il a été créé pendant `0.3.5-alpha.1` à partir de l'archive taggée `v0.3.4` et reste désormais une référence historique.
Les tranches `alpha.1`, `alpha.2.fix.1`, `alpha.3`, `alpha.4.fix.1`, `alpha.5`, `alpha.6`, `alpha.7.fix.2`, `alpha.8.fix.1`, `alpha.9.fix.1`, `alpha.10.fix.1`, `beta.1.fix.1`, `beta.2` et `rc.1` ont été validées. Les prereleases intermédiaires ayant nécessité un fix restent documentées dans `deltas/0.3.5/`. La RC finale a repassé les audits, la compilation workspace, Clippy strict, **77 tests workspace**, les quatre smokes natifs, les mesures release, les builds WASM/frontend, le smoke navigateur réel, la cross-compilation Android ARM64/API 21 et les graphes de dépendances sans défaut. La stable est une promotion mécanique de cette RC gelée. WebTransport est **retained** comme second backend realtime et WebSocket reste la baseline/fallback de référence.
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 dun 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 dintégration ; aucun contrat, comportement transport ou scope de `alpha.3` nest 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. La classification est volontairement conservative : tant que le backend WebTransport natif agrège sous `TransportErrorKind::Connect` des causes d'établissement qui peuvent inclure un échec TLS/pinning, `Connect` ne doit pas déclencher un downgrade silencieux. `alpha.8` autorise uniquement `Timeout` et `Io` comme causes de fallback ; `InvalidConfiguration`, `Connect`, `Protocol`, `Aborted`, `Closed`, `Bind`, `Accept`, `MessageTooLarge` et `Backpressure` restent visibles.
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` a explicité ce `return` sans changement sémantique, d'API, de framing, de lifecycle ou de dépendance. Sa gate complète est ensuite passée : audits, check workspace, Clippy strict, sept tests du contrat commun et vingt tests WebTransport. La preuve est conservée dans `history/0.3.5/alpha.4.fix.1.md`.
### `0.3.5-alpha.5` — smoke natif hors harness
Tranche validée avec :
- nouvelle app technique `game-realtime-webtransport-smoke` ;
- bind UDP loopback sur port éphémère ;
- identité TLS ECDSA P-256 locale générée en mémoire et pin SHA-256 dérivé de cette identité ;
- établissement WebTransport client/server réel sur `https://127.0.0.1:<port>/smoke` ;
- ouverture/accept du stream fiable principal via l'API publique du backend ;
- round-trip binaire bidirectionnel via `RealtimeConnection` ;
- FIN propre client puis serveur, chacun observé comme `TransportReceive::Closed` par le pair ;
- timeout global du launcher et verdict terminal déterministe `game-realtime-webtransport-smoke: PASS` ;
- aucune nouvelle dépendance externe et aucun couplage engine/gameplay.
Cette tranche reste distincte pour ne pas mélanger robustesse de bibliothèque et preuve runtime publique. La gate utilisateur du 2026-09-21 a confirmé fmt, audits, check workspace, Clippy strict, les sept tests du contrat commun, les vingt tests WebTransport, le smoke hors harness avec verdict `PASS` et les graphes direct/inverse attendus. La preuve est conservée dans `history/0.3.5/alpha.5.md`.
### `0.3.5-alpha.6` — chemin WASM compilable
Tranche candidate matérialisée avec :
- `web-transport-wasm 0.6.0` comme dépendance exclusivement `wasm32` ;
- `web_sys_unstable_apis` ciblé par Cargo uniquement sur `wasm32-unknown-unknown`, y compris rustdoc ;
- dépendances Quinn/Tokio/rcgen du backend déplacées dans la branche native afin que le build WASM ne tire pas la stack serveur ;
- même surface client publique qu'en natif pour hash SHA-256, config endpoint, `connect`, session et stream primaire ;
- pin navigateur transmis via `serverCertificateHashes` ;
- même framing fiable `u32` big-endian + payload et même limite avant allocation/écriture ;
- `RealtimeConnection`, sender et receiver adaptés à des types navigateur `!Send`, ce que le contrat commun autorise déjà ;
- FIN, reset/STOP, cancellation de send terminale et réception incrémentale conservés sur le chemin navigateur ;
- aucun changement Snake/Reflex et aucun host frontend.
La gate utilisateur du 2026-09-22 a confirmé le host natif inchangé, les sept tests du contrat commun, les vingt tests WebTransport, le smoke natif `PASS`, puis `cargo check`, Clippy `--lib` et build réel de `game-realtime-webtransport-lib` sur `wasm32-unknown-unknown`. Le graphe WASM ne tire ni Quinn, ni rcgen, ni Tokio. La preuve est conservée dans `history/0.3.5/alpha.6.md`.
Différence fermée dans `alpha.7` : les deadlines `WebTransportConfig` sont matérialisées côté navigateur avec un timer WASM. `connect_timeout`, `primary_stream_timeout` et `send_timeout` sont bornés ; un send expiré reset le stream comme en natif. `receive()` reste volontairement sans timeout d'idle implicite. Les durées navigateur doivent tenir dans `u32` millisecondes, plage du timer retenu, sinon la configuration est refusée avant établissement.
### `0.3.5-alpha.7` — interop navigateur et TLS local
Tranche candidate matérialisée avec :
- `gloo-timers` target-specific WASM pour appliquer les deadlines sans runtime Tokio navigateur ;
- `game-realtime-webtransport-browser-smoke`, package technique avec pair serveur natif et adapter `cdylib` WASM ;
- host direct `Web/game-realtime-webtransport-browser-smoke` en Vite/TypeScript sur `127.0.0.1:1435` ;
- page loopback vérifiant `window.isSecureContext` et la présence de WebTransport avant d'appeler le Rust/WASM ;
- serveur Rust sur port QUIC éphémère, certificat P-256 court en mémoire et SHA-256 imprimé explicitement ;
- endpoint/hash passés au frontend par query string ou saisie manuelle ;
- navigateur -> serveur Rust WebTransport sans fallback WebSocket ;
- round-trip binaire dans les deux sens via le stream primaire et `RealtimeConnection` ;
- FIN navigateur observé côté serveur, puis FIN serveur observé côté navigateur ;
- verdict `game-realtime-webtransport-browser-smoke: PASS` des deux côtés ;
- aucune désactivation de TLS et aucun secret/certificat versionné.
La page Vite peut rester servie en HTTP sur loopback : `127.0.0.1` est un origin potentiellement fiable/secure context dans les navigateurs conformes. Le transport lui-même reste impérativement `https://` et utilise `serverCertificateHashes` avec le certificat X.509v3 court ECDSA P-256 déjà produit par le backend.
Cette tranche reste volontairement séparée de `alpha.8` : le smoke WebTransport navigateur ne doit contenir aucun fallback silencieux. La sélection/fallback WebSocket est prouvée ensuite au niveau composition.
Correctif `0.3.5-alpha.7.fix.1` : la première gate utilisateur a validé fmt, audits, check/Clippy natifs, tests, smoke natif, check/Clippy/build WASM et la compilation de l'adapter navigateur, puis a échoué uniquement dans `npm run build` lorsque `wasm-bindgen` cherchait l'artefact Cargo sous `../../builds/...`. Depuis `Web/game-realtime-webtransport-browser-smoke`, le target-dir workspace `../builds/sasedev-games/target` se trouve en réalité sous `../../../builds/...`. Le fix corrige les scripts `wasm:dev`/`wasm:build` et l'audit de distribution associé, sans changement Rust/runtime.
Correctif `0.3.5-alpha.7.fix.2` : la gate du premier fix valide fmt, audits, check/Clippy natifs et le build WASM de l'adapter, puis `npm run build` atteint TypeScript et échoue uniquement parce que l'alias `@webtransport-browser-smoke-wasm` cherche encore les déclarations wasm-bindgen sous `../../builds/...`. Le chemin `paths` du `tsconfig.json` est aligné sur `../../../builds/...`; l'audit de distribution ancre désormais aussi cette résolution. Les erreurs `implicit any` observées sur les callbacks sont une conséquence de ce module non résolu et disparaissent lorsque la déclaration générée est chargée. Aucun code Rust/runtime ni protocole n'est modifié.
La gate utilisateur de `alpha.7.fix.2` valide ensuite fmt, audits, check/Clippy workspace, build WASM de l'adapter, `npm run build`, génération `wasm-bindgen`, TypeScript/Vite et le smoke navigateur réel. Une première tentative interactive a atteint la deadline browser de 5 s sans défaut reproductible établi ; une tentative suivante contre le même peer local a été acceptée et les deux côtés ont terminé par `game-realtime-webtransport-browser-smoke: PASS`. La preuve est conservée dans `history/0.3.5/alpha.7.fix.2.md`.
### `0.3.5-alpha.8` — fallback WebSocket au niveau composition
Tranche candidate matérialisée avec :
- nouveau launcher technique `game-realtime-transport-fallback-smoke`, sans modification du contrat commun ;
- sélecteur privé WebTransport-first, non réutilisé comme framework produit ;
- branche réelle WebTransport disponible avec round-trip et fermeture propres ;
- branche fallback forcée par un `Timeout` WebTransport classifié, puis vraie connexion WebSocket loopback avec round-trip et fermeture propres ;
- branche non-fallback utilisant une vraie configuration WebTransport invalide (`connect_timeout = 0`) et vérifiant que l'erreur `InvalidConfiguration` reste visible ;
- politique volontairement étroite : seuls `Timeout` et `Io` autorisent le fallback ;
- `Connect` explicitement exclu tant que le backend WebTransport ne distingue pas suffisamment les échecs réseau des échecs TLS/pinning ;
- tests unitaires du sélecteur et de la matrice des `TransportErrorKind` ;
- aucun couplage engine/gameplay, aucune registry et aucun `TransportManager`.
Le smoke navigateur `alpha.7` reste inchangé et continue d'échouer explicitement si WebTransport échoue ; le fallback n'existe que dans le launcher de composition `alpha.8`.
Correctif `0.3.5-alpha.8.fix.1` : la gate utilisateur de `alpha.8` valide fmt, audits, `cargo check --workspace`, les suites transport/WebSocket/WebTransport, les trois smokes runtime et les graphes de dépendances. Le smoke fallback termine bien par `PASS`, mais Clippy strict échoue uniquement sur un import de trait devenu inutilisé et sur les closures `|| async { ... }` qui doivent respecter la règle workspace `clippy::implicit-return`. Le correctif retire l'import inutile et écrit explicitement `|| return async { ... }` dans le launcher et ses tests, sans modifier la politique de fallback ni aucun backend.
### `0.3.5-alpha.9` — datagram POC isolé
Le backend fiable natif et le navigateur étant désormais validés, la tranche est conservée avec un scope strict :
- `WebTransportSession` expose `max_datagram_size()`, `send_datagram(...)` et `receive_datagram()` comme capacité backend-spécifique ;
- le natif utilise directement les datagrams de `web-transport-quinn` ;
- le chemin WASM expose la même capacité via `web-transport-wasm` et reste couvert par une gate de compilation ;
- un smoke natif séparé effectue un échange datagram bidirectionnel sur loopback sous deadlines locales ;
- un test d'intégration couvre le round-trip et le rejet local d'un payload supérieur à la taille courante de session ;
- aucune garantie de livraison ou d'ordre n'est introduite ni testée comme invariant ;
- aucune modification n'est apportée à `game-realtime-transport-lib` ou `RealtimeConnection`.
Le smoke navigateur interactif n'est pas rouvert dans cette tranche : sa preuve fiable `alpha.7.fix.2` reste intacte. La valeur retenue est **future capability backend-spécifique utile**, à mesurer en `alpha.10`, sans promotion vers le contrat commun.
Correctif `0.3.5-alpha.9.fix.1` : la gate utilisateur de `alpha.9` confirme fmt/audits, `cargo check --workspace`, les tests WebTransport dont `2/2` tests datagram, le smoke datagram natif terminé par `PASS`, ainsi que check/Clippy/build WASM. Clippy workspace échoue uniquement parce que le nouveau crate dintégration `tests/datagrams.rs` ne possède pas de rustdoc de niveau crate sous `missing_docs = "warn"` promu en erreur par `-D warnings`. Le correctif ajoute cette documentation sans modifier API, comportement, test ou dépendance.
### `0.3.5-alpha.10` — mesures comparatives bornées
La tranche ajoute `game-realtime-transport-measure`, un outil localhost déterministe qui ne modifie aucun backend et ne publie aucune API commune nouvelle. La méthode est figée ainsi :
- 8 établissements complets par backend ; WebTransport inclut la session QUIC/HTTP3 et l'ouverture du stream primaire, WebSocket inclut le handshake ;
- RTT fiable : 16 warmups puis 128 ping/pong de 32 octets ;
- throughput fiable : 128 messages de 64 KiB, soit 8 MiB utiles, avec ACK final après drainage côté serveur ;
- fenêtre applicative sans ACK intermédiaire : 64 messages de 1 KiB ;
- datagram WebTransport séparé : 64 envois de taille `min(256, max_datagram_size client, max_datagram_size serveur)`, nombre reçu observé sous deadline locale ;
- sortie textuelle stable `MEASURE ...` suivie de `game-realtime-transport-measure: PASS`.
Les métriques fiables utilisent strictement `RealtimeConnection`; le chemin datagram reste sur `WebTransportSession`. Les chiffres sont une caractérisation loopback de la machine qui exécute la gate, pas un benchmark Internet/mobile ni une preuve de supériorité d'un protocole. Aucun seuil arbitraire de victoire n'est introduit.
La conclusion technique provisoire est **`retain`** : conserver WebTransport comme second backend à côté de WebSocket pour la suite du projet, compte tenu du chemin natif+navigateur validé, du fallback classifié et de la capacité datagram optionnelle. Cette conclusion ne signifie pas « WebTransport est plus rapide » ; les mesures de la gate utilisateur seront enregistrées dans l'historique après exécution.
La méthode, le format des résultats et leurs limites sont détaillés dans `docs/studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md`.
Correctif `0.3.5-alpha.10.fix.1` : la gate utilisateur de `alpha.10` confirme les audits, `cargo check --workspace`, les suites ciblées WebSocket/WebTransport et lexécution release complète de `game-realtime-transport-measure`, terminée par `PASS` avec toutes les lignes `MEASURE`. La gate complète échoue uniquement sur Clippy workspace pour un import `RealtimeConnection` devenu inutilisé dans le launcher de mesure. Le correctif retire cet import sans modifier le protocole de mesure, les métriques produites, les backends ou la conclusion technique provisoire.
### `0.3.5-beta.1` — validation large
Jalon rare de full workspace, désormais actif après validation de `alpha.10.fix.1`. Cette tranche ne porte aucun comportement nouveau.
Gate principale :
```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 --workspace --all-targets --all-features
```
Preuves realtime et multiplateformes retenues :
- smoke WebSocket natif ;
- smoke WebTransport fiable natif ;
- smoke datagram WebTransport natif ;
- preuve WebTransport-first puis fallback WebSocket classifié ;
- caractérisation release bornée ;
- check/Clippy/build `wasm32-unknown-unknown` de `game-realtime-webtransport-lib` ;
- build Vite/TypeScript/WASM du host browser et smoke navigateur réel ;
- check Android `aarch64-linux-android` du backend WebTransport si la cible/NDK de l'environnement sont disponibles, sans APK/AAB ni modification Gradle/JNI ;
- `cargo tree` direct/inverse des deux backends et vérification qu'aucun engine/gameplay ne dépend d'un backend concret.
Toute capacité fonctionnelle majeure manquante réouvre une alpha ; un défaut fermé de validation/build produit `beta.1.fix.N`.
### `0.3.5-beta.1.fix.1` — cohérence de version du host navigateur
Correctif validé :
- `Web/game-realtime-webtransport-browser-smoke/package.json` synchronisé avec la version workspace ;
- `DIST-LAYOUT-114` ajouté pour empêcher une nouvelle dérive de cette version ;
- aucun backend, protocole, test, mesure ou comportement runtime modifié ;
- build frontend/WASM revalidé avec la version corrigée ;
- smoke navigateur réel revalidé avec verdict `PASS` ;
- cross-compilation Android ARM64/API 21 finalement validée après exposition explicite du Clang NDK `aarch64-linux-android21-clang` à `ring`/`cc-rs`.
La preuve détaillée est conservée dans `history/0.3.5/beta.1.fix.1.md`.
### `0.3.5-beta.2` — consolidation pré-RC
Tranche validée : audits Rust/workspace, Markdown et distribution propres, `cargo check --workspace`, Clippy strict et build frontend/WASM propres, sans modification fonctionnelle.
La tranche a été explicitement réservée par `SESSION-009` / `VER-PHASE-010` pour :
- enregistrer `history/0.3.5/beta.1.fix.1.md` avec les validations réellement obtenues ;
- réconcilier les README/USAGE realtime et les documents d'architecture devenus historiques ;
- figer la conclusion **retained** : WebTransport reste un second backend, WebSocket la baseline/fallback ;
- maintenir les datagrams hors de `RealtimeConnection` ;
- documenter Linux natif et navigateur/WASM comme preuves runtime validées, Android ARM64/API 21 comme cross-compilation validée seulement, et Apple comme non validé ;
- documenter la procédure Android NDK sans chemin machine absolu ;
- conserver `CHANGELOG.md` inchangé avant la RC conformément à `PROMPT-STR-012` et aux règles documentaires ;
- conserver `ROADMAP.md` inchangé tant que `0.3.5` n'est pas stable et que le statut macro ne change pas ;
- écrire `prompts/007-V0_3_6_START_PROMPT.md` pour la consolidation des POC `0.3.x` et la préparation de la baseline `0.4.x` ;
- aucune nouvelle fonctionnalité ni modification des backends.
La RC suivante vérifie cette documentation consolidée et porte l'entrée changelog candidate.
### `0.3.5-rc.1` — candidate gelée
Tranche validée :
- aucun comportement volontaire nouveau ;
- enregistrer `history/0.3.5/beta.2.md` après sa validation ;
- créer l'entrée candidate `0.3.5-rc.1` dans `CHANGELOG.md` conformément à `DOC-CHG-003/004` ;
- synchroniser la version workspace et le host navigateur sur `0.3.5-rc.1` ;
- full workspace conformément à `CMD-RC-002` ;
- gates realtime de publication et smokes retenus ;
- build WASM/frontend, smoke navigateur réel et cross-compilation Android ARM64/API 21 ;
- graphe de dépendances direct/inverse des backends ;
- vérification que `prompts/007-V0_3_6_START_PROMPT.md` ne présente pas `v0.3.5` comme déjà publié ;
- aucune modification de `ROADMAP.md` tant que la stable n'est pas publiée et qu'aucun statut macro ne change ;
- corrections uniquement selon `VER-RC-*`.
### `0.3.5` — stable
Promotion mécanique réalisée : version `0.3.5`, historique RC, changelog stable, roadmap clôturée, plan et index clôturés, delta `rel.001` et prompt `0.3.6` ajusté pour partir de la stable publiée/taggée après validation du delta final. Aucun comportement n'est modifié après `rc.1`.
## 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.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/studies/000-README.md --> <!-- file: docs/studies/000-README.md -->
<!-- version: 11 --> <!-- version: 14 -->
# Études # Études
@@ -65,3 +65,8 @@ Ces documents étudient les POC à réaliser ; ils ne lancent encore aucune impl
## Étude de cadrage 0.3.3 ## É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. - [`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`.
- [`027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md`](027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md) — méthode bornée de mesure loopback WebSocket/WebTransport, datagrams séparés, limites dinterprétation et conclusion consolidée `retained` de `0.3.5`.

View 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`.

View File

@@ -0,0 +1,193 @@
<!-- file: docs/studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md -->
<!-- version: 2 -->
# Étude 0.3.5 — caractérisation bornée WebSocket / WebTransport
## Objet
Cette étude définit la méthode de `0.3.5-alpha.10`. Elle ne cherche pas à produire un benchmark général de WebSocket contre WebTransport, mais à caractériser les deux implémentations **dans games.sasedev**, sur loopback, avec le même contrat fiable et des charges bornées.
Les valeurs dépendent de la machine, du kernel, du scheduler, du profil Cargo et de l'état local du système. Elles ne doivent pas être extrapolées à un réseau mobile, à Internet, à un navigateur distant ou à une charge serveur multi-utilisateur.
## Outil
Le launcher technique est :
```text
crates/apps/game-realtime-transport-measure
```
Exécution de référence :
```bash
cargo run --release -p game-realtime-transport-measure
```
Le profil `--release` est recommandé pour réduire le poids du code de mesure lui-même. La gate peut aussi exécuter le profil debug pour vérifier le fonctionnement, mais les chiffres debug ne doivent pas être comparés à des chiffres release.
## Préparation commune
Les deux chemins fiables utilisent leurs configurations produit par défaut et le contrat `RealtimeConnection`.
WebSocket :
```text
TCP loopback
ws://127.0.0.1:<port>/measure
Tokio + tokio-tungstenite
```
WebTransport :
```text
UDP loopback
https://127.0.0.1:<port>/measure
QUIC + HTTP/3
certificat P-256 loopback en mémoire
pin SHA-256 exact
stream bidirectionnel primaire
```
L'outil ne configure ni loss artificiel, ni jitter, ni bandwidth shaping.
## Établissement
Pour chaque backend :
```text
samples = 8
```
WebSocket mesure du début du connect/accept jusqu'à la connexion établie après handshake.
WebTransport mesure jusqu'au chemin fiable utilisable : session WebTransport établie **et** stream bidirectionnel primaire ouvert/accepté.
Le rapport fournit :
```text
min_us
median_us
p95_us
max_us
```
Les premières connexions peuvent inclure des coûts froids ; aucun échantillon d'établissement n'est supprimé.
## RTT fiable
Charge :
```text
warmup = 16
samples = 128
payload = 32 octets
```
Le client envoie un message et attend l'écho exact avant d'envoyer le suivant. Seuls les 128 échantillons post-warmup entrent dans le résumé.
Le résultat mesure donc le RTT applicatif du contrat `RealtimeConnection`, pas le RTT brut TCP/QUIC.
## Throughput fiable
Charge :
```text
messages = 128
payload = 64 KiB
volume utile = 8 MiB
```
Le client envoie les messages sans ACK applicatif intermédiaire. Le serveur draine exactement les 128 messages puis renvoie un ACK final. Le chronomètre client s'arrête à réception de cet ACK.
Le résultat fournit :
```text
elapsed_us
mib_per_s
messages_per_s
```
Il inclut le framing du backend, les copies/allocation du POC et la backpressure réellement appliquée par les wrappers.
## Fenêtre de messages en vol
Charge :
```text
messages = 64
payload = 1 KiB
```
Le client émet 64 messages sans ACK applicatif intermédiaire, puis attend un ACK unique après drainage côté serveur.
Cette mesure ne prétend pas connaître le nombre exact de paquets réseau simultanément en vol. Elle valide une **fenêtre applicative bornée de 64 messages non acquittés individuellement** et rapporte son temps de complétion.
## Datagram WebTransport
Le datagram est mesuré séparément du contrat fiable :
```text
attempted = 64
payload = min(256, client_max_datagram_size, server_max_datagram_size)
```
Le client émet les 64 datagrams puis le serveur les reçoit jusqu'à atteindre 64 ou jusqu'à une attente locale de 250 ms sans nouveau datagram.
Le rapport fournit :
```text
attempted
received
receive_ratio
payload_bytes
client_max
server_max
elapsed_us
```
`receive_ratio = 1.0` sur loopback ne transforme pas les datagrams en transport fiable. Une perte observée n'est pas automatiquement un défaut de protocole : elle doit être interprétée avec la sémantique non fiable de WebTransport.
## Format de sortie
Chaque mesure utilise une ligne stable :
```text
MEASURE transport=<...> metric=<...> ...
```
La fin normale est :
```text
CONCLUSION webtransport=retain scope=second-backend reason=reliable-browser-fallback-datagram-capabilities
game-realtime-transport-measure: PASS
```
## Lecture des résultats
Aucun seuil automatique « gagnant/perdant » n'est défini. Une différence loopback de quelques microsecondes ou quelques pourcents n'a pas de valeur produit suffisante à elle seule.
Les dimensions utiles sont :
- absence d'échec ou de timeout inattendu ;
- ordre de grandeur de l'établissement ;
- stabilité médiane/p95 du RTT ;
- absence d'effondrement évident du throughput fiable ;
- capacité à soutenir la fenêtre applicative bornée ;
- observation distincte de la capacité datagram.
## Conclusion technique retenue
Décision consolidée pour `0.3.5` : **retained**.
Cela signifie : conserver WebTransport comme second backend realtime expérimental/évolutif à côté de WebSocket. Cette décision repose sur l'ensemble des preuves `0.3.5` déjà acquises :
- backend natif fiable ;
- client navigateur/WASM ;
- smoke navigateur réel ;
- robustesse et deadlines ;
- fallback WebSocket strictement classifié ;
- datagrams optionnels backend-spécifiques.
Cette conclusion **ne signifie pas** que WebTransport est déclaré plus rapide que WebSocket. Les runs `alpha.10.fix.1` et `beta.1` montrent au contraire des avantages différents selon le pattern mesuré : établissement plus coûteux pour WebTransport, RTT local plus faible dans ces runs, throughput 64 KiB supérieur pour WebSocket et fenêtre 1 KiB nettement supérieure pour WebTransport. Ces écarts servent à caractériser les wrappers actuels, pas à produire un classement général.
La validation large a ensuite confirmé le workspace complet, les smokes natifs, le chemin navigateur/WASM réel et la cross-compilation Android ARM64/API 21. Les valeurs exactes des runs validés restent dans `history/0.3.5/alpha.10.fix.1.md` et `history/0.3.5/beta.1.fix.1.md`.

39
history/0.3.5/alpha.1.md Normal file
View 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`.

View File

@@ -0,0 +1,65 @@
<!-- file: history/0.3.5/alpha.10.fix.1.md -->
<!-- version: 1 -->
# Historique 0.3.5-alpha.10.fix.1
## Statut
`0.3.5-alpha.10.fix.1` a été validée par l'utilisateur le 2026-09-22. La caractérisation locale WebSocket/WebTransport est fermée avec une gate complète verte et la conclusion technique provisoire reste de conserver WebTransport comme second backend.
## Gates
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
cargo test -p game-realtime-transport-lib --all-targets --all-features
cargo test -p game-realtime-websocket-lib --all-targets --all-features
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo run --release -p game-realtime-transport-measure
```
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), 310 file(s))
Distribution layout audit: clean (66 required path(s), 8 forbidden path(s) absent)
cargo check --workspace: clean
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
game-realtime-transport-lib: 7 passed; 0 failed
game-realtime-websocket-lib: 11 passed; 0 failed
game-realtime-webtransport-lib: 22 passed; 0 failed
game-realtime-transport-measure: PASS
```
## Mesures loopback validées
Exécution release observée :
```text
MEASURE transport=websocket metric=establishment samples=8 payload_bytes=0 min_us=25 median_us=29 p95_us=155 max_us=155
MEASURE transport=webtransport metric=establishment samples=8 payload_bytes=0 min_us=696 median_us=754 p95_us=1175 max_us=1175
MEASURE transport=websocket metric=rtt samples=128 payload_bytes=32 min_us=18 median_us=18 p95_us=18 max_us=24
MEASURE transport=webtransport metric=rtt samples=128 payload_bytes=32 min_us=11 median_us=12 p95_us=12 max_us=16
MEASURE transport=websocket metric=throughput messages=128 payload_bytes=65536 total_bytes=8388608 elapsed_us=2728 mib_per_s=2931.704 messages_per_s=46907.272
MEASURE transport=webtransport metric=throughput messages=128 payload_bytes=65536 total_bytes=8388608 elapsed_us=12331 mib_per_s=648.746 messages_per_s=10379.940
MEASURE transport=websocket metric=in_flight_window messages=64 payload_bytes=1024 total_bytes=65536 elapsed_us=44356 mib_per_s=1.409 messages_per_s=1442.849
MEASURE transport=webtransport metric=in_flight_window messages=64 payload_bytes=1024 total_bytes=65536 elapsed_us=338 mib_per_s=184.496 messages_per_s=188923.755
MEASURE transport=webtransport-datagram metric=bounded_burst attempted=64 received=64 receive_ratio=1.000000 payload_bytes=256 client_max=1381 server_max=1381 elapsed_us=80
CONCLUSION webtransport=retain scope=second-backend reason=reliable-browser-fallback-datagram-capabilities
```
Ces chiffres caractérisent uniquement le loopback de cette exécution. Ils ne constituent ni un classement global des protocoles ni une prédiction des performances Internet/mobile. Les profils favorisent des chemins différents selon la métrique, ce qui renforce la décision de conserver les deux backends plutôt que d'inférer un vainqueur unique.
## Conséquence
Toutes les tranches alpha prévues de `0.3.5` sont fermées. `0.3.5-beta.1` peut exécuter la validation large : full workspace, smokes retenus, navigateur/WASM, fallback, graphe de dépendances et contrôle Android ciblé si l'environnement le permet.

View 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
View 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.

View File

@@ -0,0 +1,75 @@
<!-- file: history/0.3.5/alpha.4.fix.1.md -->
<!-- version: 1 -->
# Historique 0.3.5-alpha.4.fix.1
## Statut
`0.3.5-alpha.4.fix.1` a été validée par l'utilisateur le 2026-09-21. La tranche robustesse/lifecycle WebTransport est donc fermée : configuration, deadlines, framing négatif, cancellation, reset/abort/drop et mapping d'erreurs passent avec Clippy strict.
La suite peut ouvrir `0.3.5-alpha.5` pour le smoke natif hors harness sans réouvrir le scope de la bibliothèque.
## 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), 292 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.4.fix.1`, 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
```
## Tests WebTransport
La commande :
```bash
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
```
est entièrement propre :
```text
unit tests: 11 passed; 0 failed
establishment integration tests: 2 passed; 0 failed
realtime_connection integration tests: 1 passed; 0 failed
robustness integration tests: 6 passed; 0 failed
```
Les vingt tests WebTransport couvrent donc l'établissement et le pinning, le framing fiable, le round-trip primaire, les limites configurables, les frames tronquées, la cancellation de receive, les reset/abort/drop et la deadline d'accept du stream primaire.
## Conséquence
`alpha.5` peut se limiter à une preuve runtime publique hors harness : launcher technique dédié, bind éphémère, identité TLS locale en mémoire, pin SHA-256 exact, round-trip binaire bidirectionnel, FIN propre dans les deux directions et verdict `PASS` déterministe.

88
history/0.3.5/alpha.5.md Normal file
View File

@@ -0,0 +1,88 @@
<!-- file: history/0.3.5/alpha.5.md -->
<!-- version: 1 -->
# Historique 0.3.5-alpha.5
## Statut
`0.3.5-alpha.5` a été validée par l'utilisateur le 2026-09-21. Le backend WebTransport natif est donc prouvé hors harness avec identité TLS locale, pin SHA-256 exact, session HTTP/3/WebTransport réelle, stream fiable principal, round-trip bidirectionnel et fermeture propre.
La suite peut ouvrir `0.3.5-alpha.6` pour le chemin client WASM compilable sans réouvrir la preuve native.
## 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), 294 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
```
## Tests realtime
Le contrat commun reste entièrement propre :
```text
game-realtime-transport-lib: 7 passed; 0 failed
```
La suite WebTransport reste entièrement propre :
```text
unit tests: 11 passed; 0 failed
establishment integration tests: 2 passed; 0 failed
realtime_connection integration tests: 1 passed; 0 failed
robustness integration tests: 6 passed; 0 failed
```
Soit vingt tests WebTransport verts.
## Smoke runtime natif
La commande :
```bash
cargo run -p game-realtime-webtransport-smoke
```
établit le listener loopback sur un port éphémère, connecte le client avec le hash certificat exact, effectue le round-trip prévu et se termine par :
```text
game-realtime-webtransport-smoke: PASS
```
Le tracing observé confirme aussi l'accept serveur et la connexion client WebTransport réels.
## Graphe de dépendances
Le graphe direct du smoke contient uniquement les briques techniques attendues, dont `game-realtime-webtransport-lib`, `game-realtime-transport-lib`, le logging et Tokio.
Le graphe inverse de `game-realtime-webtransport-lib` contient désormais exactement le consommateur technique attendu :
```text
game-realtime-webtransport-lib
└── game-realtime-webtransport-smoke
```
Aucune crate engine ou gameplay n'est remontée dans ce graphe.
## Conséquence
`alpha.6` peut introduire la dépendance navigateur de façon strictement target-specific, adapter le client WASM au même contrat fiable et prouver sa compilation `wasm32-unknown-unknown`. Le runtime navigateur, le host frontend et la preuve TLS W3C restent réservés à `alpha.7`.

93
history/0.3.5/alpha.6.md Normal file
View File

@@ -0,0 +1,93 @@
<!-- file: history/0.3.5/alpha.6.md -->
<!-- version: 1 -->
# Historique 0.3.5-alpha.6
## Statut
`0.3.5-alpha.6` a été validée par l'utilisateur le 2026-09-22. Le chemin client WebTransport Rust compile donc réellement en `wasm32-unknown-unknown`, tandis que le backend natif et son smoke hors harness restent propres.
La suite peut ouvrir `0.3.5-alpha.7` pour la preuve runtime dans un vrai navigateur et la fermeture des deadlines navigateur sans réouvrir l'architecture native.
## Gates statiques et natives
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), 296 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
```
## Tests realtime et smoke natif
Le contrat commun reste entièrement propre :
```text
game-realtime-transport-lib: 7 passed; 0 failed
```
La suite WebTransport reste entièrement propre :
```text
unit tests: 11 passed; 0 failed
establishment integration tests: 2 passed; 0 failed
realtime_connection integration tests: 1 passed; 0 failed
robustness integration tests: 6 passed; 0 failed
```
Soit vingt tests WebTransport verts.
Le smoke natif :
```bash
cargo run -p game-realtime-webtransport-smoke
```
se termine toujours par :
```text
game-realtime-webtransport-smoke: PASS
```
## Gate WASM
Les trois commandes ciblées ont terminé proprement :
```text
cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings
cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
```
`web-transport-wasm 0.6.0` a été résolu et construit avec les bindings `web-sys`/`wasm-bindgen` attendus.
Le graphe direct WASM de `game-realtime-webtransport-lib` contient le contrat commun, tracing, URL et `web-transport-wasm`; il ne contient ni `web-transport-quinn`, ni rcgen, ni Tokio. Le graphe inverse confirme :
```text
web-transport-wasm v0.6.0
└── game-realtime-webtransport-lib v0.3.5-alpha.6
└── game-realtime-webtransport-smoke v0.3.5-alpha.6
```
Le smoke natif apparaît comme consommateur workspace du backend mais sa propre compilation native ne transforme pas le chemin WASM en backend Quinn.
## Conséquence
`alpha.7` peut maintenant ajouter la preuve navigateur réelle : host Vite technique isolé, adapter Rust/WASM, serveur Rust local avec certificat court P-256 et hash SHA-256 explicite, round-trip binaire, fermeture propre et deadlines navigateur matérialisées. Le fallback WebSocket reste réservé à `alpha.8`.

View File

@@ -0,0 +1,93 @@
<!-- file: history/0.3.5/alpha.7.fix.2.md -->
<!-- version: 1 -->
# Historique 0.3.5-alpha.7.fix.2
## Statut
`0.3.5-alpha.7.fix.2` a été validée par l'utilisateur le 2026-09-22. La tranche navigateur WebTransport est fermée : compilation native/WASM, build Vite/TypeScript, génération `wasm-bindgen`, chargement WASM, connexion WebTransport browser -> Rust avec pin SHA-256 exact, round-trip et fermeture propre ont abouti.
Une première tentative interactive du navigateur a atteint la deadline de connexion de 5 s. Aucun changement de code n'a été appliqué pour ce symptôme isolé ; une tentative suivante contre le même peer local a été acceptée et le smoke s'est terminé par `PASS`. Aucun défaut reproductible de transport n'a donc été établi par cette première tentative.
## 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
cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown
```
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), 300 file(s))
Distribution layout audit: clean (58 required path(s), 8 forbidden path(s) absent)
cargo check --workspace: clean
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
browser smoke WASM library build: clean
```
## Build frontend
La commande :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm install && npm run build)
```
termine proprement. `wasm-bindgen` lit désormais le bon artefact externe, TypeScript résout les déclarations générées et Vite produit le bundle sous le build root externe :
```text
vite v8.3.0 building client environment for production...
5 modules transformed
main.html
*.wasm
main-*.js
built in 41ms
```
`npm install` signale `0 vulnerabilities` dans cette gate.
## Smoke navigateur réel
Le peer natif a été lancé par :
```bash
cargo run -p game-realtime-webtransport-browser-smoke
```
Il a bind un endpoint éphémère `https://127.0.0.1:<port>/browser-smoke`, publié son hash SHA-256 exact et l'URL `OPEN=...` du host Vite.
Le host a été lancé par :
```bash
(cd Web/game-realtime-webtransport-browser-smoke && npm run dev)
```
La page a confirmé le secure context, la présence de WebTransport et le chargement du WASM. Une tentative suivante a été acceptée par le serveur :
```text
WebTransport peer accepted peer=127.0.0.1:<ephemeral-port>
game-realtime-webtransport-browser-smoke: PASS
browser WebTransport smoke passed
```
Le smoke navigateur WebTransport réel est donc validé sans fallback WebSocket.
## Graphe cible
Les graphes fournis confirment que `game-realtime-webtransport-browser-smoke` consomme `game-realtime-webtransport-lib` et que `gloo-timers` reste sur le chemin WASM. La séparation native/WASM établie en `alpha.6` reste intacte.
## Conséquence
`alpha.8` peut maintenant exercer séparément la composition WebTransport-first et le fallback WebSocket. Le smoke navigateur validé reste une preuve WebTransport pure et ne doit pas recevoir de fallback silencieux.

View File

@@ -0,0 +1,55 @@
<!-- file: history/0.3.5/alpha.8.fix.1.md -->
<!-- version: 1 -->
# Historique 0.3.5-alpha.8.fix.1
## Statut
`0.3.5-alpha.8.fix.1` a été validée par l'utilisateur le 2026-09-22. La tranche de composition WebTransport-first / fallback WebSocket est fermée sans modification du contrat transport-neutral.
## Gates
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
cargo test -p game-realtime-transport-fallback-smoke --all-targets --all-features
cargo run -p game-realtime-transport-fallback-smoke
```
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), 303 file(s))
Distribution layout audit: clean (61 required path(s), 8 forbidden path(s) absent)
cargo check --workspace: clean
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
fallback selector tests: 2 passed; 0 failed
```
## Smoke de composition
Le launcher réel confirme successivement :
```text
preferred transport branch passed selected="webtransport"
WebTransport unavailable; attempting classified WebSocket fallback
classified fallback branch passed selected="websocket"
non-fallback error remained visible kind=invalid configuration
game-realtime-transport-fallback-smoke: PASS
```
Le fallback reste donc limité à `Timeout`/`Io`. Les erreurs ambiguës `Connect` et les erreurs de configuration/protocole ne sont pas transformées en downgrade silencieux.
## Conséquence
`alpha.9` peut challenger séparément la capacité datagram WebTransport. Cette capacité reste backend-spécifique et ne doit pas modifier `RealtimeConnection`, qui représente le chemin fiable ordonné commun à WebSocket et WebTransport.

View File

@@ -0,0 +1,78 @@
<!-- file: history/0.3.5/alpha.9.fix.1.md -->
<!-- version: 1 -->
# Historique 0.3.5-alpha.9.fix.1
## Statut
`0.3.5-alpha.9.fix.1` a été validée par l'utilisateur le 2026-09-22. Le POC datagram WebTransport backend-spécifique est fermé sans promotion de cette sémantique dans le contrat `RealtimeConnection`.
## Gates
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
cargo test -p game-realtime-webtransport-lib --all-targets --all-features
cargo run -p game-realtime-webtransport-datagram-smoke
cargo check -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
cargo clippy -p game-realtime-webtransport-lib --target wasm32-unknown-unknown --lib -- -D warnings
cargo build -p game-realtime-webtransport-lib --target wasm32-unknown-unknown
```
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), 306 file(s))
Distribution layout audit: clean (64 required path(s), 8 forbidden path(s) absent)
cargo check --workspace: clean
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
WebTransport tests: 22 passed; 0 failed
wasm32 check/clippy/build: clean
```
## Datagram natif
Le smoke réel termine par :
```text
game-realtime-webtransport-datagram-smoke: PASS
```
Cette exécution a négocié des bornes de payload différentes selon les deux sessions :
```text
client_max=1350
server_max=1287
```
Le smoke utilise la borne commune minimale pour ses payloads. Cette variation confirme qu'une taille datagram ne doit pas être codée en dur et doit rester une capacité négociée/backend-spécifique.
## Tests datagram
Les tests d'intégration valident :
```text
backend_specific_datagrams_round_trip_without_entering_the_common_contract ... ok
oversized_datagram_is_rejected_before_backend_send ... ok
```
Aucune garantie de livraison ou d'ordre n'est déduite de ce succès loopback.
## Conséquence
`alpha.10` peut mesurer séparément :
- le chemin fiable commun WebSocket/WebTransport via `RealtimeConnection` ;
- le datagram WebTransport comme capacité distincte.
Les mesures doivent rester bornées, reproductibles et descriptives ; elles ne doivent pas introduire un classement arbitraire ni extrapoler le loopback à Internet/mobile.

View File

@@ -0,0 +1,101 @@
<!-- file: history/0.3.5/beta.1.fix.1.md -->
<!-- version: 1 -->
# Historique 0.3.5-beta.1.fix.1
## Statut
`0.3.5-beta.1.fix.1` a été validée par l'utilisateur le 2026-09-22. Le correctif ferme uniquement la dérive de version du host navigateur découverte pendant la gate large `beta.1`; il ne modifie aucun backend, protocole, fallback, datagram ou scénario de mesure.
La validation cumulée `beta.1` + `beta.1.fix.1` confirme le workspace complet, les smokes realtime natifs, la caractérisation release, le chemin navigateur/WASM réel et la cross-compilation Android ARM64/API 21 du backend WebTransport.
## Gate large héritée de beta.1
La gate `beta.1` a confirmé :
```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), 312 file(s))
Distribution layout audit: clean (66 required path(s), 8 forbidden path(s) absent)
cargo check --workspace: clean
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
cargo test --workspace --all-targets --all-features: 77 passed; 0 failed
```
Les smokes runtime natifs ont tous terminé par `PASS` :
```text
game-realtime-websocket-smoke: PASS
game-realtime-webtransport-smoke: PASS
game-realtime-webtransport-datagram-smoke: PASS
game-realtime-transport-fallback-smoke: PASS
```
Le fallback valide WebTransport en priorité, le passage vers WebSocket sur un `Timeout` classifié et la visibilité d'une erreur `InvalidConfiguration` non éligible au fallback.
## Mesures beta.1
La caractérisation release `game-realtime-transport-measure` a terminé par `PASS` avec :
```text
WebSocket establishment median : 42 us
WebTransport establishment median : 963 us
WebSocket RTT median : 19 us
WebTransport RTT median : 12 us
WebSocket throughput 128 x 64 KiB : 2933.255 MiB/s
WebTransport throughput 128 x 64 KiB : 646.338 MiB/s
WebSocket window 64 x 1 KiB : 1.471 MiB/s
WebTransport window 64 x 1 KiB : 153.084 MiB/s
WebTransport datagram : 64/64 reçus, payload 256 octets, client_max=1381, server_max=1381
```
Ces valeurs décrivent uniquement ce run loopback. Elles ne constituent pas un classement général WebSocket/WebTransport et ne sont pas extrapolées à Internet ou au mobile.
La conclusion technique reste :
```text
CONCLUSION webtransport=retain scope=second-backend reason=reliable-browser-fallback-datagram-capabilities
```
## WASM et host navigateur
`beta.1` puis `beta.1.fix.1` ont confirmé :
- check, Clippy `--lib` et build de `game-realtime-webtransport-lib` pour `wasm32-unknown-unknown` ;
- build de `game-realtime-webtransport-browser-smoke` pour WASM ;
- génération `wasm-bindgen` ;
- TypeScript/Vite propre ;
- host npm synchronisé sur `0.3.5-beta.1.fix.1` ;
- audit `DIST-LAYOUT-114` empêchant une nouvelle divergence de version ;
- smoke navigateur réel terminé par `game-realtime-webtransport-browser-smoke: PASS` côté pair natif.
Le smoke final a utilisé un endpoint loopback éphémère et un pin SHA-256 généré en mémoire. Les valeurs concrètes de port et de certificat appartiennent uniquement à cette exécution.
## Android ARM64
La première tentative `aarch64-linux-android` de `beta.1` a été bloquée avant compilation complète parce que `ring`/`cc-rs` ne trouvait pas le Clang du NDK dans l'environnement.
Le NDK `28.2.13676358` était déjà installé. Après exposition explicite du toolchain LLVM avec cible API 21 :
```text
CC_aarch64_linux_android=aarch64-linux-android21-clang
CXX_aarch64_linux_android=aarch64-linux-android21-clang++
AR_aarch64_linux_android=llvm-ar
CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=aarch64-linux-android21-clang
```
la commande :
```bash
cargo check -p game-realtime-webtransport-lib --target aarch64-linux-android
```
s'est terminée proprement, y compris `ring`, `rustls-platform-verifier-android`, Quinn et `game-realtime-webtransport-lib`.
Cette preuve valide la **cross-compilation ARM64 Android API 21** du backend. Elle ne vaut pas smoke WebTransport runtime sur appareil Android et ne valide pas les autres ABI Android pour ce backend.
## Conclusion beta
La fonctionnalité attendue est complète et la validation large ne révèle plus de défaut fonctionnel. La consolidation `0.3.5-beta.2` peut geler la décision `retained`, réconcilier la documentation durable et préparer le prompt `0.3.6` sans rouvrir le scope transport.

52
history/0.3.5/beta.2.md Normal file
View File

@@ -0,0 +1,52 @@
<!-- file: history/0.3.5/beta.2.md -->
<!-- version: 1 -->
# Historique 0.3.5-beta.2
## Statut
`0.3.5-beta.2` a été validée par l'utilisateur le 2026-09-22. Cette tranche est une consolidation pré-RC exclusivement documentaire/build-metadata : aucun fichier Rust, backend, dépendance, protocole, fallback, datagram, engine ou gameplay n'a été modifié.
## Gate validée
La validation exécutée a confirmé :
```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), 316 file(s))
Distribution layout audit: clean (66 required path(s), 8 forbidden path(s) absent)
cargo check --workspace: clean
cargo clippy --workspace --all-targets --all-features -- -D warnings: clean
```
Le host navigateur synchronisé sur `0.3.5-beta.2` a également été reconstruit proprement :
```text
npm install: 0 vulnerabilities
wasm:build: clean
TypeScript: clean
Vite: 5 modules transformed
production build: clean
```
Le full workspace, les smokes natifs, le smoke navigateur réel, les mesures et la cross-compilation Android ARM64/API 21 n'ont pas été répétés dans cette tranche documentaire ; ils restent couverts par `history/0.3.5/beta.1.fix.1.md` et aucun comportement n'a changé depuis.
## Consolidation acceptée
La documentation durable est désormais cohérente avec la décision finale candidate :
```text
WebSocket -> baseline/fallback de référence
WebTransport -> second backend realtime retenu
Datagrams -> capacité WebTransport backend-spécifique, hors RealtimeConnection
```
Les preuves de plateforme restent explicitement bornées : Linux natif et navigateur/WASM ont une preuve runtime, Android ARM64/API 21 a une preuve de cross-compilation du backend, Android runtime WebTransport et Apple ne sont pas présentés comme validés.
`prompts/007-V0_3_6_START_PROMPT.md` est prêt pour la future stable `v0.3.5` sans présenter ce tag comme déjà publié.
## Conclusion
La consolidation pré-RC est acceptée sans défaut nécessitant un correctif beta. `0.3.5-rc.1` peut geler la candidate, créer l'entrée changelog RC et exécuter la gate complète de publication.

95
history/0.3.5/rc.1.md Normal file
View File

@@ -0,0 +1,95 @@
<!-- file: history/0.3.5/rc.1.md -->
<!-- version: 1 -->
# Historique 0.3.5-rc.1
## Statut
`0.3.5-rc.1` a été validée par l'utilisateur le 2026-09-22. La candidate gelée passe les audits, la compilation workspace, Clippy strict, le workspace de tests complet, les smokes realtime natifs, la caractérisation release, les gates WASM/frontend, la cross-compilation Android ARM64/API 21, le smoke navigateur réel et les contrôles de dépendances sans défaut de publication.
Aucun `rc.1.fix.N` n'est requis. La candidate peut être promue mécaniquement vers la stable `0.3.5`.
## Gates statiques et workspace
La sortie utilisateur 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), 318 file(s))
Distribution layout audit: clean (66 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 bien `0.3.5-rc.1`, y compris les crates realtime, les smokes et l'outil de mesure.
## Tests workspace
La commande :
```bash
cargo test --workspace --all-targets --all-features
```
valide **77 tests, 0 échec** sur les moteurs, plateformes, assets/logging, jeux, WebSocket, WebTransport, datagrams, fallback et adapters concernés.
## Smokes realtime natifs
Les quatre launchers terminent par :
```text
game-realtime-websocket-smoke: PASS
game-realtime-webtransport-smoke: PASS
game-realtime-webtransport-datagram-smoke: PASS
game-realtime-transport-fallback-smoke: PASS
```
Le fallback confirme WebTransport préféré, WebSocket uniquement sur erreur classifiée `Timeout`/`Io`, et visibilité conservée des erreurs non éligibles au fallback.
## Mesures de la RC
La caractérisation release bornée termine par `game-realtime-transport-measure: PASS` et observe sur cette exécution loopback :
```text
establishment median: websocket=65 us, webtransport=720 us
rtt median: websocket=17 us, webtransport=11 us
throughput 128 x 64 KiB: websocket=3036.346 MiB/s, webtransport=634.827 MiB/s
in-flight 64 x 1 KiB: websocket=1.533 MiB/s, webtransport=348.883 MiB/s
datagram bounded burst: attempted=64, received=64, payload=256 bytes, client_max=1381, server_max=1381
```
Ces valeurs restent une observation locale dépendante du pattern ; elles ne constituent ni un classement général des protocoles ni une prédiction Internet/mobile. La conclusion reste :
```text
CONCLUSION webtransport=retain scope=second-backend reason=reliable-browser-fallback-datagram-capabilities
```
## WASM, frontend et navigateur réel
Les gates `wasm32-unknown-unknown` de `game-realtime-webtransport-lib` passent en check, Clippy strict et build. `game-realtime-webtransport-browser-smoke` compile également pour WASM.
Le host Vite/TypeScript synchronisé sur `0.3.5-rc.1` passe `npm install` sans vulnérabilité puis son build production. Le smoke navigateur réel contre le serveur Rust natif termine par :
```text
game-realtime-webtransport-browser-smoke: PASS
```
## Android ARM64/API 21
Avec le NDK `28.2.13676358` déjà retenu et le Clang ciblé `aarch64-linux-android21-clang` exposé à Cargo/`cc-rs`, la commande :
```bash
cargo check -p game-realtime-webtransport-lib --target aarch64-linux-android
```
termine proprement. Cette preuve valide la **cross-compilation** du backend WebTransport pour Android ARM64/API 21 ; elle ne constitue pas un smoke runtime Android WebTransport.
## Frontières de dépendances
Les graphes directs/inverses confirment que les backends realtime restent consommés uniquement par les launchers/outils techniques prévus. Aucun moteur ni gameplay ne dépend directement de `game-realtime-websocket-lib` ou `game-realtime-webtransport-lib`.
## Conclusion RC
La candidate est gelée, cohérente et validée sans correctif RC. La stable `0.3.5` peut se limiter aux changements mécaniques de version, changelog, roadmap, clôture du plan/index, historique RC, delta final et ajustement du prompt `0.3.6`.

View File

@@ -0,0 +1,403 @@
<!-- file: prompts/007-V0_3_6_START_PROMPT.md -->
<!-- version: 2 -->
# Prompt de démarrage `0.3.6` — consolidation des POC plateforme/réseau et baseline 0.4.x
## 1. Base exacte et cible
Partir uniquement de la version stable/taggée :
```text
v0.3.5
```
Ce prompt accompagne la stable `0.3.5`. Il doit être utilisé uniquement après validation du delta stable, commit de release et publication effective du tag `v0.3.5`. L'archive ZIP téléchargée depuis ce tag devient alors la baseline autoritaire de la session `0.3.6`.
Une archive annoncée comme téléchargement ZIP du tag Gitea est autoritaire conformément à `CMD-GIT-003` et `CMD-GIT-004`; l'absence de `.git` n'est pas un défaut.
Version cible :
```text
0.3.6
```
Première tranche attendue :
```text
0.3.6-alpha.1
```
`alpha.1` est obligatoirement le gate de cadrage : audit de la stable `0.3.5`, inventaire des résultats réellement validés de `0.3.0` à `0.3.5`, identification des duplications/abstractions encore injustifiées, sizing, risques, validations et création du plan actif `0.3.6` avant toute consolidation lourde.
## 2. Sources de vérité
Lire intégralement, dans cet ordre :
```text
RULES.md
ROADMAP.md
CHANGELOG.md
docs/000-README.md
```
Puis au minimum :
```text
docs/rules/RULES_GENERAL.md
docs/rules/RULES_RUST.md
docs/rules/RULES_PROJECT.md
docs/rules/RULES_DOCUMENTATION.md
docs/rules/FILE_CONTRACTS.md
docs/rules/VERSION_WORKFLOW.md
docs/rules/RULES_COMMANDS.md
docs/rules/RULES_VALIDATION_MATRIX.md
docs/rules/RULES_SESSION_PLANNING.md
docs/rules/PROMPT_STRUCTURE.md
```
Architecture et résultats POC :
```text
docs/architecture/012-MODULAR_LAYERING_AND_OWNERSHIP.md
docs/architecture/013-NETWORK_AND_SERVER_ARCHITECTURE.md
docs/architecture/014-UROBURAS_TARGET_ARCHITECTURE.md
docs/architecture/015-PLATFORM_POC_ARCHITECTURE.md
docs/architecture/016-V0_2_0_CONSOLIDATED_BASELINE.md
docs/studies/012-PLATFORM_POC_SEQUENCE.md
docs/studies/019-UROBURAS_FUNCTIONAL_OWNERSHIP_CLASSIFICATION.md
docs/studies/020-UROBURAS_CRATE_DECOMPOSITION_STUDY.md
docs/studies/022-UROBURAS_IMPLEMENTATION_DEPENDENCY_ORDER.md
docs/studies/023-V0_3_0_PLATFORM_POC_AUDIT.md
docs/studies/024-V0_3_1_TAURI_ANDROID_SNAKE_AUDIT.md
docs/studies/025-V0_3_3_ANDROID_NATIVE_MULTI_ABI_AUDIT.md
docs/studies/026-V0_3_5_WEBTRANSPORT_QUIC_STACK_AUDIT.md
docs/studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md
```
Plans et preuves :
```text
docs/plans/001-V0_3_0_WEB_SNAKE_POC_PLAN.md
docs/plans/002-V0_3_1_TAURI_ANDROID_SNAKE_PLAN.md
docs/plans/003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md
history/0.3.0/
history/0.3.1/
history/0.3.3/
history/0.3.4/
history/0.3.5/
```
`0.3.6-alpha.1` crée `docs/plans/006-V0_3_6_POC_CONSOLIDATION_PLAN.md`; ce plan devient ensuite l'autorité prévisionnelle active de la version.
## 3. État hérité attendu de 0.3.5
La stable `0.3.5` doit fournir la fin de la vague POC plateforme/réseau sans avoir commencé Uroburas.
Décisions plateforme attendues :
- Desktop natif SDL3 comme runner de référence ;
- Web direct sous Vite/TypeScript + Rust/WASM validé avec Snake ;
- Tauri Android conservé comme POC de référence/comparaison, pas comme voie Android productive par défaut ;
- Android productif orienté SDL3 natif + Java/JNI ;
- pipeline Android natif multi-ABI piloté par Cargo/Gradle, sans script Python de build ;
- `minSdk 21` conservé et compatibilité 16 KB 64 bits déjà prouvée dans `0.3.3` ;
- Tauri Desktop `0.3.2` toujours différé tant qu'aucun bénéfice produit/monétisation explicite ne justifie cette distribution.
Décisions réseau attendues :
```text
game-realtime-transport-lib
reliable + ordered binary contract
├── game-realtime-websocket-lib
│ baseline/fallback
└── game-realtime-webtransport-lib
retained second backend
```
- WebSocket/Tokio/tokio-tungstenite reste la baseline et le fallback de référence ;
- WebTransport/QUIC est retenu comme second backend fiable ;
- le client navigateur/WASM WebTransport a été prouvé contre le serveur Rust natif ;
- le backend WebTransport cross-compile pour Android ARM64/API 21 ;
- cette preuve Android ne vaut pas smoke runtime WebTransport sur appareil ;
- les datagrams WebTransport restent backend-spécifiques, non fiables et non ordonnés ;
- le fallback WebTransport -> WebSocket appartient à la composition, pas au gameplay ni au backend WebTransport ;
- les mesures loopback caractérisent les wrappers mais ne constituent pas un classement général ni une prédiction Internet/mobile.
Toujours hors de la baseline : codec wire métier final, protocole session joueur/room, reconnect/resync, snapshots/deltas gameplay, prediction/reconciliation, matchmaking et simulation authoritative.
## 4. Mission 0.3.6
Réconcilier les POC `0.3.x` en une baseline technique claire et minimale prête à accueillir Uroburas `0.4.x`, sans transformer cette version en développement du jeu lui-même.
La version doit répondre à quatre questions :
1. quelles décisions des POC sont désormais des choix durables du projet ?
2. quelles crates/apps restent utiles comme composants, références ou outils de validation ?
3. quelles abstractions/duplications peuvent réellement être consolidées parce qu'au moins deux consommateurs conservés le justifient ?
4. quelle baseline précise doit être transmise à `0.4.x` pour commencer Uroburas Mode 1 sans réouvrir les POC déjà tranchés ?
La consolidation ne signifie pas « factoriser tout ce qui se ressemble ». Une duplication observée peut rester volontaire lorsque les consumers ont des lifecycles ou plateformes différents.
## 5. Invariants à préserver
### Architecture modulaire
Conserver la séparation :
```text
engine kernel
technical capabilities
game-systems
game-specific
platform adapters
providers
server services
tooling
```
Aucune crate Uroburas monolithique n'est introduite dans `0.3.6`.
### Réseau
Conserver :
```text
transport
wire codec
session protocol
synchronization
authoritative simulation
```
`0.3.6` peut documenter ou consolider les frontières déjà prouvées, mais ne doit pas profiter de la consolidation pour implémenter les couches supérieures.
### Plateformes
Ne pas rouvrir automatiquement Tauri Desktop, Tauri iOS, Windows/macOS/iOS natifs ou un nouveau host uniquement parce qu'ils figurent dans des études anciennes. Une nouvelle cible demande encore un environnement réel et une question technique ouverte.
## 6. Scope inclus
Le scope candidat comprend :
- audit exhaustif des résultats `0.3.0` à `0.3.5` ;
- matrice « retained/reference/deferred/remove » des POC et composants techniques ;
- réconciliation de l'architecture durable avec les décisions réellement validées ;
- revue des duplications Web/WASM, Android, runners et realtime ;
- extraction/factorisation uniquement lorsqu'au moins deux consumers conservés et un besoin partagé réel la justifient ;
- suppression d'une abstraction ou d'un artefact POC uniquement lorsque son absence de valeur durable est démontrée et que les preuves/historiques restent conservés ;
- clarification des capacités nécessaires avant Uroburas Mode 1 ;
- préparation d'une baseline `0.4.x` sans implémenter le gameplay Uroburas ;
- documentation des décisions différées et de leurs conditions de réouverture ;
- préparation du prompt de la première version `0.4.x` retenue lorsque la cible est suffisamment connue.
## 7. Hors scope
Sauf défaut bloquant découvert pendant la consolidation, ne pas introduire :
- gameplay Uroburas ;
- Mode 1, Mode 2 ou Mode 3 jouable ;
- simulation authoritative ;
- protocole session joueur/room ;
- matchmaking ;
- auth complète ;
- persistence gameplay ;
- snapshot/delta définitif ;
- prediction/reconciliation/rollback ;
- Redis/NATS/Kafka ;
- orchestration Kubernetes ;
- nouvelle stack Web/API ;
- nouveau backend realtime ;
- remplacement de WebSocket ou WebTransport ;
- nouveau framework générique de plugins/transports ;
- nouvelle plateforme non nécessaire à la consolidation.
## 8. Questions de consolidation obligatoires
`alpha.1` doit produire une matrice couvrant au minimum :
- `engine-v1-*` : quelles responsabilités sont réellement stables ?
- Snake/Reflex : quels POC restent utiles comme tests de régression ou références ?
- Web direct : quelles parties sont réellement réutilisables ?
- Tauri Android : référence à conserver telle quelle ou surface à isoler davantage ?
- Android SDL3/Java/JNI : quelle baseline productive exacte transmettre à `0.4.x` ?
- assets/logging : duplication restante justifiant une extraction ?
- realtime : contrat commun, deux backends, fallback et datagrams sont-ils placés au bon niveau ?
- tooling/smokes : lesquels doivent rester comme preuves durables ?
- documentation : quelles études POC sont historiques et quelles décisions doivent être promues vers architecture/règles ?
- `0.3.2` différée : les conditions de réouverture ont-elles changé ?
Chaque proposition de refactor doit nommer les consumers réels qu'elle sert.
## 9. Baseline 0.4.x attendue
La fin de `0.3.6` doit rendre explicite, sans encore l'implémenter :
- la première version `0.4.x` à ouvrir ;
- le mode Uroburas concerné, actuellement Mode 1 Challenge sauf révision justifiée ;
- les capabilities techniques déjà disponibles ;
- les capabilities manquantes réellement nécessaires à cette première tranche produit ;
- l'ownership attendu de ces capabilities ;
- les POC conservés comme témoins de non-régression ;
- les plateformes réellement visées par la première version produit ;
- les points volontairement reportés à Mode 3/Mode 2 ou à une version ultérieure.
Ne pas réserver de nouvelles possibilités spéculatives « au cas où ».
## 10. Documentation README/USAGE
Relire `DOC-CRATE-*` pour chaque composant conservé ou consolidé.
La consolidation doit corriger les documentations devenues fausses ou historiques, mais ne crée pas de README/USAGE vides par cérémonie.
Pour chaque crate/app durable :
- README si responsabilité/frontière/points d'entrée ont une valeur durable ;
- USAGE si préconditions/commandes/API sont non triviales ;
- simple référence au document central si une documentation locale supplémentaire serait redondante.
## 11. Validation et responsabilité
Les audits statiques peuvent être exécutés par le générateur. Les builds, tests et smokes finaux restent côté utilisateur conformément à `CMD-BUILD-005` et ne sont jamais déclarés réussis sans sortie réelle.
Dès qu'un fichier Rust ou 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
```
Les tests restent ciblés par défaut. `cargo test --workspace --all-targets --all-features` est réservé aux jalons larges prévus dans le plan ou à un changement transverse qui le justifie.
Toute commande nécessitant un `cd` reste dans un sous-shell `(cd ... && ...)`.
Ne pas utiliser `npm run build` comme gate manuelle d'une app Tauri : les hooks Tauri possèdent leurs builds frontend. Les hosts Web directs non-Tauri conservent leur workflow Vite/npm propre lorsque la tranche les touche réellement.
## 12. Deltas, history, changelog et roadmap
Conserver :
```text
0.3.6-alpha.N
0.3.6-alpha.N.fix.M
0.3.6-beta.N
0.3.6-beta.N.fix.M
0.3.6-rc.N
0.3.6-rc.N.fix.M
0.3.6
```
Chaque delta indique sa base, son scope, ses fichiers et ses validations attendues.
`history/0.3.6/<jalon>.md` est créé uniquement par la tranche suivante ou son fix après validation réelle du jalon précédent.
`CHANGELOG.md` reste normalement silencieux avant la RC. `ROADMAP.md` reste macroscopique ; le plan `0.3.6` porte le découpage fin.
Un défaut fermé produit `.fix.N`. Une validation propre permet de poursuivre vers la tranche planifiée suivante. La session doit fermer au minimum `0.3.6` jusqu'à sa stable.
## 13. Cadrage alpha.1 obligatoire
Avant tout refactor ou suppression :
1. auditer la stable `v0.3.5` et ses historiques ;
2. inventorier les composants introduits/conservés dans `0.3.0` à `0.3.5` ;
3. produire une matrice retained/reference/deferred/remove avec justification ;
4. inventorier les duplications observées et les consumers concernés ;
5. vérifier les frontières des dépendances actuelles avec `cargo tree` seulement là où cela éclaire une décision ;
6. identifier les documents d'architecture à promouvoir/réconcilier ;
7. établir la baseline `0.4.x` attendue ;
8. définir les gates proportionnelles à chaque consolidation ;
9. créer `docs/plans/006-V0_3_6_POC_CONSOLIDATION_PLAN.md` ;
10. produire un forecast souple jusqu'à la stable et redécouper avant tout delta trop lourd.
Aucune suppression ou factorisation transverse n'est autorisée avant cette matrice.
## 14. Forecast initial non contraignant
Le plan `alpha.1` peut fusionner, scinder ou supprimer ces tranches selon l'audit réel.
### `0.3.6-alpha.1` — audit global et plan de consolidation
- audit stable `0.3.5` ;
- matrice des POC/composants ;
- duplications et abstractions à challenger ;
- baseline `0.4.x` candidate ;
- plan actif et validations.
### `0.3.6-alpha.2` — consolidation plateforme/documentation
Si justifié par `alpha.1` :
- réconcilier Web direct, Tauri de référence, SDL Desktop et Android productif ;
- promouvoir les décisions plateforme vers architecture durable ;
- retirer uniquement les généralisations réellement inutiles ;
- conserver les POC nécessaires comme témoins.
### `0.3.6-alpha.3` — consolidation réseau/capabilities
Si nécessaire :
- réconcilier les décisions WebSocket/WebTransport dans l'architecture durable ;
- vérifier les frontières backend/contrat/composition ;
- supprimer ou déplacer uniquement une abstraction dont le mauvais ownership est démontré ;
- ne pas implémenter wire/session/synchronization.
Cette tranche peut disparaître si `0.3.5` laisse déjà la documentation et l'ownership suffisamment consolidés.
### `0.3.6-alpha.4` — baseline 0.4.x
Si un delta séparé reste justifié :
- synthèse technique prête pour Uroburas ;
- capabilities disponibles/manquantes ;
- ownership initial ;
- plateformes produit candidates réellement justifiées ;
- aucune fonctionnalité Uroburas jouable.
### `0.3.6-beta.1` — validation large
- workspace complet si du code/build a été consolidé ;
- smokes représentatifs des chemins conservés ;
- graphes de dépendances pour les frontières modifiées ;
- vérification que les POC conservés restent fonctionnels.
### `0.3.6-beta.2` — consolidation finale pré-RC
- documentation durable ;
- `CHANGELOG.md` seulement selon les règles de transition vers RC ;
- `ROADMAP.md` si le statut macro change ;
- historique beta ;
- prompt de la première version `0.4.x` si la cible est figée.
### `0.3.6-rc.1` — candidate gelée
- aucun nouveau scope ;
- gate RC proportionnelle à la consolidation réellement effectuée ;
- vérification du prompt `0.4.x` ;
- corrections uniquement selon `VER-RC-*`.
### `0.3.6` — stable
Promotion mécanique autant que possible : version stable, historique RC, changelog stable, clôture du plan, roadmap, delta final et ajustement mécanique du prompt `0.4.x`.
## 15. Critère de réussite de 0.3.6
La version est réussie si `0.4.x` peut commencer sans ambiguïté sur :
- les plateformes réellement retenues ;
- les POC conservés comme références ;
- les transports realtime et leur ownership ;
- les abstractions réellement justifiées ;
- les capabilities déjà disponibles ;
- les capabilities manquantes à développer avec le premier jeu réel ;
- les décisions différées et leurs conditions de réouverture.
Le succès n'exige pas une factorisation maximale. Une baseline plus petite, explicite et prouvée est préférable à un framework général construit à partir d'hypothèses.

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# file: scripts/audit_distribution_layout.py # file: scripts/audit_distribution_layout.py
# version: 18 # version: 25
"""Audit the static distribution layout expected by supported POCs.""" """Audit the static distribution layout expected by supported POCs."""
@@ -24,7 +24,18 @@ FORBIDDEN_PATHS = (
REQUIRED_PATHS = ( REQUIRED_PATHS = (
"crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml",
"crates/apps/game-realtime-transport-fallback-smoke/src/main.rs",
"crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs",
"crates/apps/game-realtime-transport-measure/Cargo.toml",
"crates/apps/game-realtime-transport-measure/src/main.rs",
"crates/apps/game-realtime-websocket-smoke/Cargo.toml", "crates/apps/game-realtime-websocket-smoke/Cargo.toml",
"crates/apps/game-realtime-webtransport-browser-smoke/Cargo.toml",
"crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml",
"crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs",
"crates/common/game-realtime-webtransport-lib/tests/datagrams.rs",
"crates/apps/game-realtime-webtransport-browser-smoke/src/lib.rs",
"crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs",
"crates/apps/game-reflex-poc-desktop/Cargo.toml", "crates/apps/game-reflex-poc-desktop/Cargo.toml",
"crates/apps/game-snake-poc-desktop/Cargo.toml", "crates/apps/game-snake-poc-desktop/Cargo.toml",
"crates/apps/game-reflex-poc-tauri/Cargo.toml", "crates/apps/game-reflex-poc-tauri/Cargo.toml",
@@ -55,6 +66,11 @@ REQUIRED_PATHS = (
"crates/apps/game-snake-poc-tauri/frontend/ts/lifecycle.ts", "crates/apps/game-snake-poc-tauri/frontend/ts/lifecycle.ts",
"crates/apps/game-snake-poc-tauri/frontend/ts/main.ts", "crates/apps/game-snake-poc-tauri/frontend/ts/main.ts",
"crates/apps/game-snake-poc-tauri/frontend/ts/provenance.ts", "crates/apps/game-snake-poc-tauri/frontend/ts/provenance.ts",
"Web/game-realtime-webtransport-browser-smoke/package.json",
"Web/game-realtime-webtransport-browser-smoke/tsconfig.json",
"Web/game-realtime-webtransport-browser-smoke/vite.config.ts",
"Web/game-realtime-webtransport-browser-smoke/frontend/main.html",
"Web/game-realtime-webtransport-browser-smoke/frontend/ts/main.ts",
"Web/game-snake-poc/package.json", "Web/game-snake-poc/package.json",
"Web/game-snake-poc/tsconfig.json", "Web/game-snake-poc/tsconfig.json",
"Web/game-snake-poc/vite.config.ts", "Web/game-snake-poc/vite.config.ts",
@@ -312,6 +328,158 @@ def audit_snake_tauri_generated_android(root: pathlib.Path) -> list[str]:
def audit_webtransport_browser_smoke(root: pathlib.Path) -> list[str]:
"""Validate the technical real-browser WebTransport smoke boundary."""
violations: list[str] = []
package_path = root / "Web/game-realtime-webtransport-browser-smoke/package.json"
tsconfig_path = root / "Web/game-realtime-webtransport-browser-smoke/tsconfig.json"
vite_path = root / "Web/game-realtime-webtransport-browser-smoke/vite.config.ts"
main_path = root / "Web/game-realtime-webtransport-browser-smoke/frontend/ts/main.ts"
rust_manifest_path = root / "crates/apps/game-realtime-webtransport-browser-smoke/Cargo.toml"
rust_main_path = root / "crates/apps/game-realtime-webtransport-browser-smoke/src/main.rs"
if not all(path.exists() for path in (package_path, tsconfig_path, vite_path, main_path, rust_manifest_path, rust_main_path)):
return violations
package = package_path.read_text(encoding="utf-8")
workspace_manifest = (root / "Cargo.toml").read_text(encoding="utf-8")
workspace_section = workspace_manifest.split("[workspace.package]", maxsplit=1)[1].split("[", maxsplit=1)[0]
workspace_version_line = next((line.strip() for line in workspace_section.splitlines() if line.strip().startswith("version = ")), "")
workspace_version = workspace_version_line.removeprefix("version = ").strip().strip('"')
tsconfig = tsconfig_path.read_text(encoding="utf-8")
vite = vite_path.read_text(encoding="utf-8")
main = main_path.read_text(encoding="utf-8")
rust_manifest = rust_manifest_path.read_text(encoding="utf-8")
rust_main = rust_main_path.read_text(encoding="utf-8")
required_fragments = (
(package, 'cargo build -p game-realtime-webtransport-browser-smoke --lib --target wasm32-unknown-unknown', "DIST-LAYOUT-077: browser WebTransport smoke must build its Rust WASM adapter"),
(package, 'wasm-bindgen ../../../builds/sasedev-games/', "DIST-LAYOUT-078: browser WebTransport smoke bindings must be generated outside the repository"),
(tsconfig, '../../../builds/sasedev-games/game-realtime-webtransport-browser-smoke/wasm/game_realtime_webtransport_browser_smoke.d.ts', "DIST-LAYOUT-086: browser WebTransport smoke TypeScript declarations must resolve from the external build root"),
(vite, 'host: "127.0.0.1"', "DIST-LAYOUT-079: browser WebTransport smoke Vite host must stay on loopback"),
(vite, 'port: 1435,', "DIST-LAYOUT-080: browser WebTransport smoke Vite port must stay deterministic"),
(main, 'window.isSecureContext', "DIST-LAYOUT-081: browser WebTransport smoke must verify secure-context availability"),
(main, '"WebTransport" in window', "DIST-LAYOUT-082: browser WebTransport smoke must verify WebTransport availability"),
(main, 'run_browser_smoke(', "DIST-LAYOUT-083: browser WebTransport smoke must execute the Rust WASM path"),
(rust_manifest, 'game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }', "DIST-LAYOUT-084: browser WebTransport smoke adapter must consume the shared backend"),
(rust_main, 'game-realtime-webtransport-browser-smoke: PASS', "DIST-LAYOUT-085: browser WebTransport smoke native peer must expose a deterministic PASS verdict"),
)
for content, fragment, message in required_fragments:
if fragment not in content:
violations.append(message)
expected_package_version = f'"version": "{workspace_version}"'
if expected_package_version not in package:
violations.append(
f"DIST-LAYOUT-114: browser WebTransport smoke frontend version must match workspace version {workspace_version}"
)
return violations
def audit_realtime_fallback_smoke(root: pathlib.Path) -> list[str]:
"""Validate the technical WebTransport-first/WebSocket fallback composition boundary."""
violations: list[str] = []
manifest_path = root / "crates/apps/game-realtime-transport-fallback-smoke/Cargo.toml"
main_path = root / "crates/apps/game-realtime-transport-fallback-smoke/src/main.rs"
unit_path = root / "crates/apps/game-realtime-transport-fallback-smoke/unit_tests/fallback.rs"
if not manifest_path.exists() or not main_path.exists() or not unit_path.exists():
return violations
manifest = manifest_path.read_text(encoding="utf-8")
main = main_path.read_text(encoding="utf-8")
unit = unit_path.read_text(encoding="utf-8")
required_fragments = (
(manifest, 'game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }', "DIST-LAYOUT-087: fallback smoke must consume the WebSocket backend directly at composition level"),
(manifest, 'game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }', "DIST-LAYOUT-088: fallback smoke must consume the WebTransport backend directly at composition level"),
(main, 'kind == game_realtime_transport_lib::TransportErrorKind::Timeout || kind == game_realtime_transport_lib::TransportErrorKind::Io', "DIST-LAYOUT-089: fallback smoke classification must remain limited to Timeout/Io"),
(unit, 'assert!(!super::is_fallback_eligible(game_realtime_transport_lib::TransportErrorKind::Connect));', "DIST-LAYOUT-090: ambiguous WebTransport Connect failures must not trigger fallback"),
(main, 'game-realtime-transport-fallback-smoke: PASS', "DIST-LAYOUT-091: fallback smoke must expose a deterministic PASS verdict"),
)
for content, fragment, message in required_fragments:
if fragment not in content:
violations.append(message)
forbidden_fragments = (
"engine-v1-",
"game-reflex-poc",
"game-snake-poc",
)
for fragment in forbidden_fragments:
if fragment in manifest:
violations.append(f"DIST-LAYOUT-092: fallback smoke must not depend on engine/gameplay package: {fragment}")
return violations
def audit_realtime_transport_measure(root: pathlib.Path) -> list[str]:
"""Validate the bounded realtime transport characterization boundary."""
violations: list[str] = []
manifest_path = root / "crates/apps/game-realtime-transport-measure/Cargo.toml"
main_path = root / "crates/apps/game-realtime-transport-measure/src/main.rs"
study_path = root / "docs/studies/027-V0_3_5_REALTIME_TRANSPORT_MEASUREMENT.md"
required = (manifest_path, main_path, study_path)
if not all(path.exists() for path in required):
return violations
manifest = manifest_path.read_text(encoding="utf-8")
main = main_path.read_text(encoding="utf-8")
study = study_path.read_text(encoding="utf-8")
required_fragments = (
(manifest, 'game-realtime-transport-lib = { path = "../../common/game-realtime-transport-lib" }', "DIST-LAYOUT-102: measurement tool must use the transport-neutral contract"),
(manifest, 'game-realtime-websocket-lib = { path = "../../common/game-realtime-websocket-lib" }', "DIST-LAYOUT-103: measurement tool must consume the WebSocket backend directly"),
(manifest, 'game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }', "DIST-LAYOUT-104: measurement tool must consume the WebTransport backend directly"),
(main, 'const ESTABLISHMENT_SAMPLES: usize = 8;', "DIST-LAYOUT-105: measurement tool must keep establishment samples bounded"),
(main, 'const RTT_SAMPLES: usize = 128;', "DIST-LAYOUT-106: measurement tool must keep RTT samples bounded"),
(main, 'const THROUGHPUT_MESSAGES: usize = 128;', "DIST-LAYOUT-107: measurement tool must keep throughput messages bounded"),
(main, 'const IN_FLIGHT_MESSAGES: usize = 64;', "DIST-LAYOUT-108: measurement tool must keep the application in-flight window bounded"),
(main, 'MEASURE transport=webtransport-datagram metric=bounded_burst', "DIST-LAYOUT-109: measurement tool must report datagrams separately from the reliable contract"),
(main, 'CONCLUSION webtransport=retain scope=second-backend', "DIST-LAYOUT-110: measurement tool must expose the provisional retain conclusion without declaring a speed winner"),
(main, 'game-realtime-transport-measure: PASS', "DIST-LAYOUT-111: measurement tool must expose a deterministic PASS verdict"),
(study, 'Cette conclusion **ne signifie pas** que WebTransport est déclaré plus rapide que WebSocket.', "DIST-LAYOUT-112: measurement study must forbid interpreting retain as a performance winner"),
)
for content, fragment, message in required_fragments:
if fragment not in content:
violations.append(message)
for fragment in ("engine-v1-", "game-reflex-poc", "game-snake-poc"):
if fragment in manifest:
violations.append(f"DIST-LAYOUT-113: measurement tool must not depend on engine/gameplay package: {fragment}")
return violations
def audit_webtransport_datagram_smoke(root: pathlib.Path) -> list[str]:
"""Validate the isolated backend-specific WebTransport datagram proof."""
violations: list[str] = []
manifest_path = root / "crates/apps/game-realtime-webtransport-datagram-smoke/Cargo.toml"
main_path = root / "crates/apps/game-realtime-webtransport-datagram-smoke/src/main.rs"
native_path = root / "crates/common/game-realtime-webtransport-lib/src/webtransport.rs"
wasm_path = root / "crates/common/game-realtime-webtransport-lib/src/webtransport_wasm.rs"
common_path = root / "crates/common/game-realtime-transport-lib/src/lib.rs"
required = (manifest_path, main_path, native_path, wasm_path, common_path)
if not all(path.exists() for path in required):
return violations
manifest = manifest_path.read_text(encoding="utf-8")
main = main_path.read_text(encoding="utf-8")
native = native_path.read_text(encoding="utf-8")
wasm = wasm_path.read_text(encoding="utf-8")
common = common_path.read_text(encoding="utf-8")
required_fragments = (
(manifest, 'game-realtime-webtransport-lib = { path = "../../common/game-realtime-webtransport-lib" }', "DIST-LAYOUT-093: datagram smoke must consume the WebTransport backend directly"),
(main, 'max_datagram_size()', "DIST-LAYOUT-094: datagram smoke must inspect the negotiated backend payload bound"),
(main, 'send_datagram(', "DIST-LAYOUT-095: datagram smoke must send backend-specific datagrams"),
(main, 'receive_datagram()', "DIST-LAYOUT-096: datagram smoke must receive backend-specific datagrams"),
(main, 'game-realtime-webtransport-datagram-smoke: PASS', "DIST-LAYOUT-097: datagram smoke must expose a deterministic PASS verdict"),
(native, 'pub fn send_datagram(&self, payload: &[u8])', "DIST-LAYOUT-098: native WebTransport session must own the backend-specific datagram send capability"),
(wasm, 'pub async fn send_datagram(&self, payload: &[u8])', "DIST-LAYOUT-099: browser WebTransport session must expose the backend-specific datagram send capability"),
)
for content, fragment, message in required_fragments:
if fragment not in content:
violations.append(message)
if "datagram" in common.lower():
violations.append("DIST-LAYOUT-100: transport-neutral realtime contract must remain free of datagram semantics")
for fragment in ("engine-v1-", "game-reflex-poc", "game-snake-poc"):
if fragment in manifest:
violations.append(f"DIST-LAYOUT-101: datagram smoke must not depend on engine/gameplay package: {fragment}")
return violations
def audit_android_native_gradle(root: pathlib.Path) -> list[str]: def audit_android_native_gradle(root: pathlib.Path) -> list[str]:
"""Validate the minimum Gradle contract and multi-ABI native Rust consumers.""" """Validate the minimum Gradle contract and multi-ABI native Rust consumers."""
@@ -402,6 +570,10 @@ def main() -> int:
contract_violations.extend(audit_snake_tauri_android_integration(root)) contract_violations.extend(audit_snake_tauri_android_integration(root))
contract_violations.extend(audit_snake_tauri_generated_android(root)) contract_violations.extend(audit_snake_tauri_generated_android(root))
contract_violations.extend(audit_android_native_gradle(root)) contract_violations.extend(audit_android_native_gradle(root))
contract_violations.extend(audit_realtime_fallback_smoke(root))
contract_violations.extend(audit_realtime_transport_measure(root))
contract_violations.extend(audit_webtransport_browser_smoke(root))
contract_violations.extend(audit_webtransport_datagram_smoke(root))
if missing: if missing:
for relative in missing: for relative in missing:
print(f"DIST-LAYOUT-001: missing required path: {relative}", file=sys.stderr) print(f"DIST-LAYOUT-001: missing required path: {relative}", file=sys.stderr)