39 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
bf9ead329a 0.3.4 2026-09-21 20:02:02 +02:00
f270ed8f86 0.3.4-rc.1 2026-09-21 19:53:50 +02:00
881d5afacd 0.3.4-beta.1 2026-09-21 19:38:00 +02:00
74cdb177e9 0.3.4-alpha.5 2026-09-21 18:22:19 +02:00
fa17ea0ca5 0.3.4-alpha.4 2026-09-21 18:00:32 +02:00
bbecae5e0d 0.3.4-alpha.3.fix.1 2026-09-21 17:45:12 +02:00
4fb521f54e 0.3.4-alpha.3 2026-09-21 17:40:56 +02:00
4e2be3cc9a 0.3.4-alpha.2 2026-09-21 17:21:25 +02:00
27978c3782 0.3.4-alpha.1 2026-09-21 17:14:34 +02:00
0ad980a06b 0.3.3 2026-09-21 16:34:14 +02:00
0584b01de3 0.3.3-3-rc.1.fix.1 2026-09-21 16:11:46 +02:00
01df07d6af 0.3.3-3-rc.1 2026-09-21 14:35:57 +02:00
58ecd667b0 0.3.3-2-beta.1 2026-09-21 14:10:30 +02:00
19f31082ea 0.3.3-0-pre.5 2026-09-21 11:17:38 +02:00
b605874bea 0.3.3-0-pre.4 2026-09-21 10:47:57 +02:00
5854ac3d07 0.3.3-0-pre.3 2026-09-21 10:32:31 +02:00
f770f99a4a 0.3.3-0-pre.1 2026-09-21 08:39:57 +02:00
158 changed files with 18299 additions and 362 deletions

View File

@@ -1,6 +1,10 @@
# file: .cargo/config.toml
# version: 1
# version: 2
[build]
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
[target.wasm32-unknown-unknown]
rustflags = ["--cfg=web_sys_unstable_apis"]
rustdocflags = ["--cfg=web_sys_unstable_apis"]

View File

@@ -1,9 +1,9 @@
<!-- file: Android/README.md -->
<!-- version: 8 -->
<!-- version: 14 -->
# Android
Le frontend Android est un projet Gradle multi-module séparé du workspace Cargo.
Le frontend Android natif est un projet Gradle multi-module séparé du workspace Cargo.
## Modules
@@ -20,23 +20,47 @@ Chaque module `game-*` est une application Android indépendante ; il dépend du
## Toolchain de référence
Pour la baseline `0.1.0-0-pre.9` :
Le pipeline Android natif `0.3.3` utilise :
- Android Gradle Plugin `9.4.0` ;
- Gradle `9.6.0` attendu par AGP 9.4 ;
- JDK `17` ;
- Gradle `>= 9.6.0`, résolu depuis l'environnement de développement ;
- JDK `>= 17` pour exécuter Gradle/AGP, sans `JAVA_HOME` imposé par le projet ;
- niveau Java source/target `17` pour la glue Android ;
- `compileSdk 36` ;
- `targetSdk 36` ;
- `minSdk 21` ;
- NDK `28.2.13676358` (`r28c`) ;
- SDL3 Android AAR `3.4.16`.
Le NDK n'est pas encore utilisé directement dans ce delta. Le prochain jalon introduira la compilation Rust Android `cdylib` et figera la configuration NDK réellement nécessaire.
`Android/settings.gradle` déclare `minimumGradleVersion = 9.6.0` et refuse explicitement une version plus ancienne. Une version système plus récente reste autorisée et devient la version réellement utilisée pour le build.
Le JDK n'est pas épinglé par le projet Android natif. Le shell/IDE fournit le JDK courant ; la gate doit afficher `java -version` et `gradle --version` afin de consigner la combinaison réellement validée. Le POC Tauri Android historique conserve ses propres contraintes de JDK et de wrapper sous sa crate ; elles ne pilotent pas le chemin SDL3 natif.
## Commandes Gradle
Depuis la racine du dépôt :
```bash
java -version
(cd Android && gradle --version)
```
Le build natif normal utilise directement le Gradle de l'environnement :
```bash
(cd Android && gradle :game-snake-poc:assembleDebug)
(cd Android && gradle :game-reflex-poc:assembleDebug)
(cd Android && gradle :game-snake-poc:bundleRelease)
(cd Android && gradle :game-reflex-poc:bundleRelease)
```
`bundleRelease` ne requiert aucun secret dans le dépôt. Sans configuration de signature externe, il produit un AAB de validation non destiné à être téléversé tel quel sur un store.
Le dépôt ne versionne pas de Gradle Wrapper pour le projet Android natif. Le minimum est un contrat de compatibilité, pas une version exacte imposée à toutes les machines.
## SDL3 AAR
L'archive n'est pas vendorizée dans le dépôt.
Placer :
L'archive n'est pas vendorizée dans le dépôt. Placer :
```text
SDL3-3.4.16.aar
@@ -48,32 +72,48 @@ dans :
Android/libs/
```
Le module `common` consomme cet artefact et `SaseGameActivity` étend désormais `org.libsdl.app.SDLActivity`.
## Limite volontaire de ce jalon
Les APK peuvent être compilés côté Java/SDL3 une fois l'AAR disponible, mais ne disposent pas encore du `cdylib` Rust contenant le point d'entrée de jeu.
Le lancement fonctionnel sur appareil/émulateur appartient donc au delta suivant.
Le module `common` compile contre cet artefact et `SaseGameActivity` étend `org.libsdl.app.SDLActivity`.
## Rust Android natif
Le crate `game-android-entrypoint` produit `libgame_android_entrypoint.so`. Il est compilé séparément pour chaque jeu avec une feature Cargo et copié dans le `jniLibs` du module Android concerné.
Le crate `game-android-entrypoint` produit `libgame_android_entrypoint.so`. `Android/gradle/sasedev-rust-android.gradle` transfère au graphe Gradle les responsabilités historiques d'extraction SDL3, de résolution NDK, de `cargo ndk`, de sélection de feature et de staging `jniLibs`.
Pour `arm64-v8a` :
À `0.3.3-0-pre.5`, Snake et Reflex utilisent tous deux le même pipeline commun avec les quatre ABI Android encore supportées par la toolchain retenue :
```bash
rustup target add aarch64-linux-android
cargo install cargo-ndk
python3 ../scripts/build_android_rust.py reflex
python3 ../scripts/build_android_rust.py snake
```text
arm64-v8a
armeabi-v7a
x86_64
x86
```
Le script extrait temporairement `libSDL3.so` de l'AAR uniquement pour fournir le chemin de linkage à `rustc`; l'AAR reste responsable du packaging SDL3 dans l'APK.
Les ABI historiques supprimées des toolchains Android modernes, notamment `armeabi`, `mips` et `mips64`, ne font pas partie du contrat. Le support maximal signifie ici toutes les ABI encore supportées simultanément par Android NDK/SDL3/Rust, et non des architectures abandonnées par l'écosystème.
Chaque variante Android déclenche, pour chaque ABI, un build `cargo ndk` avec exactement la feature du module concerné. La variante Debug utilise le profil Cargo `dev`; la variante Release ajoute `--release`. Les outputs sont placés sous :
```text
Android/<game>/build/generated/sasedevNative/<variant>/<abi>/jniLibs/<abi>/
├── libSDL3.so
└── libgame_android_entrypoint.so
```
AGP fusionne ces sources `jniLibs` générées dans l'APK Debug universal ou dans l'AAB Release. Aucune copie durable n'est faite dans `src/main/jniLibs`.
Le builder historique `scripts/build_android_rust.py` est supprimé en `0-pre.5` : toutes ses responsabilités durables (résolution NDK, extraction SDL3, linkage, sélection de feature, `cargo ndk`, profil release et staging) appartiennent désormais au graphe Gradle.
## Compatibilité 16 KB et minSdk
Le plancher Android reste `minSdk 21` / Android 5.0, qui correspond au minimum Android documenté par SDL3. Le niveau Rust transmis à `cargo ndk` reste également `21`.
La baseline utilise AGP `9.4.0` et NDK `r28c`. Pour la contrainte Google Play 16 KB sur appareils 64 bits, les bibliothèques `arm64-v8a` et `x86_64` doivent être compatibles 16 KB ; SDL3 étant fourni sous forme précompilée, `libSDL3.so` est vérifiée au même titre que `libgame_android_entrypoint.so`. La gate `0-pre.5` a confirmé des segments ELF `LOAD` alignés au moins à `2**14` pour ces deux ABI, puis un `zipalign -P 16` réussi sur les APK.
Les ABI 32 bits `armeabi-v7a` et `x86` restent supportées pour compatibilité matérielle ancienne ; leurs segments observés à `2**12` ne contredisent pas l'exigence Play 16 KB, qui s'applique aux appareils 64 bits. L'AAB doit néanmoins contenir les deux bibliothèques pour les quatre ABI sous `base/lib/<abi>/`. Si `bundletool` est disponible dans l'environnement de validation, `bundletool dump config --bundle=<aab>` peut compléter le contrôle du bundle.
La preuve runtime `0-pre.5` couvre également Android 5.0/API 21 sur x86 et Android 15/API 35 x86_64 avec `getconf PAGE_SIZE=16384` sur une image `ps16k`.
## Bridge JNI et tactile
À partir de `0.1.0-0-pre.9`, `SaseGameActivity` vérifie au démarrage la version du bridge Java/JNI chargé depuis la bibliothèque Rust.
`SaseGameActivity` vérifie au démarrage la version du bridge Java/JNI chargé depuis la bibliothèque Rust.
Les touch events standards restent traités via SDL3 et ne transitent pas par JNI.
@@ -96,6 +136,6 @@ Aucune ressource source n'est copiée durablement dans un module Android ou une
`Android/gradle.properties` définit `androidNdkVersion=28.2.13676358`.
Gradle utilise cette valeur comme `ndkVersion`. Le script Rust Android résout la même version sous `${ANDROID_HOME}/ndk/` et définit `ANDROID_NDK_HOME` uniquement pour le sous-processus `cargo ndk`.
Gradle utilise cette valeur comme `ndkVersion`. La tâche Rust Android résout la même version sous `${ANDROID_HOME}/ndk/` ou `${ANDROID_SDK_ROOT}/ndk/` et définit `ANDROID_NDK_HOME` uniquement pour le sous-processus `cargo ndk`.
Aucun `ANDROID_NDK_HOME` global n'est requis ; plusieurs NDK peuvent rester installés côte à côte.

View File

@@ -1,10 +1,14 @@
// file: Android/game-reflex-poc/build.gradle
// version: 45
// version: 47
plugins {
id 'com.android.application'
}
ext.sasedevRustGameFeature = "reflex"
ext.sasedevRustAndroidAbis = ["arm64-v8a", "armeabi-v7a", "x86_64", "x86"]
ext.sasedevRustAndroidApi = 21
def sdl3AarName = providers.gradleProperty("sdl3AarName").get()
def androidNdkVersion = providers.gradleProperty("androidNdkVersion").get()
@@ -19,6 +23,10 @@ android {
targetSdk 36
versionCode 2
versionName '0.2.0'
ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86'
}
}
compileOptions {
@@ -34,3 +42,4 @@ dependencies {
ext.sasedevGameAssetDirectory = "game-reflex-poc"
apply from: rootProject.file("gradle/sasedev-assets.gradle")
apply from: rootProject.file("gradle/sasedev-rust-android.gradle")

View File

@@ -1,10 +1,14 @@
// file: Android/game-snake-poc/build.gradle
// version: 45
// version: 48
plugins {
id 'com.android.application'
}
ext.sasedevRustGameFeature = "snake"
ext.sasedevRustAndroidAbis = ["arm64-v8a", "armeabi-v7a", "x86_64", "x86"]
ext.sasedevRustAndroidApi = 21
def sdl3AarName = providers.gradleProperty("sdl3AarName").get()
def androidNdkVersion = providers.gradleProperty("androidNdkVersion").get()
@@ -19,6 +23,10 @@ android {
targetSdk 36
versionCode 2
versionName '0.2.0'
ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86'
}
}
compileOptions {
@@ -34,3 +42,4 @@ dependencies {
ext.sasedevGameAssetDirectory = "game-snake-poc"
apply from: rootProject.file("gradle/sasedev-assets.gradle")
apply from: rootProject.file("gradle/sasedev-rust-android.gradle")

View File

@@ -0,0 +1,204 @@
// file: Android/gradle/sasedev-rust-android.gradle
// version: 2
import java.util.Collections
import java.util.zip.ZipFile
import javax.inject.Inject
import org.gradle.process.ExecOperations
abstract class BuildSasedevRustAndroidTask extends DefaultTask {
@InputFile
@PathSensitive(PathSensitivity.RELATIVE)
abstract RegularFileProperty getSdl3Aar()
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
abstract ConfigurableFileCollection getRustInputs()
@Input
abstract Property<String> getGameFeature()
@Input
abstract Property<String> getAbi()
@Input
abstract Property<Integer> getAndroidApi()
@Input
abstract Property<String> getNdkVersion()
@Input
abstract Property<Boolean> getReleaseBuild()
@Internal
abstract DirectoryProperty getRepositoryRoot()
@OutputDirectory
abstract DirectoryProperty getOutputDirectory()
@Inject
abstract ExecOperations getExecOperations()
@TaskAction
void buildNative() {
def selectedAbi = abi.get()
def selectedFeature = gameFeature.get()
def aar = sdl3Aar.get().asFile
def repository = repositoryRoot.get().asFile
def output = outputDirectory.get().asFile
def runtimeDirectory = new File(output, selectedAbi)
def linkDirectory = new File(temporaryDir, "link")
project.delete(output)
project.delete(linkDirectory)
runtimeDirectory.mkdirs()
linkDirectory.mkdirs()
byte[] sdl3Payload
new ZipFile(aar).withCloseable { archive ->
def candidates = [
"prefab/modules/SDL3/libs/android.${selectedAbi}/libSDL3.so",
"jni/${selectedAbi}/libSDL3.so",
]
def entry = candidates.collect { candidate -> archive.getEntry(candidate) }.find { candidate -> candidate != null }
if (entry == null) {
def suffix = "/libs/android.${selectedAbi}/libSDL3.so"
def fallback = Collections.list(archive.entries()).findAll { candidate ->
!candidate.directory && candidate.name.endsWith(suffix)
}
if (fallback.size() != 1) {
throw new GradleException(
"SDL3 shared library for ABI ${selectedAbi} is missing from ${aar}; " +
"expected Prefab member ${candidates[0]}"
)
}
entry = fallback[0]
}
sdl3Payload = archive.getInputStream(entry).withCloseable { stream -> stream.bytes }
}
def linkLibrary = new File(linkDirectory, "libSDL3.so")
def runtimeLibrary = new File(runtimeDirectory, "libSDL3.so")
linkLibrary.bytes = sdl3Payload
runtimeLibrary.bytes = sdl3Payload
def androidHome = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT")
if (!androidHome) {
throw new GradleException("ANDROID_HOME or ANDROID_SDK_ROOT is required to resolve the project NDK")
}
def configuredNdk = new File(androidHome, "ndk/${ndkVersion.get()}")
if (!configuredNdk.isDirectory()) {
throw new GradleException("configured Android NDK is missing: ${configuredNdk}")
}
def inheritedRustFlags = System.getenv("RUSTFLAGS")?.trim()
def linkFlag = "-Lnative=${linkDirectory}"
def rustFlags = inheritedRustFlags ? "${inheritedRustFlags} ${linkFlag}" : linkFlag
def cargoCommand = [
"cargo",
"ndk",
"-t",
selectedAbi,
"-o",
output.absolutePath,
"build",
"-p",
"game-android-entrypoint",
]
if (releaseBuild.get()) {
cargoCommand.add("--release")
}
cargoCommand.addAll([
"--no-default-features",
"--features",
selectedFeature,
])
execOperations.exec { spec ->
spec.workingDir(repository)
spec.environment("ANDROID_NDK_HOME", configuredNdk.absolutePath)
spec.environment("CARGO_NDK_PLATFORM", androidApi.get().toString())
spec.environment("RUSTFLAGS", rustFlags)
spec.commandLine(cargoCommand)
}
def rustLibrary = new File(runtimeDirectory, "libgame_android_entrypoint.so")
if (!runtimeLibrary.isFile()) {
throw new GradleException("missing staged SDL3 Android library: ${runtimeLibrary}")
}
if (!rustLibrary.isFile()) {
throw new GradleException("missing Rust Android library after cargo-ndk build: ${rustLibrary}")
}
}
}
if (!project.ext.has("sasedevRustGameFeature")) {
throw new GradleException("sasedevRustGameFeature must select exactly one Cargo game feature")
}
if (!project.ext.has("sasedevRustAndroidAbis")) {
throw new GradleException("sasedevRustAndroidAbis must declare at least one Android ABI")
}
if (!project.ext.has("sasedevRustAndroidApi")) {
throw new GradleException("sasedevRustAndroidApi must declare the Rust Android API level")
}
def selectedGameFeature = project.ext.sasedevRustGameFeature as String
def selectedAbis = (project.ext.sasedevRustAndroidAbis as List).collect { configuredAbi -> configuredAbi as String }
def selectedAndroidApi = project.ext.sasedevRustAndroidApi as Integer
def configuredSdl3AarName = providers.gradleProperty("sdl3AarName").get()
def configuredNdkVersion = providers.gradleProperty("androidNdkVersion").get()
def repositoryDirectory = rootProject.projectDir.parentFile
def supportedAbis = ["arm64-v8a", "armeabi-v7a", "x86_64", "x86"] as Set
if (selectedAbis.isEmpty()) {
throw new GradleException("sasedevRustAndroidAbis must not be empty")
}
if (selectedAbis.toSet().size() != selectedAbis.size()) {
throw new GradleException("sasedevRustAndroidAbis must not contain duplicates: ${selectedAbis}")
}
def unsupportedAbis = selectedAbis.findAll { configuredAbi -> !supportedAbis.contains(configuredAbi) }
if (!unsupportedAbis.isEmpty()) {
throw new GradleException("unsupported Rust Android ABI(s): ${unsupportedAbis.join(', ')}")
}
androidComponents {
onVariants(selector().all()) { variant ->
def selectedReleaseBuild = variant.buildType == "release"
selectedAbis.each { selectedAbi ->
def variantSuffix = variant.name.substring(0, 1).toUpperCase() + variant.name.substring(1)
def abiSuffix = selectedAbi
.split(/[^A-Za-z0-9]+/)
.findAll { segment -> !segment.isEmpty() }
.collect { segment -> segment.substring(0, 1).toUpperCase() + segment.substring(1) }
.join("")
def nativeTask = tasks.register(
"build${variantSuffix}SasedevRust${abiSuffix}",
BuildSasedevRustAndroidTask,
) {
sdl3Aar.set(rootProject.layout.projectDirectory.file("libs/${configuredSdl3AarName}"))
rustInputs.from(rootProject.file("../Cargo.toml"))
rustInputs.from(rootProject.file("../.cargo/config.toml"))
rustInputs.from(rootProject.fileTree("../crates") {
include "**/*.rs"
include "**/Cargo.toml"
include "**/build.rs"
})
gameFeature.set(selectedGameFeature)
abi.set(selectedAbi)
androidApi.set(selectedAndroidApi)
ndkVersion.set(configuredNdkVersion)
releaseBuild.set(selectedReleaseBuild)
repositoryRoot.set(repositoryDirectory)
outputDirectory.set(layout.buildDirectory.dir("generated/sasedevNative/${variant.name}/${selectedAbi}/jniLibs"))
}
if (variant.sources.jniLibs == null) {
throw new GradleException("AGP variant ${variant.name} does not expose jniLibs sources")
}
variant.sources.jniLibs.addGeneratedSourceDirectory(nativeTask) { task ->
task.outputDirectory
}
}
}
}

View File

@@ -1,5 +1,7 @@
// file: Android/settings.gradle
// version: 2
// version: 3
import org.gradle.util.GradleVersion
pluginManagement {
repositories {
@@ -9,6 +11,14 @@ pluginManagement {
}
}
def minimumGradleVersion = GradleVersion.version("9.6.0")
def currentGradleVersion = GradleVersion.current()
if (currentGradleVersion < minimumGradleVersion) {
throw new GradleException(
"Gradle ${minimumGradleVersion.version} or newer is required; current version is ${currentGradleVersion.version}"
)
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {

View File

@@ -1,8 +1,78 @@
<!-- file: CHANGELOG.md -->
<!-- version: 16 -->
<!-- version: 22 -->
# 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
- 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 ;
- client `ws://`, listener serveur, split send/receive, close propre, limites message/frame/write-buffer, deadlines connect/send/close et erreurs transport-neutral validés sans runtime ni task backend privé ;
- robustesse validée par tests loopback et négatifs : payloads hors limite, Text interdit, peer drop abrupt, handshake silencieux borné et mappings capacité/backpressure ;
- smoke runtime `game-realtime-websocket-smoke: PASS` validé hors harness de test sur localhost avec port éphémère, échange binaire bidirectionnel et fermeture propre ;
- `0.3.4-beta.1` a validé le workspace complet avec 53 tests et l'arbre inverse a confirmé que seul le launcher de smoke dépend du backend WebSocket ; `0.3.4-rc.1` a ensuite repassé audits, check/Clippy, 18 tests realtime ciblés, smoke et frontière de dépendances sans défaut ;
- documentation durable des deux crates realtime livrée et prompt `0.3.5` préparé pour évaluer WebTransport/QUIC sur la même frontière avec WebSocket comme baseline/fallback ;
- aucun TLS direct, wire codec final, protocole session, synchronisation gameplay, simulation authoritative ou serveur Uroburas n'est introduit dans `0.3.4`.
Les détails des phases `alpha`, `beta`, `rc` et du correctif `alpha.3.fix.1` restent dans `deltas/0.3.4/` et `history/0.3.4/`.
## 0.3.4-rc.1 — 2026-09-21
- gel fonctionnel de la baseline realtime : `game-realtime-transport-lib` porte le contrat binaire transport-neutral et `game-realtime-websocket-lib` son backend Tokio/tokio-tungstenite, sans dépendance transport dans les moteurs ou le gameplay ;
- client `ws://`, listener serveur, split send/receive, close propre, erreurs transport-neutral, limites de message/frame/write-buffer et deadlines connect/send/close validés sans runtime ni task backend privé ;
- robustesse validée sur localhost : round-trip binaire, payload hors limite, Text interdit, peer drop abrupt, handshake silencieux borné, mappings capacité/backpressure et smoke runtime public `game-realtime-websocket-smoke: PASS` ;
- `0.3.4-beta.1` validée avec audits propres, `cargo check`, Clippy strict et `cargo test --workspace --all-targets --all-features` : 53 tests réussis, puis smoke runtime séparé et arbre inverse confirmant que seul le launcher de smoke dépend du backend WebSocket ;
- consolidation de la documentation durable des deux crates realtime et préparation du prompt `0.3.5` pour évaluer WebTransport/QUIC sur la même frontière, avec WebSocket conservé comme fallback de référence ;
- aucun TLS direct, protocole de session, synchronisation gameplay, simulation authoritative ou serveur Uroburas n'est introduit dans `0.3.4`.
La RC n'ouvre aucun nouveau scope. Seuls les correctifs nécessaires à la publication selon `VER-RC-*` peuvent produire `0.3.4-rc.1.fix.N`.
## 0.3.3 — 2026-09-21
- publication stable du pipeline Android SDL3 natif multi-ABI possédé par Gradle/Cargo, sans orchestrateur Python de build ni `src/main/jniLibs` généré dans les sources ;
- APK Debug universal et AAB Release produits pour `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`, avec `minSdk 21` conservé et fumé sur Android 5.0/API 21 x86 ;
- compatibilité pages mémoire 16 KB validée pour les ABI 64 bits : `zipalign -P 16`, segments ELF `align 2**14` et smoke API 35 x86_64 `ps16k` avec `PAGE_SIZE=16384` ;
- smokes finaux Snake et Reflex validés sur AVD API 36 x86_64 et Samsung API 29 ARM64, avec installation réussie, activité lancée, processus vivant et aucune entrée crash remontée ;
- environnement Android natif validé avec JDK courant et Gradle système `>= 9.6.0`, sans wrapper Gradle ni `JAVA_HOME` imposé au projet ;
- préparation de `0.3.4` pour l'API de transport realtime/WebSocket et pour la nouvelle nomenclature prospective `alpha.M / beta.M / rc.M`, sans aucune réécriture des anciens `deltas/`, `history/`, prompts ou entrées historiques.
Les détails des phases `pre`, `beta`, `rc` et de leurs correctifs restent dans `deltas/0.3.3/` et `history/0.3.3/`.
## 0.3.3-3-rc.1 — 2026-09-21
- gel fonctionnel du pipeline Android SDL3 natif désormais possédé par Gradle/Cargo, sans orchestrateur Python de build ni `src/main/jniLibs` généré dans les sources ;
- APK Debug universal et AAB Release validés pour `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`, avec `minSdk 21` conservé ;
- compatibilité pages mémoire 16 KB validée sur les ABI 64 bits, y compris smoke Android 15/API 35 x86_64 `ps16k` avec `PAGE_SIZE=16384` ;
- smoke Android 5.0/API 21 x86 validé, puis smokes beta Snake et Reflex validés sur AVD API 36 x86_64 et Samsung API 29 ARM64 sans crash immédiat ;
- environnement Android natif validé avec Temurin 25 et Gradle système 9.7.1, le projet imposant seulement `Gradle >= 9.6.0` ;
- prompt `0.3.4` préparé pour l'API de transport realtime/WebSocket et pour la migration de nomenclature à `alpha.M`, `beta.M`, `rc.M` à partir de la prochaine session uniquement ; aucun historique antérieur ne sera renommé ou réécrit.
La RC n'ouvre aucun nouveau scope. Seuls les correctifs de publication autorisés par `VER-RC-*` peuvent produire un `3-rc.1.fix.N`.
## 0.3.1 — 2026-09-21
- publication stable du second host Snake sous Tauri Android, conservé comme POC de référence et non comme voie de production Android ;

View File

@@ -1,5 +1,5 @@
# file: Cargo.toml
# version: 80
# version: 113
[workspace]
resolver = "3"
@@ -9,10 +9,19 @@ members = [
"crates/engines/engine-v1-sdl",
"crates/games/game-reflex-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-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-snake-poc-desktop",
"crates/common/game-assets-lib",
"crates/common/game-logging-lib",
"crates/common/game-realtime-transport-lib",
"crates/common/game-realtime-websocket-lib",
"crates/common/game-realtime-webtransport-lib",
"crates/apps/game-android-entrypoint",
"crates/apps/game-reflex-poc-tauri",
"crates/apps/game-reflex-poc-wasm",
@@ -21,7 +30,7 @@ members = [
]
[workspace.package]
version = "0.3.1"
version = "0.3.5"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/games"
@@ -29,6 +38,9 @@ authors = ["Sasedev <games@sasedev.com>"]
publish = false
[workspace.dependencies]
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"] }
sdl3 = "^0.20"
tracing = "0.1.44"
@@ -38,7 +50,13 @@ tracing-subscriber = "0.3.23"
tauri = "2"
tauri-build = "2"
tauri-plugin-tracing = "^0.3"
tokio = "1.53.1"
tokio-tungstenite = { version = "0.30.0", default-features = false }
url = "2.5.8"
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-transport-quinn = { version = "0.12.1", default-features = false }
web-transport-wasm = "0.6.0"
[workspace.lints.rust]
missing_docs = "warn"

View File

@@ -1,5 +1,5 @@
<!-- file: README.md -->
<!-- version: 51 -->
<!-- version: 83 -->
# games.sasedev
@@ -25,11 +25,11 @@ Workspace expérimental puis productif pour des jeux multiplateformes principale
## Baseline
Version stable de référence : `0.3.1`.
Version stable de référence : `0.3.5`.
Version suivante active planifiée : `0.3.3` (non démarrée). `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.1` conserve Snake Tauri Android comme POC de référence validé sur AVD x86_64 et appareil ARM64 réel. La prochaine version active `0.3.3` est centrée sur Android SDL3 natif multi-ABI, le packaging Gradle/AAB et lévaluation explicite de la compatibilité avec des versions Android plus anciennes.
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.
@@ -45,4 +45,4 @@ Les deux premiers jeux sont des POC structurels : `game-reflex-poc` et `game-sna
## Diagnostics et tests
Les socles transverses `crates/common/game-assets-lib` et `crates/common/game-logging-lib` fournissent respectivement la résolution logique des assets et le tracing commun. 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 -->
<!-- version: 25 -->
<!-- version: 28 -->
# Roadmap
@@ -48,9 +48,9 @@ Le détail historique des prereleases `0.1.0-*` reste dans `deltas/0.1.0/` et `h
- (x) `0.3.1` — Snake Tauri Android livré comme POC de référence réutilisant la baseline Web/WASM ; la production Android reste orientée SDL3 natif.
- (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.
- ( ) `0.3.3` — Android SDL3 natif multi-ABI avec Cargo/Gradle natifs, APK universal pour tests, AAB comme cible de distribution et évaluation du `minSdk`/support danciennes versions Android, sans script Python de build.
- ( ) `0.3.4` — API de transport realtime + WebSocket/tokio-tungstenite baseline, 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.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.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`.
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

@@ -1,5 +1,5 @@
<!-- file: RULES.md -->
<!-- version: 5 -->
<!-- version: 6 -->
# Index normatif games.sasedev
@@ -17,7 +17,7 @@ Les règles détaillées sont maintenues sous `docs/rules/` et sont cumulatives
6. [`docs/rules/VERSION_WORKFLOW.md`](docs/rules/VERSION_WORKFLOW.md) — SemVer, niveaux de maturité, fixes, deltas et livraisons ;
7. [`docs/rules/RULES_COMMANDS.md`](docs/rules/RULES_COMMANDS.md) — politique dexécution des commandes Cargo, audits, runners, Android, Web et Git ;
8. [`docs/rules/RULES_VALIDATION_MATRIX.md`](docs/rules/RULES_VALIDATION_MATRIX.md) — matrice évolutive des commandes, dépendances de validation et politiques de nettoyage ;
9. [`docs/rules/RULES_SESSION_PLANNING.md`](docs/rules/RULES_SESSION_PLANNING.md) — cadrage `pre.1`, dimensionnement des sessions et cycle de transmission ;
9. [`docs/rules/RULES_SESSION_PLANNING.md`](docs/rules/RULES_SESSION_PLANNING.md) — cadrage `alpha.1`, dimensionnement des sessions et cycle de transmission ;
10. [`docs/rules/PROMPT_STRUCTURE.md`](docs/rules/PROMPT_STRUCTURE.md) — structure minimale des prompts de reprise et rappels de workflow obligatoires ;
11. [`docs/rules/RULES_SERVER_HOSTING.md`](docs/rules/RULES_SERVER_HOSTING.md) — contraintes durables de portabilité et préférence d'auto-hébergement.

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,21 @@
# file: crates/apps/game-realtime-websocket-smoke/Cargo.toml
# version: 1
[package]
name = "game-realtime-websocket-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" }
tokio = { workspace = true, features = ["macros", "rt", "time"] }
tracing.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,108 @@
// file: crates/apps/game-realtime-websocket-smoke/src/main.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Executable localhost smoke for the public games.sasedev WebSocket realtime transport 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-client-smoke";
const SERVER_PAYLOAD: &[u8] = b"games.sasedev-server-smoke";
const SMOKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const TRACING_TARGET: &str = "games::realtime::websocket::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 WebSocket smoke tracing: {error}");
return std::process::ExitCode::FAILURE;
},
};
tracing::info!(target: TRACING_TARGET, "realtime WebSocket 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 WebSocket smoke passed");
println!("game-realtime-websocket-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 WebSocket smoke failed");
eprintln!("game-realtime-websocket-smoke: FAIL: {error}");
std::process::ExitCode::FAILURE
},
std::result::Result::Err(_) => {
tracing::error!(target: TRACING_TARGET, timeout_ms = SMOKE_TIMEOUT.as_millis(), "realtime WebSocket smoke timed out");
eprintln!("game-realtime-websocket-smoke: FAIL: smoke timed out");
std::process::ExitCode::FAILURE
},
};
}
async fn run_smoke() -> 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!("listener bind failed: {error}")),
};
let endpoint = format!("ws://{}/", listener.local_addr());
tracing::info!(target: TRACING_TARGET, endpoint = endpoint.as_str(), "loopback endpoint bound");
let (server_result, client_result) = tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint.as_str()));
let server_connection = 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_connection = 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 (mut server_sender, mut server_receiver) = server_connection.split();
let (mut client_sender, mut client_receiver) = client_connection.split();
let client_message = game_realtime_transport_lib::TransportMessage::new(CLIENT_PAYLOAD.to_vec());
if let std::result::Result::Err(error) = client_sender.send(client_message).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"));
}
let server_message = game_realtime_transport_lib::TransportMessage::new(SERVER_PAYLOAD.to_vec());
if let std::result::Result::Err(error) = server_sender.send(server_message).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 handshake"));
}
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

@@ -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

@@ -0,0 +1,14 @@
# file: crates/common/game-realtime-transport-lib/Cargo.toml
# version: 1
[package]
name = "game-realtime-transport-lib"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish.workspace = true
[lints]
workspace = true

View File

@@ -0,0 +1,42 @@
<!-- file: crates/common/game-realtime-transport-lib/README.md -->
<!-- version: 2 -->
# game-realtime-transport-lib
Contrat realtime transport-neutral de `games.sasedev`. La crate définit uniquement la forme minimale d'une connexion établie, de ses moitiés d'envoi/réception, des payloads binaires opaques et des erreurs stables visibles par les couches supérieures.
## Responsabilité
La crate possède :
- `TransportMessage`, buffer binaire possédé ;
- `TransportReceive`, qui distingue message reçu et fermeture distante propre ;
- `RealtimeConnection`, `RealtimeSender` et `RealtimeReceiver` ;
- `TransportError` et `TransportErrorKind` comme catégories backend-neutral.
Elle ne possède pas :
- l'établissement d'une connexion réseau ;
- Tokio ou un autre runtime ;
- WebSocket, WebTransport, QUIC, HTTP ou TLS ;
- un codec wire ;
- une session joueur/room ;
- la synchronisation gameplay ou la simulation authoritative.
## Contrat async
`RealtimeConnection::split()` consomme une connexion établie et retourne des moitiés d'envoi et de réception indépendantes. Les opérations async sont exposées par des futures associées GAT plutôt que par `async-trait` ou `Box<dyn Future>`.
Le contrat n'impose volontairement aucune borne `Send` aux futures. Un backend natif peut fournir des futures `Send`, tandis qu'un futur backend navigateur/WASM ne doit pas être exclu par une contrainte de threading qui ne relève pas de l'abstraction transport.
## Sémantique
Les payloads sont toujours binaires et opaques. Une couche supérieure pourra ultérieurement leur appliquer un codec wire ou un protocole de session sans modifier cette crate.
Une fermeture distante propre est représentée par `TransportReceive::Closed`. Elle n'est pas convertie en erreur I/O générique. Les erreurs utilisent une catégorie stable (`Timeout`, `MessageTooLarge`, `Backpressure`, `Protocol`, etc.) et un détail de diagnostic, sans exposer le type d'erreur du backend concret.
## Dépendances et sens d'ownership
Cette crate ne dépend d'aucun backend realtime. Les implémentations concrètes dépendent d'elle, jamais l'inverse.
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,42 @@
// file: crates/common/game-realtime-transport-lib/src/connection.rs
// version: 1
/// Established realtime connection that owns independent send and receive halves.
pub trait RealtimeConnection {
/// Send half produced when this connection is split.
type Sender: crate::RealtimeSender;
/// Receive half produced when this connection is split.
type Receiver: crate::RealtimeReceiver;
/// Consumes the connection and returns independent send and receive halves.
fn split(self) -> (Self::Sender, Self::Receiver);
}
/// Send half of an established realtime connection.
pub trait RealtimeSender {
/// Future returned by [`RealtimeSender::send`].
type SendFuture<'a>: core::future::Future<Output = Result<(), crate::TransportError>>
where
Self: 'a;
/// Future returned by [`RealtimeSender::close`].
type CloseFuture<'a>: core::future::Future<Output = Result<(), crate::TransportError>>
where
Self: 'a;
/// Sends one owned opaque binary message, respecting backend backpressure.
fn send(&mut self, message: crate::TransportMessage) -> Self::SendFuture<'_>;
/// Initiates a clean local transport-level close.
fn close(&mut self) -> Self::CloseFuture<'_>;
}
/// Receive half of an established realtime connection.
pub trait RealtimeReceiver {
/// Future returned by [`RealtimeReceiver::receive`].
type ReceiveFuture<'a>: core::future::Future<Output = Result<crate::TransportReceive, crate::TransportError>>
where
Self: 'a;
/// Receives one binary message or observes a clean remote close.
fn receive(&mut self) -> Self::ReceiveFuture<'_>;
}

View File

@@ -0,0 +1,82 @@
// file: crates/common/game-realtime-transport-lib/src/error.rs
// version: 1
/// Stable transport-neutral category for a realtime operation failure.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportErrorKind {
/// An endpoint or transport configuration is invalid.
InvalidConfiguration,
/// A client connection could not be established.
Connect,
/// A server endpoint could not be bound.
Bind,
/// A server could not accept an incoming connection.
Accept,
/// An operation exceeded its configured deadline.
Timeout,
/// A payload exceeds the configured transport bound.
MessageTooLarge,
/// The transport cannot currently accept more outbound data within its configured bounds.
Backpressure,
/// The requested operation requires a connection that is already closed.
Closed,
/// An underlying I/O operation failed.
Io,
/// The backend reported a protocol-level or transport-specific failure.
Protocol,
/// The operation was explicitly aborted or cancelled when that distinction is observable.
Aborted,
}
impl core::fmt::Display for TransportErrorKind {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
return match self {
crate::TransportErrorKind::InvalidConfiguration => formatter.write_str("invalid configuration"),
crate::TransportErrorKind::Connect => formatter.write_str("connect failure"),
crate::TransportErrorKind::Bind => formatter.write_str("bind failure"),
crate::TransportErrorKind::Accept => formatter.write_str("accept failure"),
crate::TransportErrorKind::Timeout => formatter.write_str("operation timed out"),
crate::TransportErrorKind::MessageTooLarge => formatter.write_str("message too large"),
crate::TransportErrorKind::Backpressure => formatter.write_str("transport backpressure"),
crate::TransportErrorKind::Closed => formatter.write_str("connection closed"),
crate::TransportErrorKind::Io => formatter.write_str("I/O failure"),
crate::TransportErrorKind::Protocol => formatter.write_str("protocol failure"),
crate::TransportErrorKind::Aborted => formatter.write_str("operation aborted"),
};
}
}
/// Transport-neutral operation failure with a stable category and diagnostic detail.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportError {
detail: String,
kind: crate::TransportErrorKind,
}
impl TransportError {
/// Creates a transport failure from a stable category and backend-neutral diagnostic detail.
#[must_use]
pub fn new(kind: crate::TransportErrorKind, detail: impl Into<String>) -> Self {
return Self { detail: detail.into(), kind };
}
/// Borrows the diagnostic detail associated with the failure.
#[must_use]
pub fn detail(&self) -> &str {
return self.detail.as_str();
}
/// Returns the stable transport-neutral failure category.
#[must_use]
pub fn kind(&self) -> crate::TransportErrorKind {
return self.kind;
}
}
impl core::fmt::Display for TransportError {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
return write!(formatter, "{}: {}", self.kind, self.detail);
}
}
impl std::error::Error for TransportError {}

View File

@@ -0,0 +1,31 @@
// file: crates/common/game-realtime-transport-lib/src/lib.rs
// version: 1
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Transport-neutral realtime connection contracts for games.sasedev.
mod connection;
mod error;
mod message;
/// Re-export of an established realtime connection that can be split into independent halves.
pub use self::connection::RealtimeConnection;
/// Re-export of the receive half of an established realtime connection.
pub use self::connection::RealtimeReceiver;
/// Re-export of the send half of an established realtime connection.
pub use self::connection::RealtimeSender;
/// Re-export of a transport-neutral operation failure.
pub use self::error::TransportError;
/// Re-export of transport-neutral error categories.
pub use self::error::TransportErrorKind;
/// Re-export of an owned opaque binary transport message.
pub use self::message::TransportMessage;
/// Re-export of the result of one receive operation.
pub use self::message::TransportReceive;
#[cfg(test)]
#[path = "../unit_tests/contract.rs"]
mod tests;

View File

@@ -0,0 +1,49 @@
// file: crates/common/game-realtime-transport-lib/src/message.rs
// version: 1
/// Owned opaque binary payload carried by a realtime transport.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TransportMessage {
bytes: Vec<u8>,
}
impl TransportMessage {
/// Creates a transport message from owned bytes.
#[must_use]
pub fn new(bytes: Vec<u8>) -> Self {
return Self { bytes };
}
/// Borrows the opaque payload bytes.
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
return self.bytes.as_slice();
}
/// Consumes the message and returns its owned payload bytes.
#[must_use]
pub fn into_bytes(self) -> Vec<u8> {
return self.bytes;
}
/// Returns whether the payload is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
return self.bytes.is_empty();
}
/// Returns the payload length in bytes.
#[must_use]
pub fn len(&self) -> usize {
return self.bytes.len();
}
}
/// Outcome of one successful transport receive operation.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum TransportReceive {
/// One opaque binary message was received.
Message(crate::TransportMessage),
/// The remote peer completed a clean transport-level close.
Closed,
}

View File

@@ -0,0 +1,131 @@
// file: crates/common/game-realtime-transport-lib/unit_tests/contract.rs
// version: 1
#[derive(Debug, Eq, PartialEq)]
struct TestSender {
closed: bool,
sent_bytes: usize,
}
impl crate::RealtimeSender for TestSender {
type SendFuture<'a>
= std::future::Ready<Result<(), crate::TransportError>>
where
Self: 'a;
type CloseFuture<'a>
= std::future::Ready<Result<(), crate::TransportError>>
where
Self: 'a;
fn send(&mut self, message: crate::TransportMessage) -> Self::SendFuture<'_> {
self.sent_bytes = self.sent_bytes.saturating_add(message.len());
return std::future::ready(Ok(()));
}
fn close(&mut self) -> Self::CloseFuture<'_> {
self.closed = true;
return std::future::ready(Ok(()));
}
}
#[derive(Debug, Eq, PartialEq)]
struct TestReceiver;
impl crate::RealtimeReceiver for TestReceiver {
type ReceiveFuture<'a>
= std::future::Ready<Result<crate::TransportReceive, crate::TransportError>>
where
Self: 'a;
fn receive(&mut self) -> Self::ReceiveFuture<'_> {
return std::future::ready(Ok(crate::TransportReceive::Closed));
}
}
struct TestConnection;
impl crate::RealtimeConnection for TestConnection {
type Sender = TestSender;
type Receiver = TestReceiver;
fn split(self) -> (Self::Sender, Self::Receiver) {
return (TestSender { closed: false, sent_bytes: 0 }, TestReceiver);
}
}
fn poll_ready<T>(future: impl core::future::Future<Output = T>) -> T {
let mut future = std::pin::pin!(future);
let waker = std::task::Waker::noop();
let mut context = std::task::Context::from_waker(waker);
return match core::future::Future::poll(future.as_mut(), &mut context) {
std::task::Poll::Ready(value) => value,
std::task::Poll::Pending => unreachable!("test future unexpectedly remained pending"),
};
}
#[test]
fn message_preserves_owned_binary_payload() {
let message = crate::TransportMessage::new(vec![0, 1, 2, 255]);
assert_eq!(message.as_bytes(), &[0, 1, 2, 255]);
assert_eq!(message.len(), 4);
assert!(!message.is_empty());
assert_eq!(message.into_bytes(), vec![0, 1, 2, 255]);
}
#[test]
fn empty_message_remains_a_valid_transport_payload() {
let message = crate::TransportMessage::new(Vec::new());
assert!(message.is_empty());
assert_eq!(message.len(), 0);
}
#[test]
fn clean_remote_close_is_distinct_from_transport_error() {
let mut receiver = TestReceiver;
let received = poll_ready(crate::RealtimeReceiver::receive(&mut receiver));
assert_eq!(received, Ok(crate::TransportReceive::Closed));
}
#[test]
fn connection_split_produces_independent_contract_halves() {
let (mut sender, mut receiver) = crate::RealtimeConnection::split(TestConnection);
assert_eq!(poll_ready(crate::RealtimeSender::send(&mut sender, crate::TransportMessage::new(vec![1, 2, 3]))), Ok(()));
assert_eq!(sender.sent_bytes, 3);
assert_eq!(poll_ready(crate::RealtimeReceiver::receive(&mut receiver)), Ok(crate::TransportReceive::Closed));
assert_eq!(poll_ready(crate::RealtimeSender::close(&mut sender)), Ok(()));
assert!(sender.closed);
}
#[test]
fn message_receive_variant_preserves_payload() {
let received = crate::TransportReceive::Message(crate::TransportMessage::new(vec![4, 5, 6]));
assert_eq!(received, crate::TransportReceive::Message(crate::TransportMessage::new(vec![4, 5, 6])));
}
#[test]
fn transport_error_preserves_stable_kind_and_diagnostic_detail() {
let error = crate::TransportError::new(crate::TransportErrorKind::Timeout, "send deadline exceeded");
assert_eq!(error.kind(), crate::TransportErrorKind::Timeout);
assert_eq!(error.detail(), "send deadline exceeded");
assert_eq!(error.to_string(), "operation timed out: send deadline exceeded");
}
#[test]
fn transport_error_kinds_have_stable_human_readable_labels() {
let cases = [
(crate::TransportErrorKind::InvalidConfiguration, "invalid configuration"),
(crate::TransportErrorKind::Connect, "connect failure"),
(crate::TransportErrorKind::Bind, "bind failure"),
(crate::TransportErrorKind::Accept, "accept failure"),
(crate::TransportErrorKind::Timeout, "operation timed out"),
(crate::TransportErrorKind::MessageTooLarge, "message too large"),
(crate::TransportErrorKind::Backpressure, "transport backpressure"),
(crate::TransportErrorKind::Closed, "connection closed"),
(crate::TransportErrorKind::Io, "I/O failure"),
(crate::TransportErrorKind::Protocol, "protocol failure"),
(crate::TransportErrorKind::Aborted, "operation aborted"),
];
for (kind, expected) in cases {
assert_eq!(kind.to_string(), expected);
}
}

View File

@@ -0,0 +1,24 @@
# file: crates/common/game-realtime-websocket-lib/Cargo.toml
# version: 3
[package]
name = "game-realtime-websocket-lib"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
futures-util = { workspace = true, features = ["sink", "std"] }
game-realtime-transport-lib = { path = "../game-realtime-transport-lib" }
tokio = { workspace = true, features = ["net", "time"] }
tokio-tungstenite = { workspace = true, features = ["connect", "handshake"] }
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt", "time"] }
[lints]
workspace = true

View File

@@ -0,0 +1,49 @@
<!-- file: crates/common/game-realtime-websocket-lib/README.md -->
<!-- version: 1 -->
# game-realtime-websocket-lib
Backend WebSocket de référence pour le contrat `game-realtime-transport-lib`, fondé sur Tokio et `tokio-tungstenite`.
## Responsabilité
La crate possède :
- connexion client `ws://` ;
- listener serveur TCP + upgrade WebSocket ;
- types concrets `WebSocketConnection`, `WebSocketSender` et `WebSocketReceiver` ;
- mapping WebSocket vers le contrat binaire transport-neutral ;
- limites de message/frame/write-buffer ;
- deadlines connect/handshake, send et close ;
- tracing du domaine `games::realtime::websocket` ;
- mapping des erreurs Tungstenite vers `TransportErrorKind`.
Elle ne possède pas le runtime Tokio : le consommateur crée et exécute son runtime. La crate ne lance ni runtime global, ni thread runtime privé, ni task backend détachée pour une connexion de base.
## Frontières
Le backend transporte des octets opaques. Il ne connaît ni joueur, ni room, ni tick, ni snapshot, ni codec wire, ni protocole de session.
Les messages Text ne font pas partie du contrat et sont rejetés comme erreur de protocole. Ping/Pong reste un détail WebSocket. Une fermeture distante propre devient `TransportReceive::Closed`.
La baseline active utilise `ws://`. Aucune feature TLS de `tokio-tungstenite` n'est activée ; une politique `wss://` directe n'est pas introduite tant qu'un besoin produit et une stratégie de certificats ne sont pas démontrés.
## Configuration
`WebSocketConfig::default()` fournit des bornes produit explicites :
```text
message maximum 1 MiB
frame maximum 1 MiB
write buffer target 64 KiB
write buffer maximum 2 MiB
connect/handshake 10 s
send 5 s
close 2 s
```
Les builders `with_*` permettent d'adapter ces limites avant `connect_with_config` ou `WebSocketListener::bind_with_config`. `validate()` refuse les configurations incohérentes avant l'établissement réseau.
## Utilisation
Voir [`USAGE.md`](USAGE.md) pour les points d'entrée client/serveur, la configuration et le smoke de référence.

View File

@@ -0,0 +1,78 @@
<!-- file: crates/common/game-realtime-websocket-lib/USAGE.md -->
<!-- version: 1 -->
# Utilisation — backend realtime WebSocket
## Préconditions
Le consommateur possède le runtime Tokio. `game-realtime-websocket-lib` utilise Tokio pour les sockets et deadlines mais ne crée jamais son propre runtime.
Le contrat transport-neutral est fourni par `game-realtime-transport-lib`. Pour appeler `split`, `send`, `receive` et `close`, importer les traits correspondants dans le module consommateur selon les règles Rust du dépôt.
## Client
Le point d'entrée simple est :
```text
game_realtime_websocket_lib::connect("ws://host:port/path")
```
La fonction retourne une `WebSocketConnection`. La connexion est ensuite consommée par `RealtimeConnection::split()` pour obtenir un sender et un receiver indépendants.
Pour une configuration spécifique, construire `WebSocketConfig`, valider ses invariants puis utiliser :
```text
game_realtime_websocket_lib::connect_with_config(endpoint, config)
```
## Serveur
Créer d'abord une adresse `SocketAddr`, puis binder :
```text
WebSocketListener::bind(address)
```
ou :
```text
WebSocketListener::bind_with_config(address, config)
```
`local_addr()` permet de connaître l'adresse réellement allouée, notamment après bind sur `127.0.0.1:0`. `accept().await` attend ensuite un peer TCP et effectue l'upgrade WebSocket dans la deadline configurée.
## Émission et réception
Un message applicatif est encapsulé dans `TransportMessage::new(Vec<u8>)`. Le sender refuse localement un payload dépassant les bornes configurées avant écriture.
`receive()` retourne :
- `TransportReceive::Message` pour un payload binaire ;
- `TransportReceive::Closed` pour une fermeture distante propre ;
- `TransportError` pour une erreur réseau/protocole, un timeout ou une limite dépassée.
Une frame Text reçue est une erreur de protocole : elle n'est jamais convertie en bytes métier.
## Fermeture
`RealtimeSender::close()` initie un close WebSocket propre et applique la deadline de fermeture configurée. Une task/future annulée n'est pas assimilée à un close handshake réussi.
## Smoke local
Le launcher de validation public utilise uniquement les API exposées par les deux crates realtime :
```bash
cargo run -p game-realtime-websocket-smoke
```
Il bind `127.0.0.1:0`, connecte un client réel, échange un payload binaire dans les deux sens, initie un close propre et doit terminer avec :
```text
game-realtime-websocket-smoke: PASS
```
Le smoke ne dépend ni d'Internet, ni d'un port fixe, ni d'un serveur externe.
## Limites intentionnelles
Cette crate n'est pas un protocole multijoueur. Authentification, versionnement wire, session joueur/room, heartbeat métier, resynchronisation, snapshots/deltas et simulation authoritative appartiennent aux couches supérieures et restent hors de son contrat.

View File

@@ -0,0 +1,167 @@
// file: crates/common/game-realtime-websocket-lib/src/config.rs
// version: 1
const DEFAULT_CLOSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const DEFAULT_MAX_FRAME_SIZE: usize = 1024 * 1024;
const DEFAULT_MAX_MESSAGE_SIZE: usize = 1024 * 1024;
const DEFAULT_MAX_WRITE_BUFFER_SIZE: usize = 2 * 1024 * 1024;
const DEFAULT_SEND_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
const DEFAULT_WRITE_BUFFER_SIZE: usize = 64 * 1024;
/// Product-facing limits and operation deadlines for one WebSocket connection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WebSocketConfig {
max_message_size: usize,
max_frame_size: usize,
write_buffer_size: usize,
max_write_buffer_size: usize,
connect_timeout: std::time::Duration,
send_timeout: std::time::Duration,
close_timeout: std::time::Duration,
}
impl Default for WebSocketConfig {
fn default() -> Self {
return Self {
max_message_size: DEFAULT_MAX_MESSAGE_SIZE,
max_frame_size: DEFAULT_MAX_FRAME_SIZE,
write_buffer_size: DEFAULT_WRITE_BUFFER_SIZE,
max_write_buffer_size: DEFAULT_MAX_WRITE_BUFFER_SIZE,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
send_timeout: DEFAULT_SEND_TIMEOUT,
close_timeout: DEFAULT_CLOSE_TIMEOUT,
};
}
}
impl WebSocketConfig {
/// Returns a copy with a different maximum 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 maximum WebSocket frame payload size.
#[must_use]
pub fn with_max_frame_size(mut self, value: usize) -> Self {
self.max_frame_size = value;
return self;
}
/// Returns a copy with a different Tungstenite write-buffer target.
#[must_use]
pub fn with_write_buffer_size(mut self, value: usize) -> Self {
self.write_buffer_size = value;
return self;
}
/// Returns a copy with a different hard maximum for the Tungstenite write buffer.
#[must_use]
pub fn with_max_write_buffer_size(mut self, value: usize) -> Self {
self.max_write_buffer_size = value;
return self;
}
/// Returns a copy with a different connection or 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 deadline for one send operation.
#[must_use]
pub fn with_send_timeout(mut self, value: std::time::Duration) -> Self {
self.send_timeout = value;
return self;
}
/// Returns a copy with a different deadline for a clean local close operation.
#[must_use]
pub fn with_close_timeout(mut self, value: std::time::Duration) -> Self {
self.close_timeout = value;
return self;
}
/// Returns the configured maximum binary message size.
#[must_use]
pub fn max_message_size(&self) -> usize {
return self.max_message_size;
}
/// Returns the configured maximum WebSocket frame payload size.
#[must_use]
pub fn max_frame_size(&self) -> usize {
return self.max_frame_size;
}
/// Returns the configured Tungstenite write-buffer target.
#[must_use]
pub fn write_buffer_size(&self) -> usize {
return self.write_buffer_size;
}
/// Returns the configured hard maximum for the Tungstenite write buffer.
#[must_use]
pub fn max_write_buffer_size(&self) -> usize {
return self.max_write_buffer_size;
}
/// Returns the configured connection or handshake deadline.
#[must_use]
pub fn connect_timeout(&self) -> std::time::Duration {
return self.connect_timeout;
}
/// Returns the configured deadline for one send operation.
#[must_use]
pub fn send_timeout(&self) -> std::time::Duration {
return self.send_timeout;
}
/// Returns the configured deadline for a clean local close operation.
#[must_use]
pub fn close_timeout(&self) -> std::time::Duration {
return self.close_timeout;
}
/// Validates all invariants required before creating a WebSocket endpoint or connection.
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 self.max_frame_size == 0 {
return Err(invalid_configuration("max_frame_size must be greater than zero"));
}
if self.max_frame_size > self.max_message_size {
return Err(invalid_configuration("max_frame_size must not exceed max_message_size"));
}
let minimum_max_write_buffer_size = match self.write_buffer_size.checked_add(self.max_message_size) {
Some(value) => value,
None => return Err(invalid_configuration("write_buffer_size + max_message_size overflows usize")),
};
if self.max_write_buffer_size < minimum_max_write_buffer_size {
return Err(invalid_configuration("max_write_buffer_size must fit write_buffer_size plus one maximum-sized message"));
}
if self.connect_timeout.is_zero() {
return Err(invalid_configuration("connect_timeout must be greater than zero"));
}
if self.send_timeout.is_zero() {
return Err(invalid_configuration("send_timeout must be greater than zero"));
}
if self.close_timeout.is_zero() {
return Err(invalid_configuration("close_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,26 @@
// file: crates/common/game-realtime-websocket-lib/src/lib.rs
// version: 2
#![warn(missing_docs)]
#![deny(unreachable_pub)]
#![forbid(unsafe_code)]
//! Tokio/tokio-tungstenite WebSocket backend for the games.sasedev realtime transport contract.
mod config;
mod websocket;
/// Re-export of product-facing WebSocket limits and operation deadlines.
pub use self::config::WebSocketConfig;
/// Re-export of an established WebSocket transport connection.
pub use self::websocket::WebSocketConnection;
/// Re-export of a bound WebSocket server listener.
pub use self::websocket::WebSocketListener;
/// Re-export of the receive half of an established WebSocket connection.
pub use self::websocket::WebSocketReceiver;
/// Re-export of the send half of an established WebSocket connection.
pub use self::websocket::WebSocketSender;
/// Re-export of the WebSocket client connection constructor using baseline defaults.
pub use self::websocket::connect;
/// Re-export of the configurable WebSocket client connection constructor.
pub use self::websocket::connect_with_config;

View File

@@ -0,0 +1,411 @@
// file: crates/common/game-realtime-websocket-lib/src/websocket.rs
// version: 2
use futures_util::SinkExt; // rust-rules: trait-import
use futures_util::StreamExt; // rust-rules: trait-import
const TRACING_TARGET: &str = "games::realtime::websocket";
type ClientStream = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
type ServerStream = tokio_tungstenite::WebSocketStream<tokio::net::TcpStream>;
type ClientSink = futures_util::stream::SplitSink<ClientStream, tokio_tungstenite::tungstenite::Message>;
type ServerSink = futures_util::stream::SplitSink<ServerStream, tokio_tungstenite::tungstenite::Message>;
type ClientReceiver = futures_util::stream::SplitStream<ClientStream>;
type ServerReceiver = futures_util::stream::SplitStream<ServerStream>;
enum WebSocketStreamKind {
Client(ClientStream),
Server(ServerStream),
}
enum WebSocketSinkKind {
Client(ClientSink),
Server(ServerSink),
}
enum WebSocketReceiverKind {
Client(ClientReceiver),
Server(ServerReceiver),
}
/// Established WebSocket connection implementing the transport-neutral realtime contract.
pub struct WebSocketConnection {
inner: WebSocketStreamKind,
config: crate::WebSocketConfig,
}
impl WebSocketConnection {
fn from_client(stream: ClientStream, config: crate::WebSocketConfig) -> Self {
return Self { inner: WebSocketStreamKind::Client(stream), config };
}
fn from_server(stream: ServerStream, config: crate::WebSocketConfig) -> Self {
return Self { inner: WebSocketStreamKind::Server(stream), config };
}
}
impl game_realtime_transport_lib::RealtimeConnection for WebSocketConnection {
type Sender = crate::WebSocketSender;
type Receiver = crate::WebSocketReceiver;
fn split(self) -> (Self::Sender, Self::Receiver) {
let max_frame_size = self.config.max_frame_size();
let max_message_size = self.config.max_message_size();
let send_timeout = self.config.send_timeout();
let close_timeout = self.config.close_timeout();
return match self.inner {
WebSocketStreamKind::Client(stream) => {
let (sender, receiver) = stream.split();
(
crate::WebSocketSender {
inner: WebSocketSinkKind::Client(sender),
max_frame_size,
max_message_size,
send_timeout,
close_timeout,
send_timed_out: false,
},
crate::WebSocketReceiver { inner: WebSocketReceiverKind::Client(receiver) },
)
},
WebSocketStreamKind::Server(stream) => {
let (sender, receiver) = stream.split();
(
crate::WebSocketSender {
inner: WebSocketSinkKind::Server(sender),
max_frame_size,
max_message_size,
send_timeout,
close_timeout,
send_timed_out: false,
},
crate::WebSocketReceiver { inner: WebSocketReceiverKind::Server(receiver) },
)
},
};
}
}
/// Send half of an established WebSocket transport connection.
pub struct WebSocketSender {
inner: WebSocketSinkKind,
max_frame_size: usize,
max_message_size: usize,
send_timeout: std::time::Duration,
close_timeout: std::time::Duration,
send_timed_out: bool,
}
impl game_realtime_transport_lib::RealtimeSender for WebSocketSender {
type SendFuture<'a>
= futures_util::future::LocalBoxFuture<'a, Result<(), game_realtime_transport_lib::TransportError>>
where
Self: 'a;
type CloseFuture<'a>
= futures_util::future::LocalBoxFuture<'a, Result<(), game_realtime_transport_lib::TransportError>>
where
Self: 'a;
fn send(&mut self, message: game_realtime_transport_lib::TransportMessage) -> Self::SendFuture<'_> {
return Box::pin(async move {
if self.send_timed_out {
return Err(game_realtime_transport_lib::TransportError::new(
game_realtime_transport_lib::TransportErrorKind::Aborted,
"sender is unavailable after a previous send timeout",
));
}
let payload_len = message.len();
if payload_len > self.max_message_size || payload_len > self.max_frame_size {
let error = game_realtime_transport_lib::TransportError::new(
game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
format!("binary payload size {payload_len} exceeds configured message/frame maxima {}/{}", self.max_message_size, self.max_frame_size),
);
tracing::warn!(
target: TRACING_TARGET,
payload_len = payload_len,
max_message_size = self.max_message_size,
max_frame_size = self.max_frame_size,
"outbound WebSocket payload rejected"
);
return Err(error);
}
let websocket_message = tokio_tungstenite::tungstenite::Message::Binary(message.into_bytes().into());
let send_timeout = self.send_timeout;
let send = async {
return match &mut self.inner {
WebSocketSinkKind::Client(sender) => sender.send(websocket_message).await,
WebSocketSinkKind::Server(sender) => sender.send(websocket_message).await,
};
};
let result = tokio::time::timeout(send_timeout, send).await;
return match result {
Ok(Ok(())) => {
tracing::trace!(target: TRACING_TARGET, payload_len = payload_len, "binary WebSocket payload sent");
Ok(())
},
Ok(Err(error)) => {
let mapped = map_stream_error(error);
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket send failed");
Err(mapped)
},
Err(_) => {
self.send_timed_out = true;
let error = timeout_error("WebSocket send", send_timeout);
tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(send_timeout), "WebSocket send timed out");
Err(error)
},
};
});
}
fn close(&mut self) -> Self::CloseFuture<'_> {
return Box::pin(async move {
let close_timeout = self.close_timeout;
let close = async {
return match &mut self.inner {
WebSocketSinkKind::Client(sender) => sender.close().await,
WebSocketSinkKind::Server(sender) => sender.close().await,
};
};
let result = tokio::time::timeout(close_timeout, close).await;
return match result {
Ok(Ok(())) => {
tracing::debug!(target: TRACING_TARGET, "local WebSocket close initiated");
Ok(())
},
Ok(Err(error)) => {
let mapped = map_stream_error(error);
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket close failed");
Err(mapped)
},
Err(_) => {
let error = timeout_error("WebSocket close", close_timeout);
tracing::warn!(target: TRACING_TARGET, timeout_ms = duration_millis(close_timeout), "WebSocket close timed out");
Err(error)
},
};
});
}
}
/// Receive half of an established WebSocket transport connection.
pub struct WebSocketReceiver {
inner: WebSocketReceiverKind,
}
impl game_realtime_transport_lib::RealtimeReceiver for WebSocketReceiver {
type ReceiveFuture<'a>
= futures_util::future::LocalBoxFuture<'a, Result<game_realtime_transport_lib::TransportReceive, game_realtime_transport_lib::TransportError>>
where
Self: 'a;
fn receive(&mut self) -> Self::ReceiveFuture<'_> {
return Box::pin(async move {
loop {
let next_message = match &mut self.inner {
WebSocketReceiverKind::Client(receiver) => receiver.next().await,
WebSocketReceiverKind::Server(receiver) => receiver.next().await,
};
match next_message {
Some(Ok(tokio_tungstenite::tungstenite::Message::Binary(bytes))) => {
tracing::trace!(target: TRACING_TARGET, payload_len = bytes.len(), "binary WebSocket payload received");
return Ok(game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(bytes.to_vec())));
},
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => {
tracing::debug!(target: TRACING_TARGET, "remote WebSocket close observed");
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
},
Some(Ok(tokio_tungstenite::tungstenite::Message::Ping(_))) | Some(Ok(tokio_tungstenite::tungstenite::Message::Pong(_))) => {},
Some(Ok(tokio_tungstenite::tungstenite::Message::Text(_))) => {
let error = game_realtime_transport_lib::TransportError::new(
game_realtime_transport_lib::TransportErrorKind::Protocol,
"text WebSocket messages are not part of the binary transport contract",
);
tracing::warn!(target: TRACING_TARGET, kind = %error.kind(), "unsupported WebSocket text message received");
return Err(error);
},
Some(Ok(tokio_tungstenite::tungstenite::Message::Frame(_))) => {
let error = game_realtime_transport_lib::TransportError::new(
game_realtime_transport_lib::TransportErrorKind::Protocol,
"unexpected raw WebSocket frame surfaced by the backend",
);
tracing::warn!(target: TRACING_TARGET, kind = %error.kind(), "unexpected raw WebSocket frame received");
return Err(error);
},
Some(Err(error)) => {
let mapped = map_stream_error(error);
tracing::warn!(target: TRACING_TARGET, kind = %mapped.kind(), detail = mapped.detail(), "WebSocket receive failed");
return Err(mapped);
},
None => {
tracing::debug!(target: TRACING_TARGET, "WebSocket stream ended");
return Ok(game_realtime_transport_lib::TransportReceive::Closed);
},
}
}
});
}
}
/// Bound TCP listener that upgrades accepted peers to WebSocket connections.
pub struct WebSocketListener {
listener: tokio::net::TcpListener,
local_addr: std::net::SocketAddr,
config: crate::WebSocketConfig,
}
impl WebSocketListener {
/// Binds a WebSocket listener with the baseline configuration.
pub async fn bind(address: std::net::SocketAddr) -> Result<Self, game_realtime_transport_lib::TransportError> {
return Self::bind_with_config(address, crate::WebSocketConfig::default()).await;
}
/// Binds a WebSocket listener with explicit product-facing limits and deadlines.
pub async fn bind_with_config(address: std::net::SocketAddr, config: crate::WebSocketConfig) -> Result<Self, game_realtime_transport_lib::TransportError> {
if let Err(error) = config.validate() {
return Err(error);
}
let listener = match tokio::net::TcpListener::bind(address).await {
Ok(value) => value,
Err(error) => {
let mapped = game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
tracing::warn!(target: TRACING_TARGET, address = %address, detail = mapped.detail(), "WebSocket listener bind failed");
return Err(mapped);
},
};
let local_addr = match listener.local_addr() {
Ok(value) => value,
Err(error) => {
let mapped = game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Bind, error.to_string());
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "bound WebSocket listener address lookup failed");
return Err(mapped);
},
};
tracing::info!(target: TRACING_TARGET, address = %local_addr, "WebSocket listener bound");
return Ok(Self { listener, local_addr, config });
}
/// Returns the concrete local 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 TCP peer and completes a bounded server-side WebSocket handshake.
pub async fn accept(&self) -> Result<crate::WebSocketConnection, game_realtime_transport_lib::TransportError> {
let (stream, peer_addr) = match self.listener.accept().await {
Ok(value) => value,
Err(error) => {
let mapped = game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Accept, error.to_string());
tracing::warn!(target: TRACING_TARGET, detail = mapped.detail(), "WebSocket TCP accept failed");
return Err(mapped);
},
};
let handshake = tokio_tungstenite::accept_async_with_config(stream, Some(tungstenite_config(&self.config)));
let result = tokio::time::timeout(self.config.connect_timeout(), handshake).await;
let websocket = match result {
Ok(Ok(value)) => value,
Ok(Err(error)) => {
let mapped = game_realtime_transport_lib::TransportError::new(game_realtime_transport_lib::TransportErrorKind::Accept, error.to_string());
tracing::warn!(target: TRACING_TARGET, peer = %peer_addr, detail = mapped.detail(), "WebSocket server handshake failed");
return Err(mapped);
},
Err(_) => {
let error = timeout_error("WebSocket server handshake", self.config.connect_timeout());
tracing::warn!(target: TRACING_TARGET, peer = %peer_addr, timeout_ms = duration_millis(self.config.connect_timeout()), "WebSocket server handshake timed out");
return Err(error);
},
};
tracing::info!(target: TRACING_TARGET, peer = %peer_addr, "WebSocket peer accepted");
return Ok(crate::WebSocketConnection::from_server(websocket, self.config));
}
}
/// Connects a client to one plain `ws://` endpoint with the baseline configuration.
pub async fn connect(endpoint: &str) -> Result<crate::WebSocketConnection, game_realtime_transport_lib::TransportError> {
return crate::connect_with_config(endpoint, crate::WebSocketConfig::default()).await;
}
/// Connects a client to one plain `ws://` endpoint with explicit limits and deadlines.
pub async fn connect_with_config(
endpoint: &str,
config: crate::WebSocketConfig,
) -> Result<crate::WebSocketConnection, game_realtime_transport_lib::TransportError> {
if !endpoint.starts_with("ws://") {
return Err(game_realtime_transport_lib::TransportError::new(
game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration,
"the baseline WebSocket backend accepts only ws:// endpoints",
));
}
if let Err(error) = config.validate() {
return Err(error);
}
tracing::debug!(target: TRACING_TARGET, endpoint = endpoint, "connecting WebSocket client");
let handshake = tokio_tungstenite::connect_async_with_config(endpoint, Some(tungstenite_config(&config)), false);
let result = tokio::time::timeout(config.connect_timeout(), handshake).await;
let (stream, _) = match result {
Ok(Ok(value)) => value,
Ok(Err(error)) => {
let mapped = map_connect_error(error);
tracing::warn!(
target: TRACING_TARGET,
endpoint = endpoint,
kind = %mapped.kind(),
detail = mapped.detail(),
"WebSocket client connect failed"
);
return Err(mapped);
},
Err(_) => {
let error = timeout_error("WebSocket client connect", config.connect_timeout());
tracing::warn!(target: TRACING_TARGET, endpoint = endpoint, timeout_ms = duration_millis(config.connect_timeout()), "WebSocket client connect timed out");
return Err(error);
},
};
tracing::info!(target: TRACING_TARGET, endpoint = endpoint, "WebSocket client connected");
return Ok(crate::WebSocketConnection::from_client(stream, config));
}
fn tungstenite_config(config: &crate::WebSocketConfig) -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig {
return tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
.write_buffer_size(config.write_buffer_size())
.max_write_buffer_size(config.max_write_buffer_size())
.max_message_size(Some(config.max_message_size()))
.max_frame_size(Some(config.max_frame_size()));
}
fn timeout_error(operation: &str, timeout: std::time::Duration) -> game_realtime_transport_lib::TransportError {
return game_realtime_transport_lib::TransportError::new(
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 map_connect_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError {
let kind = match &error {
tokio_tungstenite::tungstenite::Error::Url(_) => game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration,
_ => game_realtime_transport_lib::TransportErrorKind::Connect,
};
return game_realtime_transport_lib::TransportError::new(kind, error.to_string());
}
fn map_stream_error(error: tokio_tungstenite::tungstenite::Error) -> game_realtime_transport_lib::TransportError {
let kind = match &error {
tokio_tungstenite::tungstenite::Error::ConnectionClosed | tokio_tungstenite::tungstenite::Error::AlreadyClosed => {
game_realtime_transport_lib::TransportErrorKind::Closed
},
tokio_tungstenite::tungstenite::Error::Io(_) => game_realtime_transport_lib::TransportErrorKind::Io,
tokio_tungstenite::tungstenite::Error::Capacity(_) => game_realtime_transport_lib::TransportErrorKind::MessageTooLarge,
tokio_tungstenite::tungstenite::Error::WriteBufferFull(_) => game_realtime_transport_lib::TransportErrorKind::Backpressure,
_ => game_realtime_transport_lib::TransportErrorKind::Protocol,
};
return game_realtime_transport_lib::TransportError::new(kind, error.to_string());
}
#[cfg(test)]
#[path = "../unit_tests/websocket.rs"]
mod tests;

View File

@@ -0,0 +1,62 @@
// file: crates/common/game-realtime-websocket-lib/tests/loopback.rs
// version: 1
//! Deterministic localhost proof for the Tokio/tokio-tungstenite backend.
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 TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
#[tokio::test(flavor = "current_thread")]
async fn binary_round_trip_and_clean_close_work_on_loopback() {
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let listener = match game_realtime_websocket_lib::WebSocketListener::bind(bind_address).await {
Ok(value) => value,
Err(error) => panic!("loopback listener bind failed: {error}"),
};
let endpoint = format!("ws://{}/", listener.local_addr());
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
return tokio::join!(listener.accept(), game_realtime_websocket_lib::connect(endpoint.as_str()));
})
.await;
let (server_connection, client_connection) = match pair {
Ok((Ok(server), Ok(client))) => (server, client),
Ok((_server, _client)) => panic!("loopback connection establishment failed"),
Err(_) => panic!("loopback connection establishment timed out"),
};
let (mut server_sender, mut server_receiver) = server_connection.split();
let (mut client_sender, mut client_receiver) = client_connection.split();
let client_payload = game_realtime_transport_lib::TransportMessage::new(vec![0, 1, 2, 3, 255]);
let client_send = tokio::time::timeout(TEST_TIMEOUT, client_sender.send(client_payload)).await;
assert!(matches!(client_send, Ok(Ok(()))));
let server_receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
match server_receive {
Ok(Ok(received)) => assert_eq!(
received,
game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(vec![0, 1, 2, 3, 255]))
),
Ok(Err(error)) => panic!("server receive failed: {error}"),
Err(_) => panic!("server receive timed out"),
}
let server_payload = game_realtime_transport_lib::TransportMessage::new(vec![9, 8, 7, 6]);
let server_send = tokio::time::timeout(TEST_TIMEOUT, server_sender.send(server_payload)).await;
assert!(matches!(server_send, Ok(Ok(()))));
let client_receive = tokio::time::timeout(TEST_TIMEOUT, client_receiver.receive()).await;
match client_receive {
Ok(Ok(received)) => {
assert_eq!(received, game_realtime_transport_lib::TransportReceive::Message(game_realtime_transport_lib::TransportMessage::new(vec![9, 8, 7, 6])))
},
Ok(Err(error)) => panic!("client receive failed: {error}"),
Err(_) => panic!("client receive timed out"),
}
let client_close = tokio::time::timeout(TEST_TIMEOUT, client_sender.close()).await;
assert!(matches!(client_close, Ok(Ok(()))));
let server_close_observation = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
match server_close_observation {
Ok(Ok(received)) => assert_eq!(received, game_realtime_transport_lib::TransportReceive::Closed),
Ok(Err(error)) => panic!("server close observation failed: {error}"),
Err(_) => panic!("server close observation timed out"),
};
}

View File

@@ -0,0 +1,134 @@
// file: crates/common/game-realtime-websocket-lib/tests/robustness.rs
// version: 1
//! Negative and bounded lifecycle tests for the WebSocket transport backend.
use futures_util::SinkExt; // rust-rules: trait-import
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 SMALL_MESSAGE_LIMIT: usize = 32;
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
type RawClient = tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
#[tokio::test(flavor = "current_thread")]
async fn outbound_payload_over_the_configured_limit_is_rejected_before_write() {
let config = small_message_config();
let (server_connection, client_connection) = establish_backend_pair(config).await;
let (_server_sender, _server_receiver) = server_connection.split();
let (mut client_sender, _client_receiver) = client_connection.split();
let oversized = game_realtime_transport_lib::TransportMessage::new(vec![7; SMALL_MESSAGE_LIMIT + 1]);
let result = client_sender.send(oversized).await;
match result {
Ok(()) => panic!("oversized outbound 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_by_tungstenite() {
let config = small_message_config();
let (server_connection, mut raw_client) = establish_backend_server_with_raw_client(config).await;
let (_server_sender, mut server_receiver) = server_connection.split();
let send = raw_client.send(tokio_tungstenite::tungstenite::Message::binary(vec![3; SMALL_MESSAGE_LIMIT + 1])).await;
assert!(send.is_ok());
let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
match receive {
Ok(Ok(value)) => panic!("oversized inbound payload produced a successful receive: {value:?}"),
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge),
Err(_) => panic!("oversized inbound payload did not complete within the test timeout"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn text_message_is_rejected_by_the_binary_transport_contract() {
let (server_connection, mut raw_client) = establish_backend_server_with_raw_client(game_realtime_websocket_lib::WebSocketConfig::default()).await;
let (_server_sender, mut server_receiver) = server_connection.split();
let send = raw_client.send(tokio_tungstenite::tungstenite::Message::text("text is outside the transport contract")).await;
assert!(send.is_ok());
let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
match receive {
Ok(Ok(value)) => panic!("text message produced a successful receive: {value:?}"),
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol),
Err(_) => panic!("text-message rejection did not complete within the test timeout"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn peer_drop_without_close_handshake_is_reported_as_protocol_failure() {
let (server_connection, raw_client) = establish_backend_server_with_raw_client(game_realtime_websocket_lib::WebSocketConfig::default()).await;
let (_server_sender, mut server_receiver) = server_connection.split();
drop(raw_client);
let receive = tokio::time::timeout(TEST_TIMEOUT, server_receiver.receive()).await;
match receive {
Ok(Ok(value)) => panic!("abrupt peer drop was reported as a successful receive: {value:?}"),
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Protocol),
Err(_) => panic!("abrupt peer drop was not observed within the test timeout"),
}
}
#[tokio::test(flavor = "current_thread")]
async fn silent_tcp_peer_hits_the_server_handshake_deadline() {
let config = game_realtime_websocket_lib::WebSocketConfig::default().with_connect_timeout(std::time::Duration::from_millis(50));
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).await {
Ok(value) => value,
Err(error) => panic!("bounded listener bind failed: {error}"),
};
let _silent_peer = match tokio::net::TcpStream::connect(listener.local_addr()).await {
Ok(value) => value,
Err(error) => panic!("silent TCP peer connection failed: {error}"),
};
let accept = tokio::time::timeout(TEST_TIMEOUT, listener.accept()).await;
match accept {
Ok(Ok(_connection)) => panic!("silent TCP peer unexpectedly completed a WebSocket handshake"),
Ok(Err(error)) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::Timeout),
Err(_) => panic!("server handshake timeout did not fire within the outer test timeout"),
}
}
fn small_message_config() -> game_realtime_websocket_lib::WebSocketConfig {
return game_realtime_websocket_lib::WebSocketConfig::default().with_max_message_size(SMALL_MESSAGE_LIMIT).with_max_frame_size(SMALL_MESSAGE_LIMIT);
}
async fn establish_backend_pair(
config: game_realtime_websocket_lib::WebSocketConfig,
) -> (game_realtime_websocket_lib::WebSocketConnection, game_realtime_websocket_lib::WebSocketConnection) {
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).await {
Ok(value) => value,
Err(error) => panic!("loopback listener bind failed: {error}"),
};
let endpoint = format!("ws://{}/", listener.local_addr());
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
return tokio::join!(listener.accept(), game_realtime_websocket_lib::connect_with_config(endpoint.as_str(), config));
})
.await;
return match pair {
Ok((Ok(server), Ok(client))) => (server, client),
Ok((_server, _client)) => panic!("loopback connection establishment failed"),
Err(_) => panic!("loopback connection establishment timed out"),
};
}
async fn establish_backend_server_with_raw_client(
config: game_realtime_websocket_lib::WebSocketConfig,
) -> (game_realtime_websocket_lib::WebSocketConnection, RawClient) {
let bind_address = std::net::SocketAddr::from(([127, 0, 0, 1], 0));
let listener = match game_realtime_websocket_lib::WebSocketListener::bind_with_config(bind_address, config).await {
Ok(value) => value,
Err(error) => panic!("loopback listener bind failed: {error}"),
};
let endpoint = format!("ws://{}/", listener.local_addr());
let pair = tokio::time::timeout(TEST_TIMEOUT, async {
return tokio::join!(listener.accept(), tokio_tungstenite::connect_async(endpoint.as_str()));
})
.await;
return match pair {
Ok((Ok(server), Ok((client, _response)))) => (server, client),
Ok((_server, _client)) => panic!("raw-client loopback connection establishment failed"),
Err(_) => panic!("raw-client loopback connection establishment timed out"),
};
}

View File

@@ -0,0 +1,40 @@
// file: crates/common/game-realtime-websocket-lib/unit_tests/config.rs
// version: 1
#[test]
fn default_configuration_matches_the_product_baseline() {
let config = crate::WebSocketConfig::default();
assert_eq!(config.max_message_size(), 1024 * 1024);
assert_eq!(config.max_frame_size(), 1024 * 1024);
assert_eq!(config.write_buffer_size(), 64 * 1024);
assert_eq!(config.max_write_buffer_size(), 2 * 1024 * 1024);
assert_eq!(config.connect_timeout(), std::time::Duration::from_secs(10));
assert_eq!(config.send_timeout(), std::time::Duration::from_secs(5));
assert_eq!(config.close_timeout(), std::time::Duration::from_secs(2));
assert!(config.validate().is_ok());
}
#[test]
fn invalid_message_and_write_buffer_bounds_are_rejected() {
let zero_message = crate::WebSocketConfig::default().with_max_message_size(0);
assert_invalid_configuration(zero_message);
let oversized_frame = crate::WebSocketConfig::default().with_max_frame_size(2 * 1024 * 1024);
assert_invalid_configuration(oversized_frame);
let insufficient_write_buffer = crate::WebSocketConfig::default().with_max_write_buffer_size(1024 * 1024);
assert_invalid_configuration(insufficient_write_buffer);
}
#[test]
fn zero_operation_deadlines_are_rejected() {
assert_invalid_configuration(crate::WebSocketConfig::default().with_connect_timeout(std::time::Duration::ZERO));
assert_invalid_configuration(crate::WebSocketConfig::default().with_send_timeout(std::time::Duration::ZERO));
assert_invalid_configuration(crate::WebSocketConfig::default().with_close_timeout(std::time::Duration::ZERO));
}
fn assert_invalid_configuration(config: crate::WebSocketConfig) {
let result = config.validate();
match result {
Ok(()) => panic!("invalid WebSocket configuration was accepted"),
Err(error) => assert_eq!(error.kind(), game_realtime_transport_lib::TransportErrorKind::InvalidConfiguration),
}
}

View File

@@ -0,0 +1,18 @@
// file: crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs
// version: 1
#[test]
fn tungstenite_backpressure_maps_to_transport_backpressure() {
let message = tokio_tungstenite::tungstenite::Message::Binary(vec![1, 2, 3].into());
let backend_error = tokio_tungstenite::tungstenite::Error::WriteBufferFull(Box::new(message));
let mapped = super::map_stream_error(backend_error);
assert_eq!(mapped.kind(), game_realtime_transport_lib::TransportErrorKind::Backpressure);
}
#[test]
fn tungstenite_capacity_maps_to_message_too_large() {
let capacity = tokio_tungstenite::tungstenite::error::CapacityError::MessageTooLong { size: 65, max_size: 64 };
let backend_error = tokio_tungstenite::tungstenite::Error::Capacity(capacity);
let mapped = super::map_stream_error(backend_error);
assert_eq!(mapped.kind(), game_realtime_transport_lib::TransportErrorKind::MessageTooLarge);
}

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

204
deltas/0.3.3/0-pre.1.md Normal file
View File

@@ -0,0 +1,204 @@
<!-- file: deltas/0.3.3/0-pre.1.md -->
<!-- version: 1 -->
# Delta 0.3.3-0-pre.1
## Base
Base autoritaire : archive fournie `games-v0.3.1.zip`, téléchargée depuis le lien ZIP du tag Gitea `v0.3.1`.
La version workspace de la base est `0.3.1`. L'archive taggée ne contient pas `.git`; aucun état Git absent n'est inventé et aucune autre branche/version n'est utilisée comme source.
`0.3.2` reste différée conformément au prompt de reprise.
## Objet
Ouvrir `0.3.3` par son gate obligatoire `0-pre.1` : audit complet de la baseline et des règles, audit du pipeline Android SDL3 natif historique, recherche officielle actuelle, choix ABI/API, cadrage APK/AAB, contrainte pages mémoire 16 KB, sizing, risques, validations et plan vivant.
Cette tranche n'introduit volontairement aucune nouvelle tâche Gradle productive et ne modifie pas le comportement Android. La réécriture du chemin de build commence seulement en `0-pre.2`.
## Version
La version workspace passe de :
```text
0.3.1
```
à :
```text
0.3.3-0-pre.1
```
Les versions npm/Tauri et `versionName` Android ne sont pas synchronisées dans cette tranche de cadrage. Les produits concernés n'ont pas encore reçu de changement de packaging et `VER-TAURI-003`/`VER-TAURI-004` interdisent une synchronisation npm cérémonielle.
## 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), 222 file(s))
Distribution layout audit: clean (48 required path(s), 1 forbidden path(s) absent)
```
Le ZIP contient 378 fichiers, aucun lien symbolique, aucun fichier vide et aucun `.git`, `target/`, `node_modules/` ou `gen/android/` généré.
La sortie utilisateur initiale sur son arbre local est également propre pour les trois audits, avec 230 fichiers Markdown et un `cargo check --workspace` réussi. Elle montre ensuite un build Tauri Android Debug universal réussi pour ARM64 et x86_64. Le compte Markdown local supérieur au ZIP taggé est compatible avec un `gen/android/` Tauri déjà présent localement ; le ZIP fourni reste la source autoritaire et aucun fichier généré n'est ajouté au delta.
## Audit des règles
La revue demandée par `prompts/004-V0_3_3_START_PROMPT.md` confirme notamment :
- `0-pre.1` doit contenir le cadrage et le plan avant le développement lourd ;
- une prerelease non-fix synchronise la version workspace ;
- un nouveau chemin de build `0.3.x` ne doit pas être piloté par Python ;
- Gradle doit posséder l'artefact Android final et les outputs générés doivent rester hors sources versionnées ;
- toute modification Rust/Cargo déclenche fmt, audit, check workspace et Clippy strict ;
- les builds, tests et smokes finaux sont attestés côté utilisateur ;
- les tests workspace complets restent des gates rares et planifiées ;
- aucun historique `history/0.3.3/0-pre.1.md` n'est créé avant validation effective de cette tranche.
Aucune contradiction bloquante n'est trouvée entre le prompt, les règles et la baseline `v0.3.1`. L'audit documentaire relève toutefois un paragraphe obsolète dans `Android/README.md`, resté au vocabulaire `0.1.0-0-pre.9` et affirmant que le NDK n'était pas encore utilisé ; cette tranche le réaligne sur la réalité stable `0.3.1` sans changer le build.
## Pipeline Android hérité
La baseline configure :
```text
AGP 9.4.0
Gradle 9.6.0 attendu
JDK 17
compileSdk 36
targetSdk 36
minSdk 21
NDK 28.2.13676358 / r28c
SDL3 AAR 3.4.16
```
`scripts/build_android_rust.py` réalise aujourd'hui l'extraction/linkage SDL3 par ABI, la résolution NDK, `cargo ndk`, la sélection d'une unique feature jeu, le staging dans `src/main/jniLibs` et la vérification de `libgame_android_entrypoint.so`.
Ces responsabilités doivent être transposées dans la logique Gradle commune, avec outputs sous `build/generated/...`, avant suppression du script.
## Recherche et décisions 0-pre.1
Les sources officielles actuelles confirment :
- AGP 9.4.0 : Gradle 9.6.0, JDK 17 et NDK par défaut `28.2.13676358` ;
- SDL3 Android actuel : SDK 35+, NDK r28c+ et API minimale 21 ;
- SDL3 3.4.16 : release stable actuelle de la baseline ;
- Google Play : API 36+ requise depuis le 31 août 2026 pour nouvelles apps/mises à jour téléphone/tablette ;
- application native ciblant API 35+ : compatibilité pages mémoire 16 KB à assurer ; NDK r28+ produit l'alignement 16 KB par défaut ;
- AAB : format de distribution, dont le store dérive des APK optimisés, notamment par ABI.
Les choix retenus sont donc :
```text
minSdk candidat 21
ABI par défaut arm64-v8a + x86_64
armeabi-v7a différée
APK local universal multi-ABI
AAB distribution
splits ABI non par défaut
NDK natif r28c épinglé, distinct du NDK 30 observé dans le POC Tauri
```
Le détail, les sources et les risques sont consignés dans `docs/studies/025-V0_3_3_ANDROID_NATIVE_MULTI_ABI_AUDIT.md`.
## Plan créé
`docs/plans/003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md` devient le plan actif avec le forecast révisé :
```text
0-pre.1 audit / règles / ABI-API / minSdk / APK-AAB / 16 KB / plan
0-pre.2 ownership Gradle/Cargo Snake sur arm64-v8a + wrapper Gradle
0-pre.3 arm64-v8a + x86_64 + APK universal + Reflex si factorisation naturelle
0-pre.4 AAB + minSdk/smokes + 16 KB + fermeture du script Python historique
2-beta.1 validation large + packaging final
3-rc.1 gel + reproductibilité + consolidation documentaire
0.3.3 promotion mécanique stable
```
Le scope reste compatible avec une seule version/session tant qu'aucune incompatibilité structurante de Gradle/cargo-ndk/SDL3 n'est découverte.
## Fichiers
Ajoutés :
```text
docs/plans/003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md
docs/studies/025-V0_3_3_ANDROID_NATIVE_MULTI_ABI_AUDIT.md
deltas/0.3.3/0-pre.1.md
```
Modifiés :
```text
Android/README.md
Cargo.toml
README.md
docs/000-README.md
docs/plans/000-README.md
docs/studies/000-README.md
```
Aucun fichier Gradle, Rust source, règle normative, roadmap, changelog ou historique n'est modifié dans ce cadrage. `Android/README.md` reçoit uniquement la correction documentaire de baseline décrite ci-dessus.
## Validations exécutées dans l'environnement de génération
Après constitution de l'état livré, exécuter uniquement les audits statiques autorisés au générateur :
```bash
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
```
Aucun build Cargo/Gradle, test ou smoke final n'est attribué au générateur.
## Validation utilisateur demandée
Le manifest workspace change de version ; la gate minimale est donc :
```bash
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 fmt --all
cargo fmt --all -- --check
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
```
Aucun test workspace complet n'est demandé : cette tranche ne change aucun code Rust, comportement, dépendance ou configuration Android productive.
La première gate du prompt `004` demande également l'inventaire frais qui conditionne `0-pre.2` :
```bash
rustc --version
cargo --version
cargo ndk --version
rustup target list --installed
java -version
printf 'JAVA_HOME=%s\nANDROID_HOME=%s\n' "$JAVA_HOME" "$ANDROID_HOME"
adb version
adb devices -l
emulator -list-avds
(cd Android && gradle --version)
printf 'configured NDK: '; sed -n 's/^androidNdkVersion=//p' Android/gradle.properties
ls -ld "$ANDROID_HOME/ndk/28.2.13676358"
test -f Android/libs/SDL3-3.4.16.aar && sha256sum Android/libs/SDL3-3.4.16.aar
```
La sortie déjà fournie confirme JDK 17 sélectionné via `JAVA_HOME`, les targets Tauri ARM64/x86_64 suffisamment opérationnelles pour produire un APK universal et un NDK 30 utilisé par Tauri. Elle ne remplace pas l'inventaire du pipeline Android natif ci-dessus.
## Suite après validation
Si la gate est propre, la tranche suivante crée `history/0.3.3/0-pre.1.md` avec les sorties réellement obtenues puis ouvre `0.3.3-0-pre.2`.
`0-pre.2` doit d'abord introduire le Gradle Wrapper et prouver sur Snake/ARM64 que `:game-snake-poc:assembleDebug` déclenche lui-même le build Rust/SDL3 sans `scripts/build_android_rust.py`. Une défaillance de la gate ou une erreur du cadrage produit d'abord `0-pre.1.fix.N`.

180
deltas/0.3.3/0-pre.2.md Normal file
View File

@@ -0,0 +1,180 @@
<!-- file: deltas/0.3.3/0-pre.2.md -->
<!-- version: 1 -->
# Delta 0.3.3-0-pre.2
## Base et statut précédent
Cette tranche part exclusivement de `0.3.3-0-pre.1` validé par l'utilisateur le 2026-09-21. `history/0.3.3/0-pre.1.md` enregistre la gate propre et l'inventaire Android réellement observé avant ouverture du développement productif.
Aucun fix de `0-pre.1` n'est requis.
## Objectif
Prouver sur Snake Debug et une seule ABI que Gradle possède désormais la chaîne native complète :
```text
:game-snake-poc:assembleDebug
-> source jniLibs générée par AGP Variant API
-> extraction de libSDL3.so depuis l'AAR
-> résolution du NDK projet r28c
-> cargo ndk arm64-v8a
-> feature snake uniquement
-> libSDL3.so + libgame_android_entrypoint.so
-> APK Debug ARM64
```
La commande normale ne dépend plus d'un appel préalable à `scripts/build_android_rust.py snake`.
## Gradle Wrapper reproductible
Le projet Android reçoit le wrapper Gradle et l'épingle à `9.6.0`, version attendue par AGP `9.4.0` dans le cadrage `0-pre.1`.
Contrats de chaîne d'approvisionnement :
```text
Gradle 9.6.0 binary ZIP SHA-256
bbaeb2fef8710818cf0e261201dab964c572f92b942812df0c3620d62a529a01
Gradle Wrapper JAR SHA-256
497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7
```
`validateDistributionUrl=true` reste activé. L'audit de distribution vérifie statiquement ces contrats.
Le wrapper respecte `JAVA_HOME`; l'environnement utilisateur validé expose JDK 17 via cette variable même si `java` dans le `PATH` pointe vers JDK 25.
## Ownership Gradle/Cargo
`Android/gradle/sasedev-rust-android.gradle` introduit une tâche native incrémentale par ABI. Ses inputs comprennent l'AAR SDL3, les manifests/configurations Cargo utiles, le code Rust, la feature, l'ABI, l'API Android et la version NDK. Son output est un répertoire `jniLibs` généré sous `build/generated/sasedevNative/...`.
La tâche :
1. extrait `libSDL3.so` depuis Prefab ou le fallback AAR compatible ;
2. résout `${ANDROID_HOME}/ndk/28.2.13676358` ou l'équivalent `ANDROID_SDK_ROOT` ;
3. définit `ANDROID_NDK_HOME`, `CARGO_NDK_PLATFORM=21` et le chemin de linkage SDL3 uniquement pour le sous-processus ;
4. lance `cargo ndk` sans defaults avec la seule feature Snake ;
5. vérifie les deux `.so` attendus ;
6. remet l'output à `variant.sources.jniLibs.addGeneratedSourceDirectory`, qui crée la dépendance de build AGP.
Aucun nouvel orchestrateur Python ou shell spécifique au projet n'est introduit.
## Borne mono-ABI
`game-snake-poc` fixe volontairement :
```text
feature snake
ABI arm64-v8a
API Rust 21
```
et `ndk.abiFilters 'arm64-v8a'` empêche ce jalon de masquer une erreur de task wiring derrière le packaging d'autres ABI. `x86_64` reste pour `0-pre.3` après validation réelle de cette preuve ARM64.
Reflex n'est pas modifié dans cette tranche.
## Audit de distribution
`scripts/audit_distribution_layout.py` exige désormais les cinq nouveaux éléments durables du pipeline :
```text
Android/gradlew
Android/gradlew.bat
Android/gradle/wrapper/gradle-wrapper.jar
Android/gradle/wrapper/gradle-wrapper.properties
Android/gradle/sasedev-rust-android.gradle
```
Il contrôle également le checksum du JAR wrapper, la distribution 9.6.0, l'absence de délégation Python dans le nouveau build natif et le contrat mono-ABI Snake.
## Fichiers
Ajoutés :
```text
Android/gradlew
Android/gradlew.bat
Android/gradle/sasedev-rust-android.gradle
Android/gradle/wrapper/gradle-wrapper.jar
Android/gradle/wrapper/gradle-wrapper.properties
deltas/0.3.3/0-pre.2.md
history/0.3.3/0-pre.1.md
```
Modifiés :
```text
Android/README.md
Android/game-snake-poc/build.gradle
Cargo.toml
docs/development/006-ANDROID_RUST_NATIVE_BUILD.md
scripts/audit_distribution_layout.py
```
`scripts/build_android_rust.py`, Reflex, les sources Rust, `CHANGELOG.md` et `ROADMAP.md` restent inchangés.
## Validations exécutées dans l'environnement de génération
Le générateur exécute uniquement les audits statiques autorisés après constitution du delta :
```bash
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
```
Aucun build Cargo/Gradle, test ou smoke final n'est attribué au générateur.
## Validation utilisateur demandée
Depuis la racine :
```bash
export JAVA_HOME=/usr/lib/jvm/temurin-17-jdk-amd64
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 fmt --all
cargo fmt --all -- --check
cargo check --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
sha256sum Android/gradle/wrapper/gradle-wrapper.jar
grep -E '^(distributionUrl|distributionSha256Sum|validateDistributionUrl)=' Android/gradle/wrapper/gradle-wrapper.properties
(cd Android && ./gradlew --version)
(cd Android && ./gradlew :game-snake-poc:assembleDebug)
```
Le build Snake doit être lancé directement : ne pas appeler `scripts/build_android_rust.py snake` avant Gradle.
Puis inspecter l'APK produit :
```bash
APK="$(find Android/game-snake-poc/build/outputs/apk/debug -maxdepth 1 -type f -name '*.apk' -print -quit)"
test -n "$APK"
printf 'APK=%s\n' "$APK"
unzip -Z1 "$APK" | grep '^lib/' | sort
unzip -Z1 "$APK" | grep -Fx 'lib/arm64-v8a/libSDL3.so'
unzip -Z1 "$APK" | grep -Fx 'lib/arm64-v8a/libgame_android_entrypoint.so'
if unzip -Z1 "$APK" | grep -Eq '^lib/(x86_64|armeabi-v7a|x86)/'; then
printf '%s\n' 'unexpected non-ARM64 native library in mono-ABI proof' >&2
exit 1
fi
```
Le checksum attendu du wrapper JAR est :
```text
497c8c2a7e5031f6aa847f88104aa80a93532ec32ee17bdb8d1d2f67a194a9c7
```
Aucun smoke appareil n'est requis dans cette tranche ; il reste planifié après établissement du packaging multi-ABI.
## Suite après validation
Si cette gate est propre, `0-pre.3` crée `history/0.3.3/0-pre.2.md`, étend le même ownership à `arm64-v8a + x86_64`, exige un APK Debug universal contenant les deux couples de bibliothèques et migre Reflex uniquement si la factorisation commune reste naturelle.
Une erreur de wrapper, de task wiring, de linkage SDL3, de feature Cargo ou de contenu APK produit d'abord `0-pre.2.fix.N`.

View File

@@ -0,0 +1,4 @@
Android/gradlew
Android/gradlew.bat
Android/gradle/wrapper/gradle-wrapper.jar
Android/gradle/wrapper/gradle-wrapper.properties

253
deltas/0.3.3/0-pre.3.md Normal file
View File

@@ -0,0 +1,253 @@
<!-- file: deltas/0.3.3/0-pre.3.md -->
<!-- version: 1 -->
# Delta 0.3.3-0-pre.3
## Base et statut précédent
Cette tranche part exclusivement de `0.3.3-0-pre.2`, validé par l'utilisateur le 2026-09-21. `history/0.3.3/0-pre.2.md` enregistre la gate complète, le build Gradle réussi et l'APK Snake ARM64 inspecté.
Aucun fix de `0-pre.2` n'est requis.
## Objectif
Cette tranche traite ensemble les deux décisions prévues avant l'AAB :
1. passer d'une version Gradle/JDK imposée pour la preuve à un contrat de minimum compatible avec l'environnement ;
2. élargir le pipeline Gradle/Cargo natif à `arm64-v8a + x86_64` et produire des APK Debug universal.
Snake reste le jeu-sonde principal. Reflex devient le second consommateur de la logique commune puisque sa migration ne demande qu'un mapping de feature et d'ABI, sans nouvelle architecture.
## Politique Gradle minimale
AGP `9.4.0` conserve Gradle `9.6.0` comme minimum. Le projet déclare désormais dans `Android/settings.gradle` :
```text
minimumGradleVersion = 9.6.0
```
Le script compare ce minimum à `GradleVersion.current()` et échoue explicitement si le Gradle système est trop ancien.
Le projet Android natif ne versionne plus de Gradle Wrapper. Une machine avec Gradle `9.6.0`, `9.7.1` ou une version ultérieure compatible utilise directement sa propre version. La version réellement employée est consignée par la gate avec :
```bash
(cd Android && gradle --version)
```
Cette décision concerne uniquement `Android/`. Les wrappers générés ou contraintes historiques propres au POC Tauri restent locaux à la crate Tauri et ne sont pas réinterprétés.
## Politique JDK
Aucun `export JAVA_HOME=/usr/lib/jvm/temurin-17-jdk-amd64` n'est imposé au chemin SDL3 natif.
Gradle/AGP utilisent le JDK fourni normalement par le shell ou l'IDE. Le minimum de la chaîne reste Java 17, tandis que la gate doit vérifier le JDK réellement sélectionné. Le JDK 25 installé dans l'environnement utilisateur est donc un candidat normal pour cette validation.
La glue Java Android conserve volontairement :
```text
sourceCompatibility = 17
targetCompatibility = 17
```
Le JDK de build et le niveau Java de l'application restent deux contrats distincts.
## Multi-ABI Snake
`game-snake-poc` déclare maintenant :
```text
feature snake
ABI arm64-v8a + x86_64
API Rust 21
```
et :
```text
abiFilters 'arm64-v8a', 'x86_64'
```
La logique commune `sasedev-rust-android.gradle`, déjà prouvée en mono-ABI, enregistre une tâche Rust par ABI et remet les deux répertoires `jniLibs` générés à AGP. L'APK Debug attendu est universal, sans split ABI par défaut.
## Reflex devient second consommateur
`game-reflex-poc` reçoit uniquement la configuration symétrique :
```text
feature reflex
ABI arm64-v8a + x86_64
API Rust 21
```
puis applique la même logique Gradle commune. Aucun nouveau script, type de tâche, bridge JNI ou chemin d'output n'est introduit.
Cette symétrie confirme que la factorisation appartient bien à `Android/gradle/sasedev-rust-android.gradle` et non aux modules de jeu.
## Suppression du wrapper natif
Un ZIP delta n'efface pas les fichiers d'un jalon précédent. Le manifest :
```text
deltas/0.3.3/0-pre.3.delete.txt
```
supprime :
```text
Android/gradlew
Android/gradlew.bat
Android/gradle/wrapper/gradle-wrapper.jar
Android/gradle/wrapper/gradle-wrapper.properties
```
Après extraction du delta à la racine du dépôt, appliquer :
```bash
while IFS= read -r path; do
rm -f -- "$path"
done < deltas/0.3.3/0-pre.3.delete.txt
rmdir --ignore-fail-on-non-empty Android/gradle/wrapper
```
L'audit de distribution traite désormais ces quatre fichiers comme interdits dans le projet Android natif afin d'éviter un retour involontaire à une version Gradle épinglée.
## Règles et documentation
Les règles de commandes Android utilisent désormais `gradle` depuis `Android/` et exigent le minimum déclaré par le projet. `CMD-051` devient :
```text
(cd Android && gradle :<app>:assembleDebug)
```
Le plan `0.3.3`, la documentation Android et le document de build natif distinguent explicitement :
- Gradle système `>= 9.6.0` ;
- JDK runtime fourni par l'environnement ;
- Java source/target 17 ;
- contraintes Tauri historiques séparées ;
- deux ABI natives productives de cette tranche.
## Fichiers
Ajoutés :
```text
deltas/0.3.3/0-pre.3.md
deltas/0.3.3/0-pre.3.delete.txt
history/0.3.3/0-pre.2.md
```
Modifiés :
```text
Android/README.md
Android/game-reflex-poc/build.gradle
Android/game-snake-poc/build.gradle
Android/settings.gradle
Cargo.toml
docs/development/006-ANDROID_RUST_NATIVE_BUILD.md
docs/plans/003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md
docs/rules/RULES_COMMANDS.md
docs/rules/RULES_VALIDATION_MATRIX.md
scripts/audit_distribution_layout.py
```
Supprimés par manifest :
```text
Android/gradlew
Android/gradlew.bat
Android/gradle/wrapper/gradle-wrapper.jar
Android/gradle/wrapper/gradle-wrapper.properties
```
`scripts/build_android_rust.py`, le code Rust, les manifests Android, `CHANGELOG.md` et `ROADMAP.md` restent inchangés.
## Validations exécutées dans l'environnement de génération
Le générateur exécute uniquement les audits statiques autorisés après constitution du delta :
```bash
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
```
Aucun build Cargo/Gradle, test ou smoke final n'est attribué au générateur.
## Validation utilisateur demandée
Exécuter la gate depuis un shell normal, sans ajouter l'ancien `export JAVA_HOME=/usr/lib/jvm/temurin-17-jdk-amd64` :
```bash
java -version
printf 'JAVA_HOME=%s\nANDROID_HOME=%s\n' "$JAVA_HOME" "$ANDROID_HOME"
(cd Android && gradle --version)
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 Android && gradle :game-snake-poc:assembleDebug)
(cd Android && gradle :game-reflex-poc:assembleDebug)
```
La sortie `gradle --version` doit montrer une version `>= 9.6.0` et le JDK effectivement fourni par votre environnement. Une version supérieure au minimum n'est pas une divergence.
## Inspection des APK universal
Pour chaque jeu :
```bash
for game in game-snake-poc game-reflex-poc; do
APK="$(find "Android/${game}/build/outputs/apk/debug" -maxdepth 1 -type f -name '*.apk' -print -quit)"
test -n "$APK"
printf 'APK=%s\n' "$APK"
unzip -Z1 "$APK" | grep '^lib/' | sort
for abi in arm64-v8a x86_64; do
unzip -Z1 "$APK" | grep -Fx "lib/${abi}/libSDL3.so"
unzip -Z1 "$APK" | grep -Fx "lib/${abi}/libgame_android_entrypoint.so"
done
if unzip -Z1 "$APK" | grep -Eq '^lib/(armeabi-v7a|x86)/'; then
printf '%s\n' "unexpected 32-bit native library in ${game} universal APK" >&2
exit 1
fi
done
```
L'absence de split est démontrée par un seul APK Debug par module contenant simultanément les bibliothèques ARM64 et x86_64.
## Smoke multi-architecture Snake
Après inspection mécanique, installer le même APK Snake sur l'AVD x86_64 puis sur l'appareil ARM64 réel.
AVD connu :
```bash
APK="Android/game-snake-poc/build/outputs/apk/debug/game-snake-poc-debug.apk"
adb -s emulator-5554 install -r "$APK"
adb -s emulator-5554 shell am start -n com.sasedev.games.snake/.SnakeActivity
```
Puis sur l'appareil réel en remplaçant `<arm64-serial>` par le serial affiché par `adb devices -l` :
```bash
adb -s <arm64-serial> install -r "$APK"
adb -s <arm64-serial> shell am start -n com.sasedev.games.snake/.SnakeActivity
```
Le smoke attendu est borné : lancement sans erreur de chargement JNI/SDL3 et affichage du jeu. La validation gameplay exhaustive reste hors de ce jalon.
## Suite après validation
Si cette gate est propre, `0-pre.4` ouvre le chemin AAB, la vérification 16 KB, la validation du plancher Android disponible et la fermeture de `scripts/build_android_rust.py` si aucune responsabilité n'y subsiste.
Toute erreur de minimum Gradle, exécution JDK, task wiring x86_64, linkage SDL3, packaging universal ou activation Reflex produit d'abord `0-pre.3.fix.1`.

92
deltas/0.3.3/0-pre.4.md Normal file
View File

@@ -0,0 +1,92 @@
<!-- file: deltas/0.3.3/0-pre.4.md -->
<!-- version: 1 -->
# Delta 0.3.3-0-pre.4
## Objet
Étendre le pipeline Android SDL3 natif validé en `0-pre.3` à toutes les ABI encore supportées par la toolchain moderne, sans mélanger cette preuve avec l'AAB/16 KB.
Matrice cible obligatoire :
```text
arm64-v8a
armeabi-v7a
x86_64
x86
```
Les ABI historiques `armeabi`, `mips` et `mips64` restent hors contrat car elles ont été retirées des toolchains Android modernes.
## Base et historique
Base : `0.3.3-0-pre.3`, validée par l'utilisateur le 2026-09-21 avec Temurin 25.0.4.1, Gradle 9.7.1, audits/Cargo/Clippy propres, APK universal ARM64+x86_64 pour Snake et Reflex, puis smoke Snake réussi sur AVD x86_64 et Samsung ARM64 réel.
`history/0.3.3/0-pre.3.md` consigne cette preuve avant les changements du présent delta.
## Changements
- version workspace portée à `0.3.3-0-pre.4` ;
- Snake et Reflex déclarent maintenant les quatre ABI dans `sasedevRustAndroidAbis` et `ndk.abiFilters` ;
- l'audit de distribution exige cette matrice complète pour les deux consommateurs ;
- documentation Android/build mise à jour pour distinguer couverture de packaging et disponibilité de smokes matériels ;
- plan `0.3.3` redécoupé : `0-pre.4` est consacré aux quatre ABI, `0-pre.5` prendra l'AAB, 16 KB, minSdk et la fermeture du script Python historique ;
- l'étude de cadrage conserve sa décision initiale mais ajoute explicitement la révision de scope décidée après `0-pre.3`.
Aucune modification n'est nécessaire dans `Android/gradle/sasedev-rust-android.gradle` : cette logique possédait déjà le mapping des quatre ABI et génère une tâche par ABI configurée.
## Validation attendue
Depuis la racine, sans imposer `JAVA_HOME` :
```bash
java -version
(cd Android && gradle --version)
rustup target list --installed
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 Android && gradle :game-snake-poc:assembleDebug)
(cd Android && gradle :game-reflex-poc:assembleDebug)
```
Les targets Rust nécessaires sont :
```text
aarch64-linux-android
armv7-linux-androideabi
x86_64-linux-android
i686-linux-android
```
Pour chaque APK :
```bash
for game in game-snake-poc game-reflex-poc; do
APK="$(find "Android/${game}/build/outputs/apk/debug" \
-maxdepth 1 -type f -name '*.apk' -print -quit)"
test -n "$APK"
printf 'APK=%s\n' "$APK"
unzip -Z1 "$APK" | grep '^lib/' | sort
for abi in arm64-v8a armeabi-v7a x86_64 x86; do
unzip -Z1 "$APK" | grep -Fx "lib/${abi}/libSDL3.so"
unzip -Z1 "$APK" | grep -Fx "lib/${abi}/libgame_android_entrypoint.so"
done
done
```
La gate échoue si une des huit entrées natives par application manque. Aucun smoke 32 bits n'est revendiqué sans appareil/AVD 32 bits disponible. Les smokes déjà validés ARM64/x86_64 ne sont pas invalidés par l'ajout de la matrice 32 bits.
## Suite
Si les deux builds et l'inspection des quatre ABI sont propres, ouvrir `0.3.3-0-pre.5` pour le chemin release/AAB, le contrôle 16 KB, la consolidation minSdk et la suppression de `scripts/build_android_rust.py` lorsque toutes ses responsabilités sont effectivement remplacées.
Toute défaillance spécifique à cette tranche produit `0.3.3-0-pre.4.fix.N` avant `0-pre.5`.

View File

@@ -0,0 +1 @@
scripts/build_android_rust.py

126
deltas/0.3.3/0-pre.5.md Normal file
View File

@@ -0,0 +1,126 @@
<!-- file: deltas/0.3.3/0-pre.5.md -->
<!-- version: 1 -->
# Delta 0.3.3-0-pre.5
## Objet
Fermer le chemin Android natif historique après validation des quatre ABI : étendre l'ownership Gradle aux variantes Release/AAB, consolider `minSdk 21`, préparer la vérification 16 KB et supprimer `scripts/build_android_rust.py`.
## Base
Base : `0.3.3-0-pre.4`, validée par l'utilisateur le 2026-09-21 avec Temurin 25.0.4.1, Gradle 9.7.1, audits/Cargo/Clippy propres et builds Debug Snake/Reflex réussis sur `arm64-v8a`, `armeabi-v7a`, `x86_64` et `x86`.
`history/0.3.3/0-pre.4.md` consigne cette preuve.
## Changements
- version workspace portée à `0.3.3-0-pre.5` ;
- `Android/gradle/sasedev-rust-android.gradle` enregistre désormais ses sources `jniLibs` pour toutes les variantes, pas seulement Debug ;
- les variantes Release déclenchent `cargo ndk ... build --release`, tandis que Debug conserve le profil Cargo `dev` ;
- `bundleRelease` de Snake et Reflex devient donc propriétaire de la reconstruction native quatre ABI ;
- `minSdk 21` et `CARGO_NDK_PLATFORM=21` deviennent des invariants audités pour les deux applications ;
- le builder historique `scripts/build_android_rust.py` est supprimé via manifest ;
- l'audit de distribution interdit désormais le retour de ce script et de `src/main/jniLibs` générés ;
- les anciennes commandes RC dépendant du builder Python sont remplacées par les tâches Gradle natives ;
- documentation Android/build/plan/étude actualisée pour AAB, API 21 et contrôle 16 KB.
Aucun secret de signature ni keystore n'est ajouté.
## Référence 16 KB
Le contrôle suit le chemin recommandé par Android : AGP `>= 8.5.1`, NDK `r28+`, vérification ELF par `llvm-objdump`, puis `zipalign -P 16` sur l'APK. La SDL3 de l'AAR est précompilée et doit donc être contrôlée explicitement avec la bibliothèque Rust.
## Validation attendue
Depuis la racine :
```bash
java -version
(cd Android && gradle --version)
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 Android && gradle :game-snake-poc:bundleRelease)
(cd Android && gradle :game-reflex-poc:bundleRelease)
```
Les AAB attendus sont sous `Android/<game>/build/outputs/bundle/release/`. Pour chaque bundle :
```bash
for game in game-snake-poc game-reflex-poc; do
AAB="$(find "Android/${game}/build/outputs/bundle/release" \
-maxdepth 1 -type f -name '*.aab' -print -quit)"
test -n "$AAB"
printf 'AAB=%s\n' "$AAB"
unzip -Z1 "$AAB" | grep '^base/lib/' | sort
for abi in arm64-v8a armeabi-v7a x86_64 x86; do
unzip -Z1 "$AAB" | grep -Fx "base/lib/${abi}/libSDL3.so"
unzip -Z1 "$AAB" | grep -Fx "base/lib/${abi}/libgame_android_entrypoint.so"
done
done
```
Revalider ensuite l'APK Debug et son alignement ZIP 16 KB avec le `zipalign` des Build Tools installés :
```bash
ZIPALIGN="$(find "$ANDROID_HOME/build-tools" -mindepth 2 -maxdepth 2 -type f -name zipalign -print | sort -V | tail -n 1)"
test -x "$ZIPALIGN"
for game in game-snake-poc game-reflex-poc; do
(cd Android && gradle ":${game}:assembleDebug")
APK="$(find "Android/${game}/build/outputs/apk/debug" -maxdepth 1 -type f -name '*.apk' -print -quit)"
"$ZIPALIGN" -c -P 16 -v 4 "$APK"
done
```
Contrôler les segments ELF des bibliothèques générées et de SDL3. Sur Linux avec le NDK configuré :
```bash
OBJDUMP="$ANDROID_HOME/ndk/28.2.13676358/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objdump"
test -x "$OBJDUMP"
for game in game-snake-poc game-reflex-poc; do
for variant in debug release; do
for abi in arm64-v8a armeabi-v7a x86_64 x86; do
for so in \
"Android/${game}/build/generated/sasedevNative/${variant}/${abi}/jniLibs/${abi}/libSDL3.so" \
"Android/${game}/build/generated/sasedevNative/${variant}/${abi}/jniLibs/${abi}/libgame_android_entrypoint.so"; do
test -f "$so"
printf '%s\n' "$so"
"$OBJDUMP" -p "$so" | grep 'LOAD'
done
done
done
done
```
Aucun `LOAD` ne doit annoncer un alignement inférieur à `2**14`.
Si `bundletool` est installé dans l'environnement :
```bash
bundletool dump config --bundle="Android/game-snake-poc/build/outputs/bundle/release/game-snake-poc-release.aab" | grep alignment
bundletool dump config --bundle="Android/game-reflex-poc/build/outputs/bundle/release/game-reflex-poc-release.aab" | grep alignment
```
La valeur attendue est `PAGE_ALIGNMENT_16K`. Son absence locale parce que `bundletool` n'est pas installé doit être consignée ; elle ne doit pas être remplacée par une affirmation fictive.
Pour le plancher Android, relever les images disponibles :
```bash
emulator -list-avds
sdkmanager --list_installed | grep 'system-images;android-' || true
```
Si une image API 21 exploitable est déjà disponible ou raisonnablement installable, effectuer un smoke Snake. Sinon, consigner explicitement cette lacune ; le contrat reste API 21 car SDL3 lui-même documente ce niveau comme minimum et le build Rust/manifest l'imposent déjà.
## Suite
Si les AAB quatre ABI et les contrôles 16 KB sont propres, la prochaine tranche est `0.3.3-2-beta.1` conformément au plan révisé. Une défaillance du chemin Release/AAB, de l'alignement ou de la fermeture Python produit d'abord `0.3.3-0-pre.5.fix.N`.

139
deltas/0.3.3/2-beta.1.md Normal file
View File

@@ -0,0 +1,139 @@
<!-- file: deltas/0.3.3/2-beta.1.md -->
<!-- version: 1 -->
# Delta 0.3.3-2-beta.1
## Objectif
Entrer en beta avec le pipeline Android SDL3 natif feature-complete : quatre ABI, APK universal, AAB Release, API 21 prouvée, compatibilité 16 KB 64 bits prouvée et aucun orchestrateur Python de build.
Cette tranche n'ajoute aucune fonctionnalité. Elle synchronise la version technique, consolide les preuves `0-pre.5` et exécute la validation large prévue avant RC.
## Baseline acceptée
`0.3.3-0-pre.5` est validée par l'utilisateur le 2026-09-21.
La baseline acceptée comprend :
- audits Rust/Markdown propres ;
- audit de distribution propre après suppression de deux anciens `src/main/jniLibs` locaux ;
- `cargo check --workspace` et Clippy workspace strict propres ;
- `bundleRelease` réussi pour Snake et Reflex ;
- AAB quatre ABI pour les deux jeux ;
- `zipalign -P 16` réussi sur les APK Debug ;
- ELF `arm64-v8a` et `x86_64` alignés `2**14` pour SDL3 et la bibliothèque Rust ;
- smoke Snake réussi sur Android 5.0/API 21 x86 ;
- smoke Snake réussi sur Android 15/API 35 x86_64 `ps16k` avec `getconf PAGE_SIZE=16384` ;
- suppression du builder `scripts/build_android_rust.py`.
`history/0.3.3/0-pre.5.md` consigne le détail.
## Changements
- passage de `workspace.package.version` à `0.3.3-2-beta.1` ;
- ajout de l'historique validé `0-pre.5` ;
- correction documentaire du périmètre 16 KB : exigence Play portée sur les appareils 64 bits, sans imposer artificiellement `2**14` aux ABI 32 bits ;
- consolidation du plan actif avec les preuves API 21 et `ps16k` réellement obtenues ;
- aucune modification de gameplay, moteur, JNI, Java, logique Gradle, ABI, API Android, dépendance ou asset.
`CHANGELOG.md` et `ROADMAP.md` restent inchangés : la synthèse publiée est réservée à la RC/stable et le scope macro `0.3.3` n'est pas encore clos.
## Validation beta — statique et workspace
Depuis la racine :
```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
```
La suite workspace complète est volontairement exécutée ici : `2-beta.1` constitue la gate large planifiée de la version.
## Rebuild Android beta
Nettoyer les outputs Gradle des applications, puis reconstruire les deux formes d'artefact :
```bash
(cd Android && gradle :game-snake-poc:clean :game-reflex-poc:clean)
(cd Android && gradle :game-snake-poc:assembleDebug)
(cd Android && gradle :game-reflex-poc:assembleDebug)
(cd Android && gradle :game-snake-poc:bundleRelease)
(cd Android && gradle :game-reflex-poc:bundleRelease)
```
Pour chaque APK Debug et AAB Release, vérifier les quatre ABI :
```bash
for game in game-snake-poc game-reflex-poc; do
APK="Android/${game}/build/outputs/apk/debug/${game}-debug.apk"
AAB="Android/${game}/build/outputs/bundle/release/${game}-release.aab"
test -f "$APK"
test -f "$AAB"
for abi in arm64-v8a armeabi-v7a x86_64 x86; do
unzip -Z1 "$APK" | grep -Fx "lib/${abi}/libSDL3.so"
unzip -Z1 "$APK" | grep -Fx "lib/${abi}/libgame_android_entrypoint.so"
unzip -Z1 "$AAB" | grep -Fx "base/lib/${abi}/libSDL3.so"
unzip -Z1 "$AAB" | grep -Fx "base/lib/${abi}/libgame_android_entrypoint.so"
done
done
```
Revalider le packaging 16 KB :
```bash
ZIPALIGN="$(find "$ANDROID_HOME/build-tools" -mindepth 2 -maxdepth 2 -type f -name zipalign -print | sort -V | tail -n 1)"
test -x "$ZIPALIGN"
"$ZIPALIGN" -c -P 16 -v 4 Android/game-snake-poc/build/outputs/apk/debug/game-snake-poc-debug.apk
"$ZIPALIGN" -c -P 16 -v 4 Android/game-reflex-poc/build/outputs/apk/debug/game-reflex-poc-debug.apk
```
Les preuves ELF 64 bits et les smokes de frontière API 21/16 KB viennent d'être établis en `0-pre.5`; ils ne sont pas rejoués mécaniquement dans cette beta tant qu'aucune logique native/Gradle n'a changé.
## Smokes beta de référence
Revalider **Snake et Reflex** sur :
1. l'AVD x86_64/API 36 de référence ;
2. l'appareil ARM64 réel de référence.
Pour chaque jeu et chaque device :
```bash
adb -s <serial> install -r <apk>
adb -s <serial> shell am start -n <package>/<activity>
```
Vérifier au minimum :
- démarrage ;
- rendu SDL3 ;
- contrôles/tactile ;
- assets ;
- absence de crash/panic immédiat ;
- Back système et sortie propre.
Les packages/activities actuels sont :
```text
Snake : com.sasedev.games.snake/.SnakeActivity
Reflex : com.sasedev.games.reflex/.ReflexActivity
```
## Transition
Si la gate workspace, les rebuilds quatre ABI, le packaging et les quatre smokes de référence sont propres, ne pas créer de beta supplémentaire : ouvrir directement `0.3.3-3-rc.1`.
Toute régression imputable au projet produit d'abord `0.3.3-2-beta.1.fix.N`.

View File

@@ -0,0 +1,82 @@
<!-- file: deltas/0.3.3/3-rc.1.fix.1.md -->
<!-- version: 1 -->
# Delta 0.3.3-3-rc.1.fix.1
## Base requise
`0.3.3-3-rc.1`.
## Nature du correctif
Correctif strictement documentaire de la candidate RC. Il ne modifie ni le runtime, ni le pipeline Android, ni Cargo, ni la version technique du workspace.
Conformément à `VER-DOCFIX-001`, `workspace.package.version` reste donc :
```text
0.3.3-3-rc.1
```
L'identité `0.3.3-3-rc.1.fix.1` est portée par le présent delta et son archive.
## Défaut corrigé
Le prompt de reprise `prompts/005-V0_3_4_START_PROMPT.md` était fonctionnel mais trop pauvre comme transmission autonome de session, particulièrement dans son forecast initial :
- chaque tranche n'expliquait pas suffisamment son intention ;
- les livrables probables et critères de sortie n'étaient pas explicités ;
- les points naturels de fusion/scission n'étaient pas indiqués ;
- le rôle conditionnel d'une tranche de robustesse ou d'un demo n'était pas assez clair ;
- la séparation entre forecast initial et plan actif produit par `alpha.1` pouvait être mieux verrouillée ;
- la progression vers beta, RC et stable manquait de critères opératoires.
## Changements
`prompts/005-V0_3_4_START_PROMPT.md` passe en version documentaire 2 et développe son forecast non contraignant avec :
- règles explicites de lecture et de révision du forecast ;
- `alpha.1` détaillée pour la migration de gouvernance, l'audit, le sizing et le contrat transport ;
- `alpha.2` pour l'API async minimale ;
- `alpha.3` pour le backend WebSocket `tokio-tungstenite` et le loopback ;
- `alpha.4` conditionnelle pour robustesse, limites, backpressure et lifecycle ;
- `alpha.5` conditionnelle pour une preuve consommateur/demo uniquement si elle apporte une valeur réelle ;
- une tranche de consolidation de développement explicitement fusionnable ;
- critères de beta large, RC gelée et promotion stable mécanique ;
- branches explicites de redécoupage lorsque les résultats d'`alpha.1` invalident le découpage initial.
Le forecast reste volontairement prévisionnel : `0.3.4-alpha.1` doit le réviser dans `docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md`, qui devient ensuite l'autorité prévisionnelle active.
## Immutabilité historique
Ce fix n'applique toujours pas la nouvelle convention aux versions déjà livrées. Aucun fichier historique `deltas/`, `history/`, ancien prompt ou ancienne entrée de changelog n'est renommé ou réécrit pour harmoniser sa nomenclature.
La migration effective des règles prospectives vers :
```text
X.Y.Z-alpha.N
X.Y.Z-alpha.N.fix.M
X.Y.Z-beta.N
X.Y.Z-beta.N.fix.M
X.Y.Z-rc.N
X.Y.Z-rc.N.fix.M
X.Y.Z
```
reste la première action de la prochaine session `0.3.4`.
## État RC connu avant ce fix
La validation utilisateur de `0.3.3-3-rc.1` a déjà confirmé les audits, Cargo check/Clippy/tests workspace, rebuild Android Debug/Release quatre ABI, présence des quatre ABI dans APK/AAB et `zipalign -P 16` sur les deux APK.
Le présent correctif documentaire n'attribue pas de validation future à la RC et ne crée pas `history/0.3.3/3-rc.1.md` avant acceptation complète du jalon.
## Validation du fix
Comme seuls des fichiers Markdown sont ajoutés/modifiés, exécuter :
```bash
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
```
Une fois ce contenu accepté, reprendre la matrice RC là où elle s'était arrêtée. Le correctif ne nécessite aucun rebuild Cargo/Gradle supplémentaire par lui-même.

66
deltas/0.3.3/3-rc.1.md Normal file
View File

@@ -0,0 +1,66 @@
<!-- file: deltas/0.3.3/3-rc.1.md -->
<!-- version: 1 -->
# Delta 0.3.3-3-rc.1
## Base requise
`0.3.3-2-beta.1`, validée le 2026-09-21.
## Objectif
Créer la candidate de publication `0.3.3` sans rouvrir le scope du pipeline Android SDL3 natif multi-ABI.
## Gel RC
Le scope `0.3.3` est gelé. Cette tranche n'ajoute :
- aucune fonctionnalité ;
- aucune ABI ;
- aucune API Android ;
- aucune dépendance ;
- aucune logique Gradle/JNI/Java/Rust ;
- aucun changement de gameplay ou moteur ;
- aucune nouvelle politique de packaging.
Seuls les correctifs autorisés par `VER-RC-*` peuvent suivre sous `3-rc.1.fix.N`.
## Validation beta acquise
`2-beta.1` a validé :
- audits, formatage, check, Clippy et test workspace complet avec 35 tests réussis ;
- rebuild propre Snake/Reflex en Debug et Release ;
- quatre ABI `arm64-v8a`, `armeabi-v7a`, `x86_64`, `x86` dans APK et AAB ;
- `zipalign -P 16` sur les deux APK ;
- smokes Snake et Reflex sur AVD API 36 x86_64 avec processus vivant et aucun crash remonté ;
- smokes Snake et Reflex sur Samsung API 29 ARM64 avec processus vivant et aucun crash remonté.
Les preuves de frontière API 21/x86 et API 35/x86_64 `ps16k` avaient déjà été acquises en `0-pre.5`.
## Changements
- passage de `workspace.package.version` à `0.3.3-3-rc.1` ;
- entrée RC dans `CHANGELOG.md` ;
- ajout de `history/0.3.3/2-beta.1.md` ;
- ajout de `docs/testing/006-V0_3_3_RC_VALIDATION_MATRIX.md` et de son index ;
- consolidation du plan `0.3.3` avec la gate beta réellement obtenue ;
- création de `prompts/005-V0_3_4_START_PROMPT.md`.
La nouvelle nomenclature de versions approuvée par l'utilisateur n'est **pas** appliquée dans cette session : `0.3.3` termine sous l'ancien schéma. Le prompt `0.3.4` prépare la migration des règles actives vers `alpha.M / beta.M / rc.M` dès la prochaine session, avec interdiction de renommer ou réécrire les anciens `deltas/`, `history/`, changelog ou prompts historiques.
`ROADMAP.md` reste inchangé : le statut macro de `0.3.3` ne devient terminé qu'à la stable.
## Validation RC
Appliquer intégralement :
```text
docs/testing/006-V0_3_3_RC_VALIDATION_MATRIX.md
```
La gate revalide workspace, APK/AAB quatre ABI, packaging 16 KB 64 bits et les smokes de publication x86_64 + ARM64. Elle exige aussi une observation humaine finale du rendu et des contrôles Snake/Reflex.
## Après validation
Si la RC est propre, la promotion vers `0.3.3` est mécanique : version stable, entrée stable du changelog, historique RC, clôture du plan et delta de release. Aucun nouveau comportement ne doit être introduit.

59
deltas/0.3.3/rel.001.md Normal file
View File

@@ -0,0 +1,59 @@
<!-- file: deltas/0.3.3/rel.001.md -->
<!-- version: 1 -->
# Delta 0.3.3 — release stable
## Base
Base fonctionnelle validée : `0.3.3-3-rc.1`.
Le delta documentaire `0.3.3-3-rc.1.fix.1` est également appliqué et validé ; il n'a pas modifié la version Cargo ni le runtime.
## Objet
Promouvoir mécaniquement la candidate validée vers `0.3.3` sans introduire de nouveau comportement.
## Changements
La release stable :
- passe `workspace.package.version` de `0.3.3-3-rc.1` à `0.3.3` ;
- positionne `README.md` sur `0.3.3` comme stable de référence et `0.3.4-alpha.1` comme prochaine version planifiée ;
- marque `0.3.3` terminée dans `ROADMAP.md` ;
- ajoute l'entrée stable `0.3.3` dans `CHANGELOG.md` ;
- enregistre la validation effective de la RC dans `history/0.3.3/3-rc.1.md` ;
- clôt `docs/plans/003-V0_3_3_ANDROID_NATIVE_MULTI_ABI_PLAN.md` et son entrée d'index ;
- conserve `prompts/005-V0_3_4_START_PROMPT.md` comme transmission de la prochaine session.
## Frontière de release
Aucun changement de gameplay, code Rust, dépendance, Gradle, Android, JNI, Java, ABI, `minSdk`, NDK, SDL3, asset, protocole ou architecture n'est introduit après la RC.
La nouvelle convention de prerelease n'est pas rétroactivement appliquée à `0.3.3`. Elle commencera avec `0.3.4-alpha.1`, qui mettra à jour les règles actives sans renommer ni réécrire l'historique existant.
## Validation proportionnelle
La gate RC complète a déjà été validée sur un état fonctionnellement identique. La promotion stable demande uniquement les contrôles proportionnels aux changements de version/documentation :
```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
```
Les rebuilds Android, Clippy strict, suite workspace complète, contrôles APK/AAB/ELF et smokes runtime ne sont pas répétés puisque la promotion stable ne modifie aucun comportement ni pipeline.
## Publication
Après validation de ce delta :
- commit de release ;
- tag stable unique `v0.3.3` ;
- archive/tag de dépôt utilisée comme base de la prochaine session ;
- démarrage de `0.3.4` uniquement depuis `v0.3.3` avec `prompts/005-V0_3_4_START_PROMPT.md` ;
- première tranche suivante : migration de gouvernance puis `0.3.4-alpha.1` ;
- aucun ancien `deltas/`, `history/`, prompt ou changelog n'est renommé pour harmoniser sa nomenclature.

246
deltas/0.3.4/alpha.1.md Normal file
View File

@@ -0,0 +1,246 @@
<!-- file: deltas/0.3.4/alpha.1.md -->
<!-- version: 1 -->
# Delta 0.3.4-alpha.1
## Base
Base autoritaire : archive fournie `games-v0.3.3.zip`, téléchargée depuis le lien ZIP du tag Gitea `v0.3.3`.
La version workspace de la base est `0.3.3`. L'archive taggée est utilisée telle quelle conformément à `CMD-GIT-003` et `CMD-GIT-004`; aucun fichier local absent du ZIP n'est inventé ou réinjecté.
`0.3.2` reste différée conformément à la roadmap.
## Objet
Ouvrir `0.3.4` par le nouveau gate obligatoire `alpha.1` : migrer la nomenclature prospective de version, auditer complètement la stable et les règles, réévaluer les études réseau, vérifier les dépendances amont envisagées, décider l'ownership physique du transport realtime, fermer le contrat minimal WebSocket et créer le plan vivant jusqu'à stable.
Cette tranche n'ajoute volontairement aucune crate réseau, aucune dépendance Tokio/WebSocket et aucun comportement runtime. L'implémentation commence seulement après validation de ce cadrage.
## Migration de nomenclature
À partir de `0.3.4`, les nouvelles prereleases suivent exclusivement :
```text
X.Y.Z-alpha.N
X.Y.Z-alpha.N.fix.M
X.Y.Z-beta.N
X.Y.Z-beta.N.fix.M
X.Y.Z-rc.N
X.Y.Z-rc.N.fix.M
X.Y.Z
```
`alpha.1` remplace le rôle historique de `0-pre.1`.
La migration réconcilie les règles prospectives et les README/index actifs concernés. Les anciens deltas, historiques, prompts et entrées de changelog restent inchangés avec leurs identifiants `0-pre`, `1-alpha`, `2-beta` et `3-rc`.
L'audit workspace sépare désormais explicitement :
- la convention courante, exigée pour le workspace et les versions explicites des crates ;
- la convention historique, acceptée uniquement pour les répertoires de versions déjà livrés avant `0.3.4`.
Ainsi un nouveau jalon `0.3.4-0-pre.*` est refusé sans casser la lecture de l'historique existant.
## Version
La version workspace passe de :
```text
0.3.3
```
à :
```text
0.3.4-alpha.1
```
Aucune version npm/Tauri/Android n'est synchronisée : cette tranche ne change aucun produit packagé.
## 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), 242 file(s))
Distribution layout audit: clean (49 required path(s), 8 forbidden path(s) absent)
```
L'inventaire indépendant du ZIP confirme :
```text
400 fichiers
246 fichiers Markdown
55 fichiers Rust
15 Cargo.toml, dont le manifest racine
14 membres workspace
workspace.package.version = 0.3.3
0 symlink
0 erreur de chemin/archive détectée
```
Aucune arborescence générée `target/`, `node_modules/`, `gen/android/` ou `build/` n'est livrée dans l'archive.
Le log utilisateur fourni à l'ouverture de session confirme également sur son checkout `0.3.3` : format Cargo propre, audits propres, `cargo check --workspace` et Clippy workspace strict réussis. Son audit Markdown annonce `258` fichiers, soit davantage que le ZIP taggé fourni. Cette différence n'est pas masquée : le delta est construit exclusivement depuis l'archive autoritaire reçue.
## Audit des règles
La lecture intégrale demandée par `prompts/005-V0_3_4_START_PROMPT.md` confirme notamment :
- `alpha.1` porte le cadrage, le sizing, les risques, les validations et le plan avant développement lourd ;
- les archives taggées peuvent être utilisées sans `.git` et constituent la baseline de travail déclarée ;
- les historiques validés sont immuables ;
- le transport doit rester séparé du wire codec, de la session, de la synchronisation et de la simulation authoritative ;
- aucune dépendance réseau concrète ne doit remonter dans le gameplay ;
- les builds, tests et smokes finaux sont attestés côté utilisateur ;
- une modification Cargo impose format/check/Clippy workspace côté utilisateur ;
- `CHANGELOG.md` reste silencieux avant RC et `ROADMAP.md` reste macroscopique.
La recherche prospective a trouvé des références à l'ancienne nomenclature au-delà des six fichiers minimum du prompt. `RULES_VALIDATION_MATRIX.md`, `docs/plans/000-README.md`, `history/000-README.md` et le `README.md` racine sont donc également réconciliés lorsqu'ils décrivent le workflow courant. Les références historiques restent intactes.
Aucune contradiction bloquante n'est trouvée entre le prompt, les règles, la roadmap, les études réseau et la baseline `v0.3.3`.
## Vérification des dépendances envisagées
État amont vérifié le 2026-09-21 pour préparer les tranches d'implémentation :
```text
Tokio 1.53.1
futures-util 0.3.34
tokio-tungstenite 0.30.0
tungstenite 0.30.0
```
Constats retenus :
- Tokio `1.53.1` annonce un MSRV `1.71` ;
- `tokio-tungstenite 0.30.0` et `tungstenite 0.30.0` annoncent un MSRV `1.85` ;
- `tokio-tungstenite` fournit `connect`/`handshake` par défaut mais pas de backend TLS obligatoire ;
- les features `native-tls` et `rustls-*` restent optionnelles ;
- Tungstenite expose déjà des limites configurables de message, frame et write buffer.
Aucune de ces dépendances n'est ajoutée dans `alpha.1`. Le plan les introduira uniquement dans la crate backend qui les consomme.
## Ownership et contrat retenus
Le plan `docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md` retient deux frontières physiques :
```text
crates/common/game-realtime-transport-lib
crates/common/game-realtime-websocket-lib
```
`game-realtime-transport-lib` portera uniquement le contrat transport-neutral : payload binaire opaque, send/receive/close, fermeture distante, erreurs transport-neutral et split concurrent. Elle ne dépendra ni de Tokio ni de Tungstenite.
`game-realtime-websocket-lib` portera l'établissement client/server, Tokio, `tokio-tungstenite`, le mapping des frames, les limites, timeouts, close handshake, tracing et tests loopback localhost.
Décisions structurantes :
- runtime Tokio possédé par l'application/service/test consommateur, jamais créé globalement par le backend ;
- aucune tâche backend détachée nécessaire au chemin de base ;
- aucun codec métier dans `0.3.4` : le transport véhicule des octets opaques ;
- aucune abstraction générique `Connector`/`Provider`/runtime avant besoin démontré ;
- baseline locale en `ws://`, sans choix TLS prématuré ;
- aucune file interne non bornée ;
- tests loopback sur `127.0.0.1:0`, sans Internet ni port fixe ;
- aucune dépendance `tokio-tungstenite` dans `crates/games/` ou `crates/engines/`.
Les limites et timeouts candidats, le modèle d'erreur, le lifecycle, le tracing et la matrice de tests sont détaillés dans le plan actif. Les signatures Rust exactes restent volontairement à fermer dans `alpha.2`, après validation de ce cadrage.
## Forecast révisé
Le plan actif retient :
```text
0.3.4-alpha.1 gouvernance + audit + ownership + contrat
0.3.4-alpha.2 API game-realtime-transport-lib
0.3.4-alpha.3 backend game-realtime-websocket-lib + loopback
0.3.4-alpha.4 robustesse + limites + lifecycle + consolidation
0.3.4-alpha.5 uniquement si un demo/consolidation autonome est réellement utile
0.3.4-beta.1 validation large
0.3.4-rc.1 candidate gelée + publication documentaire
0.3.4 promotion mécanique stable
```
Le scope reste compatible avec une seule version/session tant qu'il ne dérive pas vers le wire codec, la session multijoueur, TLS/PKI produit ou WebTransport/QUIC.
## Fichiers
Ajoutés :
```text
deltas/0.3.4/alpha.1.md
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
```
Modifiés :
```text
Cargo.toml
README.md
RULES.md
docs/000-README.md
docs/plans/000-README.md
docs/rules/PROMPT_STRUCTURE.md
docs/rules/RULES_DOCUMENTATION.md
docs/rules/RULES_SESSION_PLANNING.md
docs/rules/RULES_VALIDATION_MATRIX.md
docs/rules/VERSION_WORKFLOW.md
history/000-README.md
scripts/audit_project_workspace_rules.py
```
`ROADMAP.md`, `CHANGELOG.md`, les anciens prompts, anciens deltas et anciens historiques restent inchangés.
## 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), 244 file(s))
Distribution layout audit: clean (49 required path(s), 8 forbidden path(s) absent)
```
Le contrôle ciblé de politique de version a également obtenu :
```text
history/0.3.4/0-pre.1.md -> DOC-010, rejet attendu
history/0.3.4/alpha.1.md -> accepté
targeted version-policy probe: clean
```
L'historique antérieur à `0.3.4` reste accepté par l'audit workspace normal.
Aucun `cargo check`, Clippy, test ou smoke post-delta n'est attribué au générateur.
## Validation utilisateur demandée
Le manifest Cargo et l'audit Python changent. Depuis la racine :
```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
```
Aucun test workspace complet n'est demandé dans `alpha.1` : aucun code Rust, aucune dépendance réseau et aucun comportement runtime ne changent.
## Suite après validation
Si cette gate est propre, `alpha.2` crée `history/0.3.4/alpha.1.md` avec les sorties réellement obtenues, puis introduit `game-realtime-transport-lib` sans Tokio/Tungstenite.
Une erreur de migration, d'audit, de plan ou de versionnement produit d'abord `0.3.4-alpha.1.fix.N`.

133
deltas/0.3.4/alpha.2.md Normal file
View File

@@ -0,0 +1,133 @@
<!-- file: deltas/0.3.4/alpha.2.md -->
<!-- version: 1 -->
# Delta 0.3.4-alpha.2
## Base
Base : `0.3.4-alpha.1` validée par l'utilisateur le 2026-09-21.
Cette tranche matérialise uniquement la frontière transport-neutral prévue par le plan `0.3.4`. Elle n'ajoute encore ni Tokio, ni Tungstenite, ni socket, ni protocole de session/gameplay.
## Historique fermé
Ajout de :
```text
history/0.3.4/alpha.1.md
```
L'entrée enregistre les gates effectivement fournies par l'utilisateur pour `alpha.1`, notamment le rebuild complet après `cargo clean`, les audits propres, `cargo check --workspace` et Clippy workspace strict.
## Nouvelle crate `game-realtime-transport-lib`
Ajout de :
```text
crates/common/game-realtime-transport-lib/Cargo.toml
crates/common/game-realtime-transport-lib/src/lib.rs
crates/common/game-realtime-transport-lib/src/connection.rs
crates/common/game-realtime-transport-lib/src/error.rs
crates/common/game-realtime-transport-lib/src/message.rs
crates/common/game-realtime-transport-lib/unit_tests/contract.rs
```
La crate appartient à `crates/common/` car le contrat realtime est indépendant d'une génération de moteur et d'un backend réseau concret.
Elle ne possède aucune dépendance tierce.
## Contrat public
Le payload transport est `TransportMessage`, un buffer binaire possédé sans sémantique gameplay ni codec.
`TransportReceive` distingue explicitement :
```text
Message(TransportMessage)
Closed
```
Une fermeture distante propre n'est donc pas confondue avec une erreur I/O.
Les contrats sont :
```text
RealtimeConnection
RealtimeSender
RealtimeReceiver
```
`RealtimeConnection::split()` produit les deux moitiés indépendantes. Les opérations `send`, `close` et `receive` restent async sans imposer un runtime particulier.
L'implémentation du contrat utilise des futures associées GAT. Cette forme évite une allocation `Box<dyn Future>`, conserve le dispatch statique et n'impose pas de borne `Send` au niveau transport-neutral. Un backend natif reste libre de fournir des futures `Send`; un backend navigateur/WASM futur n'est pas exclu artificiellement.
## Erreurs
`TransportError` conserve une catégorie stable `TransportErrorKind` et un détail diagnostic backend-neutral.
Les catégories de baseline sont :
```text
InvalidConfiguration
Connect
Bind
Accept
Timeout
MessageTooLarge
Backpressure
Closed
Io
Protocol
Aborted
```
Les futurs types d'erreur Tungstenite ne sont pas exposés par cette crate.
## Tests
Les tests unitaires couvrent :
- conservation du payload binaire possédé, y compris le payload vide ;
- distinction message/fermeture propre ;
- split sender/receiver ;
- futures associées de send/receive/close avec une implémentation test sans runtime ;
- catégorie, détail et libellés des erreurs.
Aucun test réseau n'existe dans cette tranche puisqu'aucun backend réseau n'existe encore.
## Documentation locale
Aucun `README.md` ni `USAGE.md` local n'est ajouté pendant `alpha.2`. La crate est volontairement petite, son contrat est documenté par rustdoc et le plan central couvre encore ses frontières ; créer un guide local maintenant dupliquerait ces sources sans valeur durable supplémentaire. Ce choix sera réévalué pendant la consolidation finale conformément à `DOC-CRATE-001`.
## Fichiers existants modifiés
```text
Cargo.toml
README.md
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
```
`Cargo.toml` ajoute le membre workspace et passe la version technique à `0.3.4-alpha.2`.
Le README racine annonce la candidate active. Le plan enregistre la validation de `alpha.1` et ferme le choix des futures associées GAT pour le contrat commun.
`ROADMAP.md` et `CHANGELOG.md` ne changent pas : le scope macroscopique de `0.3.4` est inchangé et une alpha n'ajoute normalement pas d'entrée de changelog.
## Validation attendue
Appliquer d'abord le formatage canonique, puis exécuter :
```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
```
Aucune gate Android, Web/Tauri ou réseau n'est requise : cette tranche n'affecte ni leurs contrats ni un backend socket.

View File

@@ -0,0 +1,87 @@
<!-- file: deltas/0.3.4/alpha.3.fix.1.md -->
<!-- version: 1 -->
# Delta 0.3.4-alpha.3.fix.1
## Cause
La gate utilisateur de `0.3.4-alpha.3` s'est arrêtée avant compilation lors du chargement des manifests Cargo :
```text
error inheriting `futures-util` from workspace root manifest's `workspace.dependencies.futures-util`
Caused by:
`default-features = false` cannot override workspace's `default-features`
```
Le même défaut était présent sur `tokio-tungstenite` mais n'était pas encore affiché parce que Cargo s'arrêtait sur la première dépendance invalide.
`alpha.3` n'est donc pas historisée comme validée et aucune tranche `alpha.4` n'est ouverte avant fermeture de ce défaut.
## Correction
Le workspace passe à :
```text
0.3.4-alpha.3.fix.1
```
Les deux dépendances concernées portent désormais la désactivation de leurs features par défaut directement dans `[workspace.dependencies]` :
```toml
futures-util = { version = "0.3.34", default-features = false }
tokio-tungstenite = { version = "0.30.0", default-features = false }
```
La crate `game-realtime-websocket-lib` hérite ensuite de ces réglages et ajoute uniquement les features dont elle a besoin :
```toml
futures-util = { workspace = true, features = ["sink", "std"] }
tokio-tungstenite = { workspace = true, features = ["connect", "handshake"] }
```
Les features Tokio restent inchangées. Aucun TLS n'est ajouté et aucun code Rust/API/comportement transport n'est modifié.
## Pourquoi ce placement
Cargo autorise une crate membre à enrichir les `features` d'une dépendance héritée du workspace, mais pas à remplacer localement la valeur `default-features` définie par l'héritage. La politique `default-features = false` doit donc être possédée par la déclaration workspace lorsqu'elle est commune à l'utilisation héritée.
Ce fix corrige les deux dépendances concernées immédiatement afin d'éviter qu'une seconde erreur identique apparaisse après correction de `futures-util` seulement.
## Fichiers modifiés
```text
Cargo.toml
README.md
crates/common/game-realtime-websocket-lib/Cargo.toml
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
```
Nouveau fichier :
```text
deltas/0.3.4/alpha.3.fix.1.md
```
Aucun fichier `history/0.3.4/alpha.3.md` n'est créé : une entrée `history/` n'existe qu'après validation réussie du jalon qu'elle décrit.
## 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 tree -p game-realtime-websocket-lib --edges normal
```
La validation recommence depuis le début de la gate, car `alpha.3` n'a jamais atteint une compilation Cargo valide.

144
deltas/0.3.4/alpha.3.md Normal file
View File

@@ -0,0 +1,144 @@
<!-- file: deltas/0.3.4/alpha.3.md -->
<!-- version: 1 -->
# Delta 0.3.4-alpha.3
## Mission
Introduire le premier backend concret de la frontière realtime validée en `alpha.2` : WebSocket async natif avec Tokio + `tokio-tungstenite`, sans TLS, sans protocole session/gameplay et sans dépendance backend dans les moteurs ou jeux.
## Historique fermé
`history/0.3.4/alpha.2.md` enregistre la validation utilisateur : audits propres, workspace/Clippy strict propres et `7` tests sur `game-realtime-transport-lib` passés.
Aucun `alpha.2.fix.N` n'est requis.
## Version et dépendances
Le workspace passe à :
```text
0.3.4-alpha.3
```
Dépendances tierces centralisées au workspace :
```text
futures-util 0.3.34
tokio 1.53.1
tokio-tungstenite 0.30.0
```
`game-realtime-websocket-lib` active uniquement les features dont elle a besoin :
```text
futures-util: default-features=false, sink, std
tokio: net
tokio-tungstenite: default-features=false, connect, handshake
```
Le harness de test ajoute localement les features Tokio `macros`, `rt` et `time`. Aucune feature TLS n'est activée.
## Nouvelle crate WebSocket
Ajout :
```text
crates/common/game-realtime-websocket-lib/Cargo.toml
crates/common/game-realtime-websocket-lib/src/lib.rs
crates/common/game-realtime-websocket-lib/src/websocket.rs
crates/common/game-realtime-websocket-lib/tests/loopback.rs
```
La crate implémente le contrat `game-realtime-transport-lib` sans en modifier l'API.
### Client
`connect(&str)` établit une connexion WebSocket cliente à partir d'un endpoint `ws://`. Un endpoint hors baseline, notamment `wss://`, est rejeté comme `InvalidConfiguration` au lieu d'activer implicitement un backend TLS.
### Serveur
`WebSocketListener::bind(SocketAddr)` bind un `tokio::net::TcpListener`; `127.0.0.1:0` permet au système de choisir un port de test éphémère. `accept()` accepte le TCP puis effectue le handshake WebSocket serveur.
### Connexion
Client et serveur retournent le même `WebSocketConnection` public. Les représentations `tokio_tungstenite::WebSocketStream<...>` restent privées.
`RealtimeConnection::split()` produit `WebSocketSender` et `WebSocketReceiver`. Les futures concrètes sont boxées uniquement dans ce backend afin d'implémenter les associated futures GAT du contrat. Les représentations WebSocket/Tungstenite restent privées ; l'alias public `LocalBoxFuture` de `futures-util` sert uniquement de représentation concrète des associated futures du backend.
### Mapping WebSocket
```text
Binary -> TransportReceive::Message
Close -> TransportReceive::Closed
Ping / Pong -> détail de contrôle backend, non remonté au gameplay
Text -> TransportErrorKind::Protocol
Frame -> TransportErrorKind::Protocol
WriteBufferFull -> TransportErrorKind::Backpressure
Capacity -> TransportErrorKind::MessageTooLarge
I/O -> TransportErrorKind::Io
closed/already -> TransportErrorKind::Closed
```
Les erreurs de connect/bind/accept restent catégorisées au niveau de l'opération qui échoue et aucun `tungstenite::Error` ne fuit dans l'API publique.
## Tracing
Le target backend est :
```text
games::realtime::websocket
```
Les événements couvrent connect, bind, accept, send, receive, close et erreurs. Le contenu brut des payloads n'est jamais loggué ; seule leur longueur peut apparaître au niveau trace.
La crate ne configure aucun subscriber global.
## Preuve loopback
Le test d'intégration public :
```text
binary_round_trip_and_clean_close_work_on_loopback
```
utilise `127.0.0.1:0`, établit client et serveur sans Internet ni port fixe, puis vérifie :
- payload binaire client vers serveur ;
- payload binaire serveur vers client ;
- fermeture locale cliente observée comme fermeture distante propre côté serveur.
Une borne de trois secondes entoure les étapes asynchrones uniquement pour rendre le test déterministe en cas de régression ; elle ne constitue pas encore le timeout de transport produit, réservé à `alpha.4`.
## Documentation locale
Aucun `README.md` ou `USAGE.md` local n'est ajouté pour cette nouvelle crate pendant `alpha.3`. Le contrat reste petit, la rustdoc décrit l'API et le plan central porte les décisions d'architecture. Cette décision sera réévaluée pendant la consolidation finale conformément à `DOC-CRATE-*`.
## Fichiers existants modifiés
```text
Cargo.toml
README.md
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
```
`ROADMAP.md` et `CHANGELOG.md` restent inchangés : le scope macro de `0.3.4` n'a pas changé et une alpha n'ajoute pas encore de changelog de release.
## 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 tree -p game-realtime-websocket-lib --edges normal
```
`cargo test --workspace --all-targets --all-features` reste réservé à la beta conformément au plan.

157
deltas/0.3.4/alpha.4.md Normal file
View File

@@ -0,0 +1,157 @@
<!-- file: deltas/0.3.4/alpha.4.md -->
<!-- version: 1 -->
# Delta 0.3.4-alpha.4
## Base
Base directe : `0.3.4-alpha.3.fix.1`, validée par l'utilisateur le 2026-09-21.
Cette tranche n'élargit pas le contrat transport-neutral et n'introduit ni session gameplay, ni wire codec, ni TLS. Elle ferme les limites, deadlines et principaux cas négatifs du backend WebSocket avant la validation large.
## Historique du jalon précédent
Ajout de :
```text
history/0.3.4/alpha.3.fix.1.md
```
L'historique enregistre la gate utilisateur réellement fournie : audits propres, workspace/Clippy propres, sept tests transport, loopback WebSocket propre et graphe normal sans pile TLS.
## Configuration WebSocket produit
Ajout de `WebSocketConfig`, réexportée au crate-root de `game-realtime-websocket-lib`.
Valeurs par défaut :
```text
max message size 1 MiB
max frame size 1 MiB
write buffer target 64 KiB
max write buffer 2 MiB
connect/handshake 10 s
send 5 s
close 2 s
receive idle timeout aucun
```
La configuration peut être ajustée par builders `with_*` puis validée. Les invariants refusent notamment :
- message/frame de taille nulle ;
- frame maximale supérieure au message maximal ;
- write buffer maximal incapable de contenir le target plus un message maximal ;
- overflow de ce calcul ;
- deadline connect/send/close nulle.
Les wrappers `connect()` et `WebSocketListener::bind()` restent disponibles avec la configuration par défaut. Les nouveaux chemins configurables sont :
```text
connect_with_config(endpoint, config)
WebSocketListener::bind_with_config(address, config)
```
## Limites et backpressure
Les limites message/frame/write-buffer sont transmises à `tungstenite::protocol::WebSocketConfig` aussi bien côté client que côté serveur.
Le send vérifie également `max_message_size` et `max_frame_size` avant l'écriture afin que le dépassement sortant soit déterministe et remonte `TransportErrorKind::MessageTooLarge` sans dépendre d'un comportement réseau.
`max_write_buffer_size` est borné à `2 MiB` par défaut. `tungstenite::Error::WriteBufferFull` continue d'être mappé vers `TransportErrorKind::Backpressure` et ce mapping dispose désormais d'un test unitaire dédié. Aucun test loopback ne fabrique une saturation artificielle : le write buffer Tungstenite ne grossit au-delà de son target que lors d'échecs d'écriture sous-jacents, ce qui rendrait ce scénario réseau local non déterministe.
## Timeouts et lifecycle
La feature Tokio `time` devient une dépendance de production uniquement pour `game-realtime-websocket-lib`.
Les deadlines sont appliquées ainsi :
- connexion client complète : `connect_timeout` ;
- handshake serveur après accept TCP : `connect_timeout` ;
- émission d'un message : `send_timeout` ;
- fermeture locale du sink : `close_timeout`.
L'attente d'un nouveau peer sur `TcpListener::accept()` n'est volontairement pas bornée par la configuration d'une connexion. `receive()` ne reçoit aucun idle timeout : heartbeat et inactivité appartiennent aux futures couches session/synchronisation.
Si un send expire, la moitié émission mémorise cet état et refuse un nouvel envoi avec `TransportErrorKind::Aborted`. Le send interrompu peut avoir progressé partiellement ; il ne doit donc jamais être rejoué implicitement comme s'il n'avait rien produit. Une fermeture explicite reste néanmoins tentable avec sa propre deadline.
Le backend continue de ne créer aucun runtime, thread ou task détachée.
## Tests ajoutés
Tests unitaires de configuration :
- defaults produit exacts ;
- limites message/frame/write-buffer invalides ;
- deadlines nulles invalides.
Tests unitaires de mapping backend :
- `WriteBufferFull -> Backpressure` ;
- `Capacity -> MessageTooLarge`.
Nouveau `tests/robustness.rs` :
1. payload sortant hors limite rejeté avant écriture ;
2. payload entrant hors limite rejeté par Tungstenite ;
3. frame Text rejetée par le contrat binaire ;
4. peer drop sans close handshake remonté comme erreur de protocole ;
5. peer TCP silencieux borné par le timeout de handshake serveur.
Tous les scénarios réseau utilisent uniquement `127.0.0.1:0` et une borne externe de test de trois secondes.
## Documentation
Le plan actif `docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md` est mis à jour avec :
- validation effective de `alpha.3.fix.1` ;
- configuration et deadlines retenues ;
- raison de l'absence d'idle timeout transport ;
- stratégie backpressure testable sans test réseau flaky ;
- orientation vers beta ou courte consolidation selon le résultat réel de cette gate.
`CHANGELOG.md` et `ROADMAP.md` restent inchangés : la version est encore en alpha et le scope macroscopique `0.3.4` ne change pas.
## Fichiers
Modifiés :
```text
Cargo.toml
README.md
crates/common/game-realtime-websocket-lib/Cargo.toml
crates/common/game-realtime-websocket-lib/src/lib.rs
crates/common/game-realtime-websocket-lib/src/websocket.rs
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
```
Ajoutés :
```text
crates/common/game-realtime-websocket-lib/src/config.rs
crates/common/game-realtime-websocket-lib/unit_tests/config.rs
crates/common/game-realtime-websocket-lib/unit_tests/websocket.rs
crates/common/game-realtime-websocket-lib/tests/robustness.rs
history/0.3.4/alpha.3.fix.1.md
deltas/0.3.4/alpha.4.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 tree -p game-realtime-websocket-lib --edges normal
```
La validation utilisateur reste autoritaire pour la compilation, Clippy et les tests runtime. Les audits statiques exécutables dans l'environnement de génération doivent être propres avant livraison du delta.

127
deltas/0.3.4/alpha.5.md Normal file
View File

@@ -0,0 +1,127 @@
<!-- file: deltas/0.3.4/alpha.5.md -->
<!-- version: 1 -->
# Delta 0.3.4-alpha.5
## Base
Base directe : `0.3.4-alpha.4`, validée par l'utilisateur le 2026-09-21.
La gate `alpha.4` est entièrement propre, mais la revue post-validation a identifié une omission de processus : les preuves runtime réseau restent sous le harness de test Cargo. Cette tranche ajoute le smoke réellement exécutable avant toute promotion en beta.
## Historique du jalon précédent
Ajout de :
```text
history/0.3.4/alpha.4.md
```
L'historique enregistre les audits propres, check/Clippy propres, sept tests transport, onze tests WebSocket/robustesse et le graphe de dépendances sans TLS fournis par l'utilisateur.
## Launcher de smoke runtime
Ajout de la crate binaire :
```text
crates/apps/game-realtime-websocket-smoke
```
Ce launcher n'introduit aucune logique session/gameplay et n'ajoute aucune dépendance tierce nouvelle. Il consomme uniquement :
- `game-realtime-websocket-lib` ;
- `game-realtime-transport-lib` ;
- le logging commun ;
- Tokio pour posséder le runtime du processus de smoke.
Le backend reste donc conforme à la décision d'ownership : `game-realtime-websocket-lib` ne crée toujours ni runtime ni thread privé.
## Scénario fumé
La commande :
```bash
cargo run -p game-realtime-websocket-smoke
```
exécute hors harness `#[test]` le scénario suivant :
1. initialise le tracing applicatif commun ;
2. bind un listener WebSocket sur `127.0.0.1:0` ;
3. construit l'endpoint `ws://` à partir du port réellement alloué ;
4. accepte un serveur et connecte un client via les API publiques ;
5. envoie un payload binaire client → serveur et vérifie son contenu ;
6. envoie un payload binaire serveur → client et vérifie son contenu ;
7. initie un close propre côté client ;
8. vérifie que le serveur observe `TransportReceive::Closed` ;
9. affiche `game-realtime-websocket-smoke: PASS` et sort avec le code zéro.
Une deadline globale de cinq secondes empêche le smoke de rester bloqué en cas de régression. Toute erreur de bind/connect/send/receive/close, payload inattendu ou timeout produit un code de sortie non nul.
Le smoke ne dépend ni d'Internet, ni d'un service externe, ni d'un port fixe.
## Documentation locale
`DOC-CRATE-*` est réévalué pour le nouveau launcher. Aucun `README.md` ou `USAGE.md` local n'est ajouté : le binaire n'a aucune configuration, aucun argument et un unique workflow opérateur (`cargo run -p game-realtime-websocket-smoke`), entièrement documenté par le plan actif et le présent delta.
## Plan et version
L'audit de distribution exige désormais aussi la présence du manifeste du launcher de smoke afin que cette preuve runtime ne puisse pas disparaître silencieusement d'une archive future.
Le workspace passe à :
```text
0.3.4-alpha.5
```
Le plan `0.3.4` est corrigé pour distinguer explicitement les tests automatisés du smoke runtime et rendre ce dernier obligatoire avant l'entrée en beta.
`CHANGELOG.md` et `ROADMAP.md` restent inchangés : aucun scope macro ou jalon RC/stable n'est modifié.
## Fichiers
Modifiés :
```text
Cargo.toml
README.md
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
scripts/audit_distribution_layout.py
```
Ajoutés :
```text
crates/apps/game-realtime-websocket-smoke/Cargo.toml
crates/apps/game-realtime-websocket-smoke/src/main.rs
history/0.3.4/alpha.4.md
deltas/0.3.4/alpha.5.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 run -p game-realtime-websocket-smoke
cargo tree -p game-realtime-websocket-smoke --edges normal
```
La validation attendue du smoke est explicitement :
```text
game-realtime-websocket-smoke: PASS
```
avec code de sortie zéro. La compilation, les tests et le smoke restent exécutés côté utilisateur conformément à `CMD-BUILD-005`.

106
deltas/0.3.4/beta.1.md Normal file
View File

@@ -0,0 +1,106 @@
<!-- file: deltas/0.3.4/beta.1.md -->
<!-- version: 1 -->
# Delta 0.3.4-beta.1
## Objectif
Entrer en beta après fermeture de la phase alpha : contrat transport-neutral, backend WebSocket, robustesse, tests localhost et smoke runtime public sont tous validés.
Cette tranche n'ajoute aucune fonctionnalité. Elle synchronise la version technique, historise `alpha.5` et ouvre la validation large prévue avant la consolidation de publication.
## Baseline acceptée
`0.3.4-alpha.5` est validée par l'utilisateur le 2026-09-21.
La baseline acceptée comprend :
- audits Rust/workspace, Markdown et distribution propres ;
- `cargo check --workspace` et Clippy workspace strict propres ;
- sept tests du contrat transport-neutral ;
- cinq tests unitaires WebSocket ;
- un test loopback WebSocket ;
- cinq tests de robustesse WebSocket ;
- smoke runtime hors harness `#[test]` avec bind `127.0.0.1:0`, échange binaire bidirectionnel, close propre et sortie `game-realtime-websocket-smoke: PASS` ;
- graphe normal du launcher conforme aux frontières prévues.
`history/0.3.4/alpha.5.md` consigne le détail.
## Changements
- passage de `workspace.package.version` à `0.3.4-beta.1` ;
- ajout de l'historique validé `alpha.5` ;
- promotion du plan actif vers la phase beta ;
- précision de la gate beta : test workspace complet, smoke runtime séparé et arbres de dépendances direct/inverse ;
- aucune modification de code réseau, contrat transport, gameplay, moteur, SDL, Android, Tauri, WASM, asset ou dépendance tierce.
`CHANGELOG.md` et `ROADMAP.md` restent inchangés : la consolidation publiée et le gel de publication sont réservés aux tranches suivantes.
## Fichiers
Modifiés :
```text
Cargo.toml
README.md
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
```
Ajoutés :
```text
history/0.3.4/alpha.5.md
deltas/0.3.4/beta.1.md
```
## Validation beta — statique et workspace
Depuis la racine :
```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
```
La suite workspace complète est volontairement exécutée ici : `beta.1` constitue la première gate transverse de `0.3.4`.
## Smoke beta
Le smoke runtime reste une gate distincte du test workspace :
```bash
cargo run -p game-realtime-websocket-smoke
```
Résultat attendu :
```text
game-realtime-websocket-smoke: PASS
```
avec code de sortie zéro.
## Frontières et dépendances
Revalider le graphe direct du backend puis ses consommateurs workspace :
```bash
cargo tree -p game-realtime-websocket-lib --edges normal
cargo tree -i game-realtime-websocket-lib --workspace --edges normal
```
L'arbre inverse ne doit révéler aucune dépendance depuis `crates/games/` ni `crates/engines/`. Le launcher de smoke est un consommateur attendu.
## Transition
Si la gate workspace, le smoke et les graphes sont propres, aucune beta supplémentaire n'est créée par cérémonie. La tranche suivante doit effectuer la consolidation finale exigée par `VER-PHASE-010` : documentation durable, `CHANGELOG.md`, `ROADMAP.md`, prompt de la version/session suivante et préparation du gel RC.
Toute régression imputable au projet produit d'abord `0.3.4-beta.1.fix.N`. Une capacité fonctionnelle manquante réouvre une alpha.

151
deltas/0.3.4/rc.1.md Normal file
View File

@@ -0,0 +1,151 @@
<!-- file: deltas/0.3.4/rc.1.md -->
<!-- version: 1 -->
# Delta 0.3.4-rc.1
## Base requise
`0.3.4-beta.1`, validée par l'utilisateur le 2026-09-21.
## Objectif
Créer la candidate de publication `0.3.4` après validation large de la baseline realtime, sans rouvrir le comportement réseau.
Cette tranche ferme `VER-PHASE-010` : historique beta, documentation durable, changelog, prompt de la version suivante et gel RC.
## Gel fonctionnel
`0.3.4-rc.1` n'ajoute :
- aucun transport ;
- aucune API réseau ;
- aucune dépendance tierce ;
- aucun TLS ;
- aucun wire codec ;
- aucun protocole session/joueur/room ;
- aucune synchronisation gameplay ;
- aucune simulation authoritative ;
- aucun changement moteur, jeu, SDL, Android, Tauri ou WASM.
Les seules modifications techniques sont la promotion de `workspace.package.version` vers `0.3.4-rc.1`. Le code Rust realtime validé en beta reste inchangé.
Tout défaut de publication fermé relève de `0.3.4-rc.1.fix.N` selon `VER-RC-*`. Une réouverture fonctionnelle invalide la RC.
## Validation beta acquise
`history/0.3.4/beta.1.md` consigne la gate réelle :
- audits Rust/workspace, Markdown et distribution propres ;
- `cargo check --workspace` propre ;
- Clippy workspace strict propre ;
- `cargo test --workspace --all-targets --all-features` : 53 tests réussis, 0 échec ;
- smoke runtime `game-realtime-websocket-smoke: PASS` ;
- backend sur Tokio `1.53.1`, tokio-tungstenite/Tungstenite `0.30.0`, sans pile TLS ;
- arbre inverse limité à `game-realtime-websocket-smoke`, sans dépendance backend dans `crates/games/` ou `crates/engines/`.
Aucune beta supplémentaire n'est créée uniquement par cérémonie.
## Consolidation documentaire
La revue `DOC-CRATE-*` conclut :
- `game-realtime-transport-lib` reçoit un `README.md` durable décrivant son contrat, ses frontières et le sens des dépendances ; son API est assez petite pour ne pas justifier un `USAGE.md` séparé ;
- `game-realtime-websocket-lib` reçoit un `README.md` durable et un `USAGE.md`, car configuration, client/listener, limites, deadlines et smoke constituent un workflow de consommation non trivial ;
- `game-realtime-websocket-smoke` reste sans document local supplémentaire : la commande Cargo unique, le plan et le delta couvrent entièrement son rôle.
Les documents locaux ne contiennent aucun journal de prerelease.
## Changelog et roadmap
`CHANGELOG.md` reçoit l'entrée `0.3.4-rc.1` avec le scope réellement validé.
`ROADMAP.md` est relue mais reste inchangée : `0.3.4` ne devient `(x)` qu'après validation de la RC et promotion stable. `0.3.5` reste le POC WebTransport/QUIC prévu ensuite.
## Prompt suivant
Ajout de :
```text
prompts/006-V0_3_5_START_PROMPT.md
```
Le prompt part de la future stable/taggée `v0.3.4`, rappelle que la RC/stable doit être terminée si nécessaire, et cadre `0.3.5-alpha.1` autour de :
- recherche actuelle des stacks WebTransport/QUIC ;
- challenge du contrat transport-neutral livré par `0.3.4` ;
- plateformes navigateur/native/Android réellement testables ;
- exigences TLS/certificats/HTTP3/UDP ;
- fallback WebSocket au niveau d'ownership approprié ;
- comparaison mesurée sans présumer que WebTransport doit être retenu ;
- maintien hors scope des couches session/synchronisation/simulation.
Le prompt ne présente aucune validation future de la RC ou de la stable comme acquise.
## Fichiers
Modifiés :
```text
Cargo.toml
README.md
CHANGELOG.md
docs/000-README.md
docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md
```
Ajoutés :
```text
crates/common/game-realtime-transport-lib/README.md
crates/common/game-realtime-websocket-lib/README.md
crates/common/game-realtime-websocket-lib/USAGE.md
history/0.3.4/beta.1.md
prompts/006-V0_3_5_START_PROMPT.md
deltas/0.3.4/rc.1.md
```
## Validation RC
Depuis la racine :
```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 run -p game-realtime-websocket-smoke
cargo tree -i game-realtime-websocket-lib --workspace --edges normal
```
Le test workspace complet n'est pas répété par cérémonie : aucun Rust, dépendance ou contrat transverse n'a changé depuis la gate beta qui l'a déjà validé. Il redevient obligatoire si un fix RC touche ces éléments.
Le smoke doit afficher :
```text
game-realtime-websocket-smoke: PASS
```
L'arbre inverse doit continuer à montrer uniquement le launcher de smoke comme consommateur du backend WebSocket.
## Après validation
Si la RC est propre, la promotion vers `0.3.4` est mécanique :
- `workspace.package.version = 0.3.4` ;
- ajout de `history/0.3.4/rc.1.md` ;
- entrée stable dans `CHANGELOG.md` ;
- `ROADMAP.md` passe `0.3.4` en `(x)` ;
- clôture du plan `004` et de son index ;
- ajustement mécanique de `prompts/006-V0_3_5_START_PROMPT.md` si une référence devient certaine ;
- `deltas/0.3.4/rel.001.md`.
Aucun nouveau comportement ne doit apparaître dans cette promotion.

64
deltas/0.3.4/rel.001.md Normal file
View File

@@ -0,0 +1,64 @@
<!-- file: deltas/0.3.4/rel.001.md -->
<!-- version: 1 -->
# Delta 0.3.4 — release stable
## Base
Base validée : `0.3.4-rc.1`.
## Objet
Promouvoir mécaniquement la candidate validée vers `0.3.4` sans introduire de nouveau comportement.
## Changements
La release stable :
- passe `workspace.package.version` de `0.3.4-rc.1` à `0.3.4` ;
- positionne `README.md` sur `0.3.4` comme stable de référence et `0.3.5-alpha.1` comme prochaine version planifiée ;
- marque `0.3.4` terminée dans `ROADMAP.md` ;
- ajoute l'entrée stable `0.3.4` dans `CHANGELOG.md` ;
- enregistre la validation effective de la RC dans `history/0.3.4/rc.1.md` ;
- clôt `docs/plans/004-V0_3_4_REALTIME_TRANSPORT_WEBSOCKET_PLAN.md` et ses deux entrées d'index ;
- ajuste mécaniquement `prompts/006-V0_3_5_START_PROMPT.md` afin qu'il parte explicitement de la stable taggée `v0.3.4` et des validations réellement acquises.
## Frontière de release
Aucun fichier Rust, test Rust, manifeste de crate realtime, dépendance tierce, frontend, Android, gameplay, moteur, comportement WebSocket, limite, timeout ou contrat transport n'est modifié après la RC.
La stable conserve notamment :
- `game-realtime-transport-lib` transport-neutral ;
- `game-realtime-websocket-lib` comme backend WebSocket de référence ;
- `game-realtime-websocket-smoke` comme preuve runtime localhost ;
- l'absence de dépendance du backend dans `crates/games/` et `crates/engines/` ;
- WebTransport/QUIC, TLS produit, wire/session, synchronisation et simulation authoritative hors scope de `0.3.4`.
## Validation proportionnelle
La gate RC complète a déjà été validée sur un état fonctionnellement identique : audits propres, check/Clippy, 18 tests realtime ciblés, smoke runtime `PASS` et arbre inverse conforme. `beta.1` avait en outre validé `cargo test --workspace --all-targets --all-features` avec 53 tests réussis.
La promotion stable demande uniquement les contrôles proportionnels aux changements de version et de documentation :
```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 strict, suites de tests et smoke runtime ne sont pas répétés par cérémonie puisque la promotion stable ne modifie aucun code ni comportement. Tout échec de la gate ci-dessus doit néanmoins être corrigé avant publication.
## Publication
Après validation de ce delta :
- commit de release ;
- tag stable unique `v0.3.4` ;
- archive ZIP du tag utilisée comme baseline autoritaire de la session suivante ;
- démarrage de `0.3.5` uniquement depuis `v0.3.4` avec `prompts/006-V0_3_5_START_PROMPT.md` ;
- première tranche suivante : `0.3.5-alpha.1`, audit/recherche WebTransport/QUIC et création du plan `docs/plans/005-V0_3_5_WEBTRANSPORT_QUIC_POC_PLAN.md`.

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

Some files were not shown because too many files have changed in this diff Show More