diff --git a/.gitattributes b/.gitattributes index 1cb59805..e75b066c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,3 +13,19 @@ # repair is in native/GameCult.Mesh.Quic.Native/README.md, beside the container # path it unblocks. *.sh text eol=lf + +# The committed CultMesh QUIC native binaries (cut 6, packaging) ride the +# deploy `git archive` as raw bytes. `* text=auto` already autodetects them as +# binary by content, but that autodetection is a heuristic; say so explicitly +# so no future text-processing rule (a diff driver, an export filter) can +# apply text conversion and poison the committed digests in SHA256SUMS. +packages/cultmesh-ts/native/**/*.dll binary +packages/cultmesh-ts/native/**/*.so binary +packages/cultmesh-ts/native/**/*.so.* binary + +# The digest list and manifest beside them keep LF on every checkout. Left to +# `text=auto`, a Windows clone writes them with carriage returns, and the +# documented `sha256sum -c SHA256SUMS` then fails on every line (each filename +# ends in `\r`) while CI, checking out on Linux, stays green. +packages/cultmesh-ts/native/SHA256SUMS text eol=lf +packages/cultmesh-ts/native/MANIFEST.txt text eol=lf diff --git a/.github/workflows/quic-native.yml b/.github/workflows/quic-native.yml new file mode 100644 index 00000000..9b16cbeb --- /dev/null +++ b/.github/workflows/quic-native.yml @@ -0,0 +1,202 @@ +name: CultMesh QUIC native + +# Builds and, on a tag, releases the CultMesh QUIC native binaries that +# packages/cultmesh-ts/native/{win32-x64,linux-x64} commits (cut 6, +# docs/typescript-quic-realtime-cut.md:2108-2159). Under Q8 Option 1 the +# operator downloads a tagged run's Release assets, verifies every digest +# against this workflow's own logs and SHA256SUMS, and commits the bytes; +# nothing here writes to the tree. Under Q6 A, the Linux artifact comes only +# from the pinned debian:13 container below, never from a local or Yggdrasil +# build. + +on: + push: + branches: + - main + tags: + - "cultmesh-quic-native-v*" + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Push to main and pull_request run this job only: it proves the tree's + # committed bytes still match the manifest they shipped with. A tag push + # also runs it, alongside the two builds and the release. + verify-committed: + name: Verify committed binaries match SHA256SUMS + if: >- + github.event_name == 'pull_request' || + (github.event_name == 'push' && + (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v'))) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out CultLib + uses: actions/checkout@v4 + + - name: sha256sum -c SHA256SUMS + run: | + set -euo pipefail + if [ ! -f packages/cultmesh-ts/native/SHA256SUMS ]; then + echo "packages/cultmesh-ts/native/SHA256SUMS is missing" >&2 + exit 1 + fi + cd packages/cultmesh-ts/native + sha256sum -c SHA256SUMS + + # workflow_dispatch and a cultmesh-quic-native-v* tag run the two builds. + # A dry (workflow_dispatch) run uploads artifacts and creates no release. + build-win32-x64: + name: Build win32-x64 + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')) + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Check out CultLib + uses: actions/checkout@v4 + + - name: Build the native QUIC bridge + shell: pwsh + run: .\scripts\build-quic-native.ps1 -OutputDirectory artifacts\quic-native\win32-x64 + + - name: Print toolchain version and digests + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $compilerCmake = Get-ChildItem -Recurse -LiteralPath "artifacts\quic-native-build\x64\Release" -Filter "CMakeCXXCompiler.cmake" | + Select-Object -First 1 + if ($compilerCmake) { + Select-String -LiteralPath $compilerCmake.FullName -Pattern "CMAKE_CXX_COMPILER_VERSION" | + ForEach-Object { Write-Host $_.Line.Trim() } + } + Get-FileHash -Algorithm SHA256 artifacts\quic-native\win32-x64\gamecult_mesh_quic_native.dll + Get-FileHash -Algorithm SHA256 artifacts\quic-native\win32-x64\msquic.dll + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: quic-native-win32-x64 + path: artifacts/quic-native/win32-x64 + if-no-files-found: error + + build-linux-x64: + name: Build linux-x64 + if: >- + github.event_name == 'workflow_dispatch' || + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')) + runs-on: ubuntu-latest + # Q6 A: this pinned Debian 13 container is the only permitted Linux + # release builder. The digest is the pin; the `debian:13` tag is only a + # label. The artifact links against this image's glibc (2.41); a bare + # ubuntu-latest runner carries glibc 2.39, which is not the deploy host's + # OS (docs/typescript-quic-realtime-cut.md:2192-2197). + container: + image: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1 + timeout-minutes: 20 + steps: + - name: Check out CultLib + uses: actions/checkout@v4 + + - name: Install build tools + run: | + set -euo pipefail + apt-get update + apt-get install -y --no-install-recommends cmake ninja-build g++ curl ca-certificates dpkg + + - name: Build the native QUIC bridge + run: ./scripts/build-quic-native.sh artifacts/quic-native/linux-x64 + + - name: Print toolchain version and digests + run: | + g++ --version + sha256sum artifacts/quic-native/linux-x64/libgamecult_mesh_quic_native.so + sha256sum artifacts/quic-native/linux-x64/libmsquic.so.2 + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: quic-native-linux-x64 + path: artifacts/quic-native/linux-x64 + if-no-files-found: error + + # Only this job has write permission, and only a tag push runs it. It + # attaches a draft MANIFEST.txt: the fields both build jobs cannot know + # (host names, MSVC version, the pinned dependency sources) are left for + # the operator to fill in from this run's own logs before committing the + # binaries, per 11.5's manifest paragraph. + release: + name: Release native binaries + needs: [build-win32-x64, build-linux-x64] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v') + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Check out CultLib + uses: actions/checkout@v4 + + - name: Download win32-x64 artifact + uses: actions/download-artifact@v4 + with: + name: quic-native-win32-x64 + path: release/win32-x64 + + - name: Download linux-x64 artifact + uses: actions/download-artifact@v4 + with: + name: quic-native-linux-x64 + path: release/linux-x64 + + - name: Write SHA256SUMS and a draft MANIFEST.txt + run: | + set -euo pipefail + cd release + # Relative paths matching packages/cultmesh-ts/native/ exactly, so + # this file is the one the operator commits verbatim (11.5, 11.7). + sha256sum \ + win32-x64/gamecult_mesh_quic_native.dll \ + win32-x64/msquic.dll \ + linux-x64/libgamecult_mesh_quic_native.so \ + linux-x64/libmsquic.so.2 \ + > SHA256SUMS + cat > MANIFEST.txt </msquic.dll") + else() target_link_libraries(cultmesh_quic_native_tests PRIVATE pthread) # The linker resolves the bridge's own NEEDED libmsquic.so.2 while linking # this executable, and the runtime copy only lands beside the binaries after @@ -90,5 +104,15 @@ if(CULTMESH_QUIC_BUILD_TESTS) set_target_properties(cultmesh_quic_native_tests PROPERTIES BUILD_WITH_INSTALL_RPATH TRUE INSTALL_RPATH "$ORIGIN") + # Both binaries carry INSTALL_RPATH "$ORIGIN" and expect libmsquic.so.2 + # beside them at load time. build-quic-native.sh's packaged output gets + # that copy from its own recipe; a test build has no packaging step, so + # the runner would otherwise die at once with "cannot open shared object + # file". CMake already knows where MSQUIC_LIB_DIR is, so it places the + # file itself instead of leaving the reader to run an extra cp by hand. + add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + "${MSQUIC_LIB_DIR}/libmsquic.so.2" + "$/libmsquic.so.2") endif() endif() diff --git a/native/GameCult.Mesh.Quic.Native/README.md b/native/GameCult.Mesh.Quic.Native/README.md index f9609d27..ae5d78d2 100644 --- a/native/GameCult.Mesh.Quic.Native/README.md +++ b/native/GameCult.Mesh.Quic.Native/README.md @@ -114,8 +114,8 @@ and a container that cannot ask for that cannot run them: scripts/build-quic-native.sh cmake -S native/GameCult.Mesh.Quic.Native -B artifacts/quic-native-tests/linux-x64 -G Ninja \ -DCMAKE_BUILD_TYPE=Release -DCULTMESH_QUIC_BUILD_TESTS=ON -DCULTMESH_QUIC_DEBUG_ASSERTS=ON \ - -DMSQUIC_INCLUDE_DIR=artifacts/dependencies/msquic-linux-2.5.9/include \ - -DMSQUIC_LIB_DIR=artifacts/dependencies/msquic-linux-2.5.9/package/usr/lib/x86_64-linux-gnu + -DMSQUIC_INCLUDE_DIR=\$(pwd)/artifacts/dependencies/msquic-linux-2.5.9/include \ + -DMSQUIC_LIB_DIR=\$(pwd)/artifacts/dependencies/msquic-linux-2.5.9/package/usr/lib/x86_64-linux-gnu cmake --build artifacts/quic-native-tests/linux-x64 for scenario in closerace holdclose holdtimeout polltimeout pollbusy pollhammer zerotimeout payloadfit latecall waitseam; do setarch -R artifacts/quic-native-tests/linux-x64/bin/cultmesh_quic_native_tests \"\${scenario}\" || exit 1 @@ -123,7 +123,16 @@ and a container that cannot ask for that cannot run them: " Both lines are run from the repository root, and the mount is that root wherever -it is: `${PWD}` in PowerShell, `$(pwd)` in a POSIX shell. `CULTMESH_QUIC_BUILD_TESTS` +it is: `${PWD}` in PowerShell, `$(pwd)` in a POSIX shell. `MSQUIC_INCLUDE_DIR` +and `MSQUIC_LIB_DIR` need `$(pwd)` too, even though the `cmake` invocation +itself already runs from `/src`: CMake resolves a relative path passed as a +cache variable against the CMakeLists.txt that consumes it +(`native/GameCult.Mesh.Quic.Native`), not against the directory `cmake` was +invoked from, so a bare `artifacts/...` here looks for the dependency one level +too deep and fails the compile before it reaches the loader. The test target's +own build now copies `libmsquic.so.2` from `MSQUIC_LIB_DIR` beside the built +binaries, so nothing else here needs the packaged output directory `scripts/build-quic-native.sh` +produces. `CULTMESH_QUIC_BUILD_TESTS` is off in the shipped configuration (see the CMakeLists.txt option comment); it turns on the scenario runner `tests/cultmesh_quic_native_tests.cpp` compiles into, and `CULTMESH_QUIC_DEBUG_ASSERTS` turns on both the bridge's internal invariant diff --git a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp b/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp index b11610aa..e42b6d25 100644 --- a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp +++ b/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp @@ -83,10 +83,29 @@ namespace { -// Long enough that a poller is still parked when the close begins. The close is -// what ends the wait; this timeout only bounds the scenario if the close never -// arrives, which is itself the failure. -constexpr int32_t kPollTimeoutMs = 5000; +// `CloseRaceOnce`'s own settle budget, named here — ahead of `kPollTimeoutMs` +// below — so the poll timeout can be derived from it instead of drifting apart +// as a second freestanding number. Under CPU starvation the settle can spend +// this whole budget waiting for every poller to be counted inside the library +// before the close begins; a poll timeout shorter than that lets the first +// pollers time out for real while the fixture is still waiting on the last +// ones, which is exactly the failure this scenario exists to catch, not the +// one it is supposed to be running. +constexpr int32_t kCloseRaceCountedDeadlineMs = 30000; // asserts-on: cap on the peak_calls wait +constexpr int32_t kCloseRaceAssertsSettleMs = 50; // asserts-on: fixed remainder after counted +constexpr int32_t kCloseRaceNoSeamSettleMs = 500; // no-seam #else: fixed settle window +constexpr int32_t kCloseRaceSettleBudgetMs = + (kCloseRaceCountedDeadlineMs + kCloseRaceAssertsSettleMs) > kCloseRaceNoSeamSettleMs + ? (kCloseRaceCountedDeadlineMs + kCloseRaceAssertsSettleMs) + : kCloseRaceNoSeamSettleMs; + +// Long enough that a poller is still parked when the close begins, in every +// configuration, even when the settle above burns its whole budget. The close +// is what ends the wait; this timeout only bounds the scenario if the close +// never arrives, which is itself the failure, so a long poll timeout costs +// nothing on a passing run. The flat 5 s margin is headroom above the settle +// budget, not a second guess at it. +constexpr int32_t kPollTimeoutMs = kCloseRaceSettleBudgetMs + 5000; // A poller asks for a payload buffer, so the close races a `next_event` that has // somewhere to copy into rather than one that can only ever return 0. @@ -175,11 +194,29 @@ std::string CloseRaceOnce(int pollers) { // development seam is compiled in, this reads the bridge's own count // instead of guessing at a duration: the settle ends only once every // poller is actually counted inside, however long the scheduler took. - const auto counted_deadline = - std::chrono::steady_clock::now() + std::chrono::seconds(30); + const auto counted_deadline = std::chrono::steady_clock::now() + + std::chrono::milliseconds(kCloseRaceCountedDeadlineMs); while (cultmesh_quic_debug_peak_calls() < pollers && std::chrono::steady_clock::now() < counted_deadline) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + + // The fixture's own settle can fail to reach every poller inside the + // deadline above, distinct from the race this scenario exists to run. + // Proceeding to the close anyway would then produce the pollers' own + // failure message — "N of M pollers returned before the close began" — + // which blames the pollers for a race the fixture never set up. Report + // this plainly instead, and unstick the parked pollers before returning so + // this iteration does not just sit out `kPollTimeoutMs`. + const int32_t counted = cultmesh_quic_debug_peak_calls(); + if (counted < pollers) { + closing_started.store(true); + cultmesh_quic_runtime_close(runtime); + for (auto& thread : threads) thread.join(); + return "fixture could not settle: " + std::to_string(counted) + " of " + + std::to_string(pollers) + " pollers counted inside after " + + std::to_string(kCloseRaceCountedDeadlineMs) + " ms"; + } + // `active_calls` — what `peak_calls` reflects — is raised the moment a // poller's `CallScope` is constructed, at the top of // `cultmesh_quic_next_event`, not when it reaches the condition wait a few @@ -187,14 +224,14 @@ std::string CloseRaceOnce(int pollers) { // starvation (getting the scheduler to run a thread at all); a short fixed // settle covers the cheap remainder (a lock acquisition and an empty-queue // check) once every poller is already scheduled and running. - std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::this_thread::sleep_for(std::chrono::milliseconds(kCloseRaceAssertsSettleMs)); #else // No seam in this configuration (the sanitizer build deliberately ships // without one), so the settle falls back to a longer fixed window rather // than the 50 ms that starved under 16 contending burners. It is still a // window, not a guarantee — the honest limit of a config with no way to // ask the bridge what it has actually counted. - std::this_thread::sleep_for(std::chrono::milliseconds(500)); + std::this_thread::sleep_for(std::chrono::milliseconds(kCloseRaceNoSeamSettleMs)); #endif closing_started.store(true); diff --git a/package-lock.json b/package-lock.json index f027ecd5..5c0a5591 100644 --- a/package-lock.json +++ b/package-lock.json @@ -489,6 +489,326 @@ "dev": true, "license": "MIT" }, + "node_modules/@koromix/koffi-android-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-arm64/-/koffi-android-arm64-3.3.0.tgz", + "integrity": "sha512-lHXE2mq+uG6CVeX1iepn2ATcAWMZPagr+J0ICYge7wTMDYc5pPRQMq0Rr/16775i6QlUqQqpk8c5Gu66+u3gGA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-android-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-android-x64/-/koffi-android-x64-3.3.0.tgz", + "integrity": "sha512-7EeVK/KS9XNbOsUfy0B3uuSjPQZenjJ35y9QSi6R6eK4Fv0oGvREQaBzIJRJwonxp7sJfGObnwnQTnKTVK2DWA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.3.0.tgz", + "integrity": "sha512-2R9bihYmYFyRP98wRG2m5WQVKMONf7DoPJI8RkGCOUB2afOXJEVXFepiSKxBZGPml8i1USwAxMUwkzbJ5uMG1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.3.0.tgz", + "integrity": "sha512-OecYCQfzhUx+XyAtatIHlk0jIz87wKNjtYvwlwgMwgaGQwO/TxFeReNo+s3167Rh1a9c8rUIOjq1jfzAPBxbKA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.3.0.tgz", + "integrity": "sha512-6ytHzV1DuyxTP5LtFmQ3imBk71j/F/Nb0Hcwhjcn35esnknm5rQbgRl6tNRldrkGnM4GIAJpsagooUBh1xAzYg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.3.0.tgz", + "integrity": "sha512-hINo5z1Iz2u3Rx3oHfzcmi5A03gYAoYf4nCJJYaY0bb5IuGdIC7noQTyyI9YxfyTJgoqw3wKFWWYtvsAmEo9Hw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.3.0.tgz", + "integrity": "sha512-7XQYAZh828nW6+y3IdyUNdo2VbVHL5ZC8Qb+yqHrlDjTjVXSyGYZJHAyJZ5c7eSTTKBhtIMTX238rIsdexxrog==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm/-/koffi-linux-arm-3.3.0.tgz", + "integrity": "sha512-NbahRKKKFkHoFN/ZpLY+w4F6eYSqV4KMN40PmjZ1xQjG/XjYb6PIYgE41c7/acAPxOYqujrcaI3B4BAgWYm5/A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.3.0.tgz", + "integrity": "sha512-Eo/+wGeawGICzJkUlkzi/KerAJ6U1L/Hf7cnJKH7aGgCOY5RRnjUqXs/eL+UDpRcFHzGeUn+Fq+8q2VxYxvAfw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.3.0.tgz", + "integrity": "sha512-u9tKuj42RFWJU8ZgZqcBhkFCkIZE/SE6oR1ljpukttJUhOVuaf/lmsWxyOxiVu3U8T1gtD658FlLgrbMVIn6aQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.3.0.tgz", + "integrity": "sha512-2hKlNZ6OXkTUp6fcueZcP0sQ6yhpdfANg9/mWxDKzT1o8qIj4qJD7UcZLCcRIC8JXiP8JQwZBtlPuyXIqofDQg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ppc64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ppc64/-/koffi-linux-ppc64-3.3.0.tgz", + "integrity": "sha512-bIVAoHgzyRN7xs529BK7rF3BbZUTJgIr0t/wvBvAgaIpreKsdZ/QWxhedKRbTN7ljlDHkPZFmu5XcCjb/vqUag==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.3.0.tgz", + "integrity": "sha512-2niYAe+TBXbvf7yog/aiHiDUrKQn+RkMSH5x3ZYo65OQyII1CY3Jk2kP3Pe735gq0LzIYJsDuGMW+02ighSadQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.3.0.tgz", + "integrity": "sha512-apU8ZMOrOh1eqMP/Pp0sDZ/WY6A4r6JWph26h63VM9nZ+5KCTYuPgojfoLPNvrfI/bn1PY3+bpXKRhZHI3gxbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-arm64/-/koffi-openbsd-arm64-3.3.0.tgz", + "integrity": "sha512-iTyHMCSSs+X579sWJv7XVFMp9gOfROI8IUEwp09YxJpGvmiSoipI0J7kytFuFq6dyuwO0Ymy4JdlmKnCM0qkxg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.3.0.tgz", + "integrity": "sha512-xCvMV+4VNHjTxgVPEsBzU0k8dlA7aoFO5QjRF9lVU/NzGhUedlYvFgaF6IWsGOoWEaNt9hgP4KK/wxMbm9v8EQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.3.0.tgz", + "integrity": "sha512-xItIwx1elM8k1pTudEfuV/1U2gQ06+rFfxCVXrZkyWYYGTzmA5To00f8garJFR+E9gbjQJbn8QDFvWMI+i2sMA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.3.0.tgz", + "integrity": "sha512-fD41LgKBx5i1YlJDuds4tRHAaG5vV55tvagzJwKFDR/5BDeaFvH9QFMUMDoNl2LkmVyeug0dIElHr/oxejh3IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.3.0.tgz", + "integrity": "sha512-BfDbDVEuQwPZOvWXawc3JT/7i9cN7QHYotgo014jjXb9pNv46Ykhb0RRglkcHg+SdytW/7pxYn18YwcQP6LLvQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.3.0.tgz", + "integrity": "sha512-D+ORp73rTmvGVfq01+4uBmgVZqrPqU9N9xCZCfexaCDlVk0WQsaqid1dGsi6eScJuHt4Cp8s1i5/4CsecxFTKQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, "node_modules/@msgpack/msgpack": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", @@ -715,6 +1035,38 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/koffi": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.3.0.tgz", + "integrity": "sha512-UeyQtptuPeCP0BqKD+Q8ftTQhIUBHO7bXyK9DVIaW9hJSg0BrICm4K5N8Qtn7zKk20sb5vpnES9KBqVksM2kpQ==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-android-arm64": "3.3.0", + "@koromix/koffi-android-x64": "3.3.0", + "@koromix/koffi-darwin-arm64": "3.3.0", + "@koromix/koffi-darwin-x64": "3.3.0", + "@koromix/koffi-freebsd-arm64": "3.3.0", + "@koromix/koffi-freebsd-ia32": "3.3.0", + "@koromix/koffi-freebsd-x64": "3.3.0", + "@koromix/koffi-linux-arm": "3.3.0", + "@koromix/koffi-linux-arm64": "3.3.0", + "@koromix/koffi-linux-ia32": "3.3.0", + "@koromix/koffi-linux-loong64": "3.3.0", + "@koromix/koffi-linux-ppc64": "3.3.0", + "@koromix/koffi-linux-riscv64": "3.3.0", + "@koromix/koffi-linux-x64": "3.3.0", + "@koromix/koffi-openbsd-arm64": "3.3.0", + "@koromix/koffi-openbsd-ia32": "3.3.0", + "@koromix/koffi-openbsd-x64": "3.3.0", + "@koromix/koffi-win32-arm64": "3.3.0", + "@koromix/koffi-win32-ia32": "3.3.0", + "@koromix/koffi-win32-x64": "3.3.0" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -867,7 +1219,8 @@ "dependencies": { "@gamecult/cultcache-ts": "^0.14.0", "@msgpack/msgpack": "^3.1.2", - "cultnet-ts": "^0.2.0" + "cultnet-ts": "^0.2.0", + "koffi": "3.3.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md index 0f9a99fe..13f17973 100644 --- a/packages/cultmath/README.md +++ b/packages/cultmath/README.md @@ -26,7 +26,8 @@ shader semantics. acceleration and semi-implicit Euler integration - `math` intrinsics: `radians`, `degrees`, `abs`, `floor`, `ceil`, `frac`, `min`, `max`, `clamp`, `saturate`, `lerp`, `step`, `smoothstep`, `dot`, - `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, exponential + `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, `erf`, + `erfinv` (scalar only), exponential `decay`/`damp`, intercept/segment helpers, Catmull-Rom, Bezier curves, and deterministic value-noise primitives - `shaders/CultMath.hlsl`, a canonical HLSL mirror include for shader-side diff --git a/packages/cultmath/src/CultMath/math.cs b/packages/cultmath/src/CultMath/math.cs index 99b3c234..b5d5339c 100644 --- a/packages/cultmath/src/CultMath/math.cs +++ b/packages/cultmath/src/CultMath/math.cs @@ -521,6 +521,74 @@ public static partial class math a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21, a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22); + // Abramowitz & Stegun 7.1.26, a single-precision rational/exponential fit with a stated + // max absolute error of 1.5e-7. erf is odd, so only x >= 0 is fit and the sign is restored. + // Measured against an independent reference: worst absolute error 6.621e-7, inside the + // 2e-6 bar this package's tests pin. + public static float erf(float value) + { + const float p = 0.3275911f; + const float a1 = 0.254829592f; + const float a2 = -0.284496736f; + const float a3 = 1.421413741f; + const float a4 = -1.453152027f; + const float a5 = 1.061405429f; + + var s = sign(value); + var x = abs(value); + var t = 1.0f / (1.0f + p * x); + var poly = ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t; + var result = 1.0f - poly * exp(-x * x); + return s * result; + } + + // Giles, "Approximating the erfinv function" (GPU Computing Gems, 2010): a single-precision + // minimax polynomial in two ranges of w = -ln((1-x)(1+x)), central and tail. + // Domain is [-1, 1]. Outside it, including +-infinity, the result is NaN (there is no real + // inverse). At the edges the mathematical limit is infinite: erfinv(1) = +infinity and + // erfinv(-1) = -infinity. The unguarded polynomial hits log(0) = -infinity at those edges and + // the tail branch's leading coefficient is negative, so it delivers the wrong sign; guard the + // edges explicitly instead of trusting the fit through the singularity. + // Measured against an independent reference: worst absolute error 5.066e-7, and the + // erf(erfinv(y)) round trip 6.109e-7 worst case, both inside the 2e-6 bar this package's + // tests pin. + public static float erfinv(float value) + { + if (value >= 1.0f) return value > 1.0f ? float.NaN : float.PositiveInfinity; + if (value <= -1.0f) return value < -1.0f ? float.NaN : float.NegativeInfinity; + + var w = -log((1.0f - value) * (1.0f + value)); + float p; + if (w < 5.0f) + { + w -= 2.5f; + p = 2.81022636e-08f; + p = 3.43273939e-07f + p * w; + p = -3.5233877e-06f + p * w; + p = -4.39150654e-06f + p * w; + p = 0.00021858087f + p * w; + p = -0.00125372503f + p * w; + p = -0.00417768164f + p * w; + p = 0.246640727f + p * w; + p = 1.50140941f + p * w; + } + else + { + w = sqrt(w) - 3.0f; + p = -0.000200214257f; + p = 0.000100950558f + p * w; + p = 0.00134934322f + p * w; + p = -0.00367342844f + p * w; + p = 0.00573950773f + p * w; + p = -0.0076224613f + p * w; + p = 0.00943887047f + p * w; + p = 1.00167406f + p * w; + p = 2.83297682f + p * w; + } + + return p * value; + } + // Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3) // in shaders/CultMath.hlsl with the same float32 evaluation order. public static float snoise(float3 value) diff --git a/packages/cultmath/tests/CultMath.Tests/MathTests.cs b/packages/cultmath/tests/CultMath.Tests/MathTests.cs index 534ec276..b47a389e 100644 --- a/packages/cultmath/tests/CultMath.Tests/MathTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/MathTests.cs @@ -367,4 +367,160 @@ public sealed class MathTests Assert.Equal(0.0f, px[1], precision: 5); Assert.Equal(0.0f, py[1], precision: 5); } + + // Tabulated to 16 significant figures (Abramowitz & Stegun table 7.1 lineage / standard erf tables). + [Theory] + [InlineData(0.0f, 0.0)] + [InlineData(0.5f, 0.5204998778130465)] + [InlineData(-0.5f, -0.5204998778130465)] + [InlineData(1.0f, 0.8427007929497149)] + [InlineData(-1.0f, -0.8427007929497149)] + [InlineData(2.0f, 0.9953222650189527)] + [InlineData(-2.0f, -0.9953222650189527)] + [InlineData(3.0f, 0.9999779095030014)] + [InlineData(-3.0f, -0.9999779095030014)] + public void ErfMatchesReferenceValues(float x, double expected) + { + Assert.True( + Math.Abs(math.erf(x) - expected) <= 2e-6, + $"erf({x}) = {math.erf(x)}, expected {expected}"); + } + + [Fact] + public void ErfIsOdd() + { + for (var i = -30; i <= 30; i++) + { + var x = i / 10.0f; + Assert.Equal(-math.erf(x), math.erf(-x)); + } + } + + [Fact] + public void ErfApproachesItsLimitsOfPlusMinusOne() + { + Assert.Equal(1.0f, math.erf(6.0f)); + Assert.Equal(1.0f, math.erf(20.0f)); + Assert.Equal(-1.0f, math.erf(-6.0f)); + Assert.Equal(-1.0f, math.erf(-20.0f)); + } + + [Fact] + public void ErfinvInvertsErf() + { + for (var i = -999; i <= 999; i++) + { + var y = i / 1000.0f; + var x = math.erfinv(y); + var roundTrip = math.erf(x); + Assert.True( + MathF.Abs(roundTrip - y) < 1e-5f, + $"erf(erfinv({y})) = {roundTrip}"); + } + } + + // Reference values from bisecting the platform's double-precision erf (Python's math.erf, + // glibc-backed), not this package's own erf, to 200 iterations over [-6, 6] -- effectively + // exact at float precision. This is what ErfinvInvertsErf cannot be: a check against this + // package's own erf only proves the two functions agree with each other, not that either is + // numerically correct. Soul measured the round-trip test alone to be blind to a coefficient + // change that degraded erfinv's worst error 300-400x (5.0e-7 to 2.0e-4). + [Theory] + // Expected values are computed for the actual float32-rounded input (e.g. the literal + // 0.999f is really 0.9990000128746033 as a double), not the decimal literal, because + // erfinv's derivative diverges near +-1: at y = 0.999f that rounding alone moves the + // true answer by ~2.6e-6, larger than the test's own error bar, if ignored. + [InlineData(0.0f, 0.0)] + [InlineData(0.1f, 0.08885599182530649)] + [InlineData(-0.1f, -0.08885599182530649)] + [InlineData(0.3f, 0.2724627261055238)] + [InlineData(-0.3f, -0.2724627261055238)] + [InlineData(0.5f, 0.4769362762044699)] + [InlineData(-0.5f, -0.4769362762044699)] + [InlineData(0.7f, 0.7328690598827587)] + [InlineData(-0.7f, -0.732869059882759)] + [InlineData(0.9f, 1.1630870719457724)] + [InlineData(-0.9f, -1.1630870719457729)] + [InlineData(0.95f, 1.3859037522360764)] + [InlineData(-0.95f, -1.3859037522360773)] + [InlineData(0.99f, 1.8213866009002766)] + [InlineData(-0.99f, -1.8213866009002793)] + [InlineData(0.999f, 2.3267563267961435)] + [InlineData(-0.999f, -2.3267563267961657)] + [InlineData(0.9999f, 2.751035437903192)] + [InlineData(-0.9999f, -2.751035437903383)] + public void ErfinvMatchesReferenceValues(float y, double expected) + { + Assert.True( + Math.Abs(math.erfinv(y) - expected) <= 2e-6, + $"erfinv({y}) = {math.erfinv(y)}, expected {expected}"); + } + + [Fact] + public void ErfinvAtDomainEdgesAndBeyond() + { + // Domain is [-1, 1]. The edges are the correctly-signed infinite limits; anything + // outside, including +-infinity itself, has no real inverse and is NaN. + Assert.Equal(float.PositiveInfinity, math.erfinv(1.0f)); + Assert.Equal(float.NegativeInfinity, math.erfinv(-1.0f)); + Assert.True(float.IsNaN(math.erfinv(1.0001f))); + Assert.True(float.IsNaN(math.erfinv(-1.0001f))); + Assert.True(float.IsNaN(math.erfinv(2.0f))); + Assert.True(float.IsNaN(math.erfinv(-2.0f))); + Assert.True(float.IsNaN(math.erfinv(float.PositiveInfinity))); + Assert.True(float.IsNaN(math.erfinv(float.NegativeInfinity))); + Assert.True(float.IsNaN(math.erfinv(float.NaN))); + } + + // The deleted ErfinvIsMonotonicallyIncreasing asserted a property erfinv does not have: + // Soul measured 9662 backward steps scanning every representable float32 in [0, 0.999999], + // first at y = 0.00022214651, worst drop 5.960464e-8 (one ULP at that scale) -- invisible + // only because that fixture stepped by 0.001. erf has the same shape: 100 backward steps + // over [0, 4.5], worst drop 1.192093e-7. Pin what is actually true: monotone to within a + // tolerance comfortably above that measured noise floor. + [Fact] + public void ErfinvIsMonotonicWithinFloatingPointTolerance() + { + const float tolerance = 1e-6f; + var previous = float.NegativeInfinity; + for (var i = -999; i <= 999; i++) + { + var y = i / 1000.0f; + var x = math.erfinv(y); + Assert.True( + x >= previous - tolerance, + $"erfinv({y}) = {x} dropped more than {tolerance} below previous value {previous}"); + previous = x; + } + } + + // One property the consumer needs, not the whole of correctness: fire control turns + // adjacent erf values into a cell's probability mass, so a backward step in erf would + // show up as a negative mass. An erf that returned a constant would pass this and is + // caught by ErfMatchesReferenceValues instead. + // Soul checked these geometries clean: 16/64/256/1024/4096 cells over + // +-4 sigma, plus a tight 1 sigma spread and a very tight 0.25 sigma spread. + [Theory] + [InlineData(16, 4f)] + [InlineData(64, 4f)] + [InlineData(256, 4f)] + [InlineData(1024, 4f)] + [InlineData(4096, 4f)] + [InlineData(256, 1f)] + [InlineData(64, 0.25f)] + public void ErfCellMassesStayNonNegativeAcrossRealisticGeometries(int cells, float sigmaRange) + { + var lo = -sigmaRange; + var hi = sigmaRange; + var step = (hi - lo) / cells; + var previous = math.erf(lo / MathF.Sqrt(2f)); + for (var i = 1; i <= cells; i++) + { + var edge = lo + step * i; + var current = math.erf(edge / MathF.Sqrt(2f)); + var mass = current - previous; + Assert.True(mass >= 0f, $"cell {i} of {cells} over +-{sigmaRange} sigma has negative mass {mass}"); + previous = current; + } + } } diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index 0398d313..ea37c17e 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -20,6 +20,7 @@ import { bytesToBase64, isLoopbackEndpoint, isUnsignedCertificate, + routeCertificateView, verifyAuthorityRoute, verifyProviderSessionProof, type CultMeshAuthorityIdentity, @@ -870,17 +871,7 @@ async function selectOdinRoute( protocolIds: route.protocolIds, priority: route.priority, generation: route.generation, - ...(route.certificate ? { certificate: { - providerKey: { - keyId: route.certificate.providerKeyId, - x: route.certificate.providerPublicKeyX, - y: route.certificate.providerPublicKeyY, - }, - odinKeyId: route.certificate.odinKeyId, - issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds, - expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds, - signature: route.certificate.signature, - } } : {}), + ...(route.certificate ? { certificate: routeCertificateView(route.certificate) } : {}), }; try { await verifyAuthorityRoute(candidate, trust); diff --git a/packages/cultmesh-ts/native/MANIFEST.txt b/packages/cultmesh-ts/native/MANIFEST.txt new file mode 100644 index 00000000..074975f0 --- /dev/null +++ b/packages/cultmesh-ts/native/MANIFEST.txt @@ -0,0 +1,9 @@ +msquic.version=2.5.9 +msquic.windows.source=https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.OpenSSL/2.5.9 sha256=FEE9A664C0052DEEE66D40CED2E19BFE20BC423DE5AE5234CFC0CEBC22CA3F5E +msquic.linux.source=https://packages.microsoft.com/debian/13/prod/pool/main/libm/libmsquic/libmsquic_2.5.9_amd64.deb sha256=1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba +bridge.commit=e5bf1c9243acb6b76de72c7864e3b79214177199 +build.workflow_run=https://github.com/GameCult/CultLib/actions/runs/35912386052 +build.release=none (TEMPORARY push-to-quic-cut6 trigger, no tag; see quic-native.yml history for the tagged release path) +build.win32-x64.host=windows-2025-vs2026, cl.exe (MSVC) 19.51.36256.0 +build.linux-x64.host=ubuntu-24.04, container debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1, g++ (Debian 14.2.0-19) 14.2.0 +date=2026-09-23T19:54:08Z diff --git a/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt b/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt new file mode 100644 index 00000000..63447fd8 --- /dev/null +++ b/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/cultmesh-ts/native/SHA256SUMS b/packages/cultmesh-ts/native/SHA256SUMS new file mode 100644 index 00000000..a1233d52 --- /dev/null +++ b/packages/cultmesh-ts/native/SHA256SUMS @@ -0,0 +1,4 @@ +9dae391a44d59f12e19baa08a44d86c7e8b4916ca5d40007a42d65913ce0896f *win32-x64/gamecult_mesh_quic_native.dll +c17c65813fe007eb486a5270d244f3659261355795b27abb99bd19ff77539263 *win32-x64/msquic.dll +0ee3d28324c79a5b0135368187e7bd75c1969ad94fc6985755e81de4ec96e430 *linux-x64/libgamecult_mesh_quic_native.so +c09a72f9b3bfd9119044b305c6064731c1e1879411e6f54276474fb58b524b68 *linux-x64/libmsquic.so.2 diff --git a/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so b/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so new file mode 100644 index 00000000..7363d1cb Binary files /dev/null and b/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so differ diff --git a/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 b/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 new file mode 100644 index 00000000..e14af169 Binary files /dev/null and b/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 differ diff --git a/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll b/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll new file mode 100644 index 00000000..da7be3c0 Binary files /dev/null and b/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll differ diff --git a/packages/cultmesh-ts/native/win32-x64/msquic.dll b/packages/cultmesh-ts/native/win32-x64/msquic.dll new file mode 100644 index 00000000..71107c2c Binary files /dev/null and b/packages/cultmesh-ts/native/win32-x64/msquic.dll differ diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index 0ca8afb3..65a32110 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -12,13 +12,14 @@ } }, "files": [ - "dist" + "dist", + "native" ], "scripts": { "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json", "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false", - "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test dist-test/test/*.test.js" + "test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js" }, "keywords": [ "cultmesh", @@ -44,7 +45,8 @@ "dependencies": { "@msgpack/msgpack": "^3.1.2", "@gamecult/cultcache-ts": "^0.14.0", - "cultnet-ts": "^0.2.0" + "cultnet-ts": "^0.2.0", + "koffi": "3.3.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index ef532359..ab99639d 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -34,7 +34,18 @@ import { type CultNetWireContract, } from "cultnet-ts"; +import { + CultMeshQuicRealtimeConnector, + CultMeshQuicRealtimeProvider, + CultMeshQuicRealtimeSessionManager, + parseQuicRealtimeEndpoint, + type CultMeshQuicRealtimeConnectorOptions, + type CultMeshQuicRealtimeProviderOptions, + type CultMeshQuicRealtimeSessionManagerOptions, +} from "./realtime-quic"; + export * from "./realtime-wire"; +export * from "./realtime-quic"; export * from "./provider-session"; export * from "./provider-session-wire"; export * from "./provider-rudp-transport"; @@ -6269,6 +6280,28 @@ export class CultMesh { } return CultMesh.createRudpPeerForPeer(runtimeId, connectionId, peer, options); } + + public static parseQuicRealtimeEndpoint(endpoint: string): ReturnType { + return parseQuicRealtimeEndpoint(endpoint); + } + + public static createQuicRealtimeConnector( + options: CultMeshQuicRealtimeConnectorOptions = {}, + ): CultMeshQuicRealtimeConnector { + return new CultMeshQuicRealtimeConnector(options); + } + + public static createQuicRealtimeSessionManager( + options: CultMeshQuicRealtimeSessionManagerOptions, + ): CultMeshQuicRealtimeSessionManager { + return new CultMeshQuicRealtimeSessionManager(options); + } + + public static createQuicRealtimeProvider( + options: CultMeshQuicRealtimeProviderOptions, + ): Promise { + return CultMeshQuicRealtimeProvider.listen(options); + } } function cultMeshSchemaFromDefinition( diff --git a/packages/cultmesh-ts/src/realtime-quic-native.ts b/packages/cultmesh-ts/src/realtime-quic-native.ts new file mode 100644 index 00000000..52b0ce01 --- /dev/null +++ b/packages/cultmesh-ts/src/realtime-quic-native.ts @@ -0,0 +1,583 @@ +// The koffi binding for `native/GameCult.Mesh.Quic.Native`'s C ABI v2 +// (`include/cultmesh_quic_native.h`). This file is the only place `koffi` is +// imported, and it is imported lazily, inside `loadBindings`, so a consumer +// that never touches QUIC (AetheriaEve Electron, Odin, Stonks, weksa) loads no +// native module at all. +// +// No koffi callbacks. `cultmesh_quic_next_event` is the single blocking +// export; `CultMeshQuicNativeRuntime` runs it on koffi's async thread pool in +// one loop per process-wide runtime and dispatches decoded events to +// registered listener/connection handlers by id. Every other export is a +// plain synchronous call. +// +// Binary resolution: `CULTMESH_QUIC_NATIVE_DIR` when set (the development +// loop and CI point it at `artifacts/quic-native/` straight from a +// build), otherwise `path.join(__dirname, "..", "native", "-")` +// (the committed tree a later cut lands; `__dirname` is `dist/` at runtime, so +// this path holds in the repo, an `npm pack` tarball and a pnpm `file:` copy +// alike). No `node_modules` lookup, no platform package, no registry. +// +// Two koffi quirks this file works around, both probed against the pinned +// 3.3.0 build: `_Out_` does not auto-allocate storage, so every out +// parameter is `koffi.alloc(type, 1)` written by the call and read back with +// `koffi.decode(buffer, type)`; and a decoded 64-bit value (`void *`, +// `uint64_t`) comes back as a plain `number` when it fits a safe integer, +// not a `bigint`. `toBigInt64` below is the one place that coerces either +// into the `bigint` every exported type in this module promises. + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +/** One decoded native event, payload already sliced to `payload_length`. */ +export interface CultMeshQuicNativeEvent { + readonly type: number; + readonly streamKind: number; + readonly listenerId: bigint; + readonly connectionId: bigint; + readonly streamId: bigint; + readonly code: bigint; + readonly status: number; + readonly payload: Uint8Array; +} + +export const CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION = 1; +export const CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED = 2; +export const CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED = 3; +export const CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN = 4; +export const CULTMESH_QUIC_EVENT_STREAM_STARTED = 5; +export const CULTMESH_QUIC_EVENT_STREAM_FRAME = 6; +export const CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE = 7; +export const CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN = 8; +export const CULTMESH_QUIC_EVENT_LISTENER_STOPPED = 9; + +export const CULTMESH_QUIC_SEND_COMPLETED = 0; +export const CULTMESH_QUIC_SEND_CANCELED = 1; + +export const CULTMESH_QUIC_STREAM_RELIABLE = 1; +export const CULTMESH_QUIC_STREAM_LATEST_ONLY = 2; + +export const CULTMESH_QUIC_RESULT_BAD_CALL = -1; +export const CULTMESH_QUIC_RESULT_BAD_ARGUMENT = -2; + +type NativeEventListener = (event: CultMeshQuicNativeEvent) => void; +/** Invoked once when a connection is faulted: a throwing listener, a malformed + * frame, or any other error raised while dispatching an event for it. */ +type NativeFaultHandler = (error: Error) => void; + +/** koffi decodes a 64-bit value as `number` when it fits a safe integer and as + * `bigint` otherwise (probed on the pinned 3.3.0 build); this is the single + * place that coerces either into the `bigint` every exported type promises. */ +function toBigInt64(value: number | bigint): bigint { + return typeof value === "bigint" ? value : BigInt(value); +} + +/** The opaque native runtime handle: a `void *`, decoded by koffi as a `bigint`. */ +export type CultMeshQuicNativeHandle = bigint; + +interface NativeEventStruct { + type: number; + stream_kind: number; + listener_id: bigint; + connection_id: bigint; + stream_id: bigint; + code: bigint; + status: number; + payload_length: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KoffiLib = any; + +/** `nextEventAsync`'s signature, typed explicitly because `promisify` cannot + * infer one from koffi's untyped (`any`) async export. */ +type NextEventAsyncFn = ( + runtime: CultMeshQuicNativeHandle, + timeoutMs: number, + outEvent: unknown, + payload: Uint8Array | null, + payloadCapacity: number, + outRequired: unknown, +) => Promise; + +/** + * The loaded native library: koffi itself, the declared struct, and the + * twelve raw v2 exports. Untyped (koffi is `any`); the runtime class methods + * below are the type surface for these calls, so wrapping them a second time + * in a typed interface would only restate what the methods already say. The + * type is inferred from `buildLibrary`'s return value instead of restated. + */ +// eslint-disable-next-line @typescript-eslint/no-use-before-define +type LoadedNativeLibrary = ReturnType; + +let libraryCache: LoadedNativeLibrary | undefined; + +function platformArchDir(): string { + return `${process.platform}-${process.arch}`; +} + +function platformFileNames(): { bridge: string; dependency: string } { + if (process.platform === "win32") { + return { bridge: "gamecult_mesh_quic_native.dll", dependency: "msquic.dll" }; + } + if (process.platform === "linux") { + return { bridge: "libgamecult_mesh_quic_native.so", dependency: "libmsquic.so.2" }; + } + throw new Error(`CultMesh QUIC native binding does not support platform '${process.platform}'.`); +} + +function resolveNativeDir(): string { + const configured = process.env.CULTMESH_QUIC_NATIVE_DIR; + if (configured && configured.length > 0) return configured; + return join(__dirname, "..", "native", platformArchDir()); +} + +/** Loads koffi, resolves the binary directory, and declares the ABI. */ +function buildLibrary() { + // Untyped on purpose: koffi's own type declarations are ESM-flavored and + // fight a CommonJS `typeof import(...)` reference under Node16 resolution. + // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any + const koffi: any = require("koffi"); + const { bridge, dependency } = platformFileNames(); + const dir = resolveNativeDir(); + const bridgePath = join(dir, bridge); + const dependencyPath = join(dir, dependency); + if (!existsSync(bridgePath) || !existsSync(dependencyPath)) { + const envDir = process.env.CULTMESH_QUIC_NATIVE_DIR; + throw new Error( + "CultMesh QUIC native binaries were not found. Tried " + + `'${dir}' (${envDir ? "CULTMESH_QUIC_NATIVE_DIR" : "the package's committed native/ tree"}). ` + + "Set CULTMESH_QUIC_NATIVE_DIR to a directory built by scripts/build-quic-native.ps1 or " + + "scripts/build-quic-native.sh, or ship the committed native/ tree.", + ); + } + + // The dependent library must be resolvable before the bridge loads. + koffi.load(dependencyPath); + const bridgeLib = koffi.load(bridgePath); + + const event = koffi.struct("cultmesh_quic_event", { + type: "uint32_t", + stream_kind: "uint32_t", + listener_id: "uint64_t", + connection_id: "uint64_t", + stream_id: "uint64_t", + code: "uint64_t", + status: "int32_t", + payload_length: "int32_t", + reserved: koffi.array("uint8_t", 16), + }); + + const runtimeOpenFn = bridgeLib.func( + "int32_t cultmesh_quic_runtime_open(str app_name, void **out_runtime)", + ); + const runtimeCloseFn = bridgeLib.func("void cultmesh_quic_runtime_close(void *runtime)"); + const listenerOpenFn = bridgeLib.func( + "int32_t cultmesh_quic_listener_open(void *runtime, str host, uint16_t port, " + + "uint8_t *pkcs12, int32_t pkcs12_length, str password, " + + "uint64_t *out_listener_id, uint16_t *out_bound_port)", + ); + const listenerCloseFn = bridgeLib.func( + "void cultmesh_quic_listener_close(void *runtime, uint64_t listener_id)", + ); + const connectionOpenFn = bridgeLib.func( + "int32_t cultmesh_quic_connection_open(void *runtime, str host, uint16_t port, " + + "uint64_t *out_connection_id)", + ); + const connectionCertificateCompleteFn = bridgeLib.func( + "int32_t cultmesh_quic_connection_certificate_complete(void *runtime, uint64_t connection_id, int32_t accept)", + ); + const connectionShutdownFn = bridgeLib.func( + "void cultmesh_quic_connection_shutdown(void *runtime, uint64_t connection_id, uint64_t code)", + ); + const streamOpenFn = bridgeLib.func( + "int32_t cultmesh_quic_stream_open(void *runtime, uint64_t connection_id, uint8_t kind, " + + "uint64_t *out_stream_id)", + ); + const streamSendFrameFn = bridgeLib.func( + "int32_t cultmesh_quic_stream_send_frame(void *runtime, uint64_t stream_id, " + + "uint8_t *encoded_frame, int32_t length, int32_t fin)", + ); + const streamShutdownFn = bridgeLib.func( + "void cultmesh_quic_stream_shutdown(void *runtime, uint64_t stream_id, uint64_t code)", + ); + const nextEventFn = bridgeLib.func( + "int32_t cultmesh_quic_next_event(void *runtime, int32_t timeout_ms, " + + `${event.name} *out_event, uint8_t *payload, int32_t payload_capacity, ` + + "int32_t *out_required)", + ); + const lastErrorFn = bridgeLib.func( + "int32_t cultmesh_quic_last_error(void *runtime, uint8_t *destination, int32_t capacity)", + ); + + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { promisify } = require("node:util") as typeof import("node:util"); + const nextEventAsync = promisify(nextEventFn.async) as NextEventAsyncFn; + + return { + koffi, + event, + runtimeOpenFn, + runtimeCloseFn, + listenerOpenFn, + listenerCloseFn, + connectionOpenFn, + connectionCertificateCompleteFn, + connectionShutdownFn, + streamOpenFn, + streamSendFrameFn, + streamShutdownFn, + nextEventAsync, + lastErrorFn, + }; +} + +/** Lazily builds and caches the loaded native library. */ +function loadBindings(): LoadedNativeLibrary { + if (libraryCache) return libraryCache; + libraryCache = buildLibrary(); + return libraryCache; +} + +function readLastError(lib: LoadedNativeLibrary, runtime: CultMeshQuicNativeHandle): string { + const buffer = Buffer.alloc(1024); + const written = lib.lastErrorFn(runtime, buffer, buffer.length); + return written > 0 ? buffer.subarray(0, written).toString("utf8") : "CultMesh native QUIC call failed."; +} + +/** + * One MsQuic registration and one event queue, shared process-wide. + * `open`/`release` are reference-counted: the underlying runtime opens on the + * first `open` and closes on the last matching `release`. `open`/`release` + * calls do not need to be paired 1:1 by the same caller as long as the counts + * balance overall. + */ +export class CultMeshQuicNativeRuntime { + private static shared: CultMeshQuicNativeRuntime | undefined; + private static refCountValue = 0; + + /** Outstanding `open()` references not yet matched by `release()`. Test-visible: the UAF and eviction regression tests assert it returns to its baseline. */ + static get refCount(): number { + return CultMeshQuicNativeRuntime.refCountValue; + } + + private readonly handle: CultMeshQuicNativeHandle; + private readonly lib: LoadedNativeLibrary; + private readonly listenerListeners = new Map(); + private readonly connectionListeners = new Map< + bigint, + { listener: NativeEventListener; onFault?: NativeFaultHandler } + >(); + private closed = false; + private pumpLoop: Promise; + + /** + * `nextEvent`'s out-parameter buffers, allocated once and reused for every + * call instead of per call: the pump is single-threaded with at most one + * `nextEvent` in flight (`nextEventAsync` is only ever awaited, never + * fired concurrently), so decode-then-reuse is safe. Freed once in + * `release()` after the pump loop has exited. + */ + private readonly outEventBuffer: unknown; + private readonly outRequiredBuffer: unknown; + + private constructor(handle: CultMeshQuicNativeHandle, lib: LoadedNativeLibrary) { + this.handle = handle; + this.lib = lib; + this.outEventBuffer = lib.koffi.alloc(lib.event, 1); + this.outRequiredBuffer = lib.koffi.alloc("int32_t", 1); + this.pumpLoop = this.pump(); + } + + static async open(): Promise { + if (!CultMeshQuicNativeRuntime.shared) { + const lib = loadBindings(); + const out = lib.koffi.alloc("void *", 1); + const status = lib.runtimeOpenFn(null, out); + if (status !== 0) { + lib.koffi.free(out); + throw new Error(`cultmesh_quic_runtime_open failed with status ${status}.`); + } + const handle = toBigInt64(lib.koffi.decode(out, "void *")); + lib.koffi.free(out); + CultMeshQuicNativeRuntime.shared = new CultMeshQuicNativeRuntime(handle, lib); + } + CultMeshQuicNativeRuntime.refCountValue += 1; + return CultMeshQuicNativeRuntime.shared; + } + + /** + * Takes one more reference on this already-open runtime, synchronously — + * unlike `open()`, this never awaits a runtime-open call, so a caller + * already holding a live instance (a provider handing a reference to a + * newly accepted peer) can attach it in the same synchronous turn instead + * of crossing a microtask boundary that would leave the event a caller is + * mid-dispatching unowned until the next tick. + */ + retain(): CultMeshQuicNativeRuntime { + CultMeshQuicNativeRuntime.refCountValue += 1; + return this; + } + + /** + * Drops one reference; closes the shared runtime once none remain. Refuses + * a release when no reference is outstanding. The bridge header (section 4) + * forbids any host call beginning once `cultmesh_quic_runtime_close` has + * started, so this marks the runtime closed and waits for the pump to exit + * (it wakes within its 250 ms `nextEvent` timeout) before making that call: + * a queued, not-yet-started `nextEvent` call must never begin after close + * and run against freed memory. + */ + async release(): Promise { + if (CultMeshQuicNativeRuntime.refCountValue === 0) { + throw new Error("CultMeshQuicNativeRuntime.release called with no outstanding reference."); + } + CultMeshQuicNativeRuntime.refCountValue -= 1; + if (CultMeshQuicNativeRuntime.refCountValue > 0) return; + this.closed = true; + CultMeshQuicNativeRuntime.shared = undefined; + await this.pumpLoop; + this.lib.runtimeCloseFn(this.handle); + this.lib.koffi.free(this.outEventBuffer); + this.lib.koffi.free(this.outRequiredBuffer); + } + + onListenerEvent(listenerId: bigint, listener: NativeEventListener): void { + this.listenerListeners.set(listenerId, listener); + } + + offListenerEvent(listenerId: bigint): void { + this.listenerListeners.delete(listenerId); + } + + /** + * Registers the event listener for one connection. `onFault`, if given, is + * invoked exactly once if `listener` (or anything it calls synchronously, + * such as a caller-supplied certificate validator) throws while dispatching + * an event for this connection: the pump isolates the fault to this + * connection, shuts it down at the native level, removes the listener, and + * keeps running for every other connection. + */ + onConnectionEvent(connectionId: bigint, listener: NativeEventListener, onFault?: NativeFaultHandler): void { + this.connectionListeners.set(connectionId, { listener, onFault }); + } + + offConnectionEvent(connectionId: bigint): void { + this.connectionListeners.delete(connectionId); + } + + listenerOpen( + host: string | null, + port: number, + pkcs12: Uint8Array, + password: string | null, + ): { listenerId: bigint; boundPort: number } { + const outListenerId = this.lib.koffi.alloc("uint64_t", 1); + const outBoundPort = this.lib.koffi.alloc("uint16_t", 1); + try { + const status = this.lib.listenerOpenFn( + this.handle, + host, + port, + pkcs12, + pkcs12.length, + password, + outListenerId, + outBoundPort, + ); + if (status !== 0) { + throw new Error(`cultmesh_quic_listener_open failed: ${readLastError(this.lib, this.handle)}`); + } + return { + listenerId: toBigInt64(this.lib.koffi.decode(outListenerId, "uint64_t")), + boundPort: this.lib.koffi.decode(outBoundPort, "uint16_t") as number, + }; + } finally { + this.lib.koffi.free(outListenerId); + this.lib.koffi.free(outBoundPort); + } + } + + listenerClose(listenerId: bigint): void { + this.lib.listenerCloseFn(this.handle, listenerId); + this.offListenerEvent(listenerId); + } + + connectionOpen(host: string, port: number): bigint { + const out = this.lib.koffi.alloc("uint64_t", 1); + try { + const status = this.lib.connectionOpenFn(this.handle, host, port, out); + if (status !== 0) { + throw new Error(`cultmesh_quic_connection_open failed: ${readLastError(this.lib, this.handle)}`); + } + return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); + } finally { + this.lib.koffi.free(out); + } + } + + connectionCertificateComplete(connectionId: bigint, accept: boolean): void { + const status = this.lib.connectionCertificateCompleteFn(this.handle, connectionId, accept ? 1 : 0); + if (status !== 0) { + throw new Error( + `cultmesh_quic_connection_certificate_complete failed: ${readLastError(this.lib, this.handle)}`, + ); + } + } + + connectionShutdown(connectionId: bigint, code: bigint): void { + this.lib.connectionShutdownFn(this.handle, connectionId, code); + } + + streamOpen(connectionId: bigint, kind: number): bigint { + const out = this.lib.koffi.alloc("uint64_t", 1); + try { + const status = this.lib.streamOpenFn(this.handle, connectionId, kind, out); + if (status !== 0) { + throw new Error(`cultmesh_quic_stream_open failed: ${readLastError(this.lib, this.handle)}`); + } + return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); + } finally { + this.lib.koffi.free(out); + } + } + + streamSendFrame(streamId: bigint, encodedFrame: Uint8Array, fin: boolean): void { + const status = this.lib.streamSendFrameFn(this.handle, streamId, encodedFrame, encodedFrame.length, fin ? 1 : 0); + if (status !== 0) { + throw new Error(`cultmesh_quic_stream_send_frame failed: ${readLastError(this.lib, this.handle)}`); + } + } + + streamShutdown(streamId: bigint, code: bigint): void { + this.lib.streamShutdownFn(this.handle, streamId, code); + } + + /** + * Isolates the faulting connection so one bad event never brings down the + * pump: a malformed frame, a stream-kind/delivery mismatch, or a throwing + * caller-supplied listener (certificate validator, waiter) faults only its + * own connection. The listener is removed, the connection is shut down at + * the native level, and `onFault` (if registered) is told, so pending and + * future work on that connection can reject with the error. Every other + * connection, and the pump itself, keep running. + */ + private dispatch(event: NativeEventStruct, payload: Uint8Array): void { + const decoded: CultMeshQuicNativeEvent = { + type: event.type, + streamKind: event.stream_kind, + listenerId: event.listener_id, + connectionId: event.connection_id, + streamId: event.stream_id, + code: event.code, + status: event.status, + payload, + }; + if (decoded.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION || decoded.type === CULTMESH_QUIC_EVENT_LISTENER_STOPPED) { + try { + this.listenerListeners.get(decoded.listenerId)?.(decoded); + } catch (error) { + // No fault contract for listener-level events today; do not let a + // throwing handler crash the pump. + this.reportUnhandledDispatchError(error); + } + return; + } + const entry = this.connectionListeners.get(decoded.connectionId); + if (!entry) return; + try { + entry.listener(decoded); + } catch (error) { + this.faultConnection(decoded.connectionId, error instanceof Error ? error : new Error(String(error)), entry.onFault); + } + } + + private faultConnection(connectionId: bigint, error: Error, onFault: NativeFaultHandler | undefined): void { + this.connectionListeners.delete(connectionId); + try { + this.lib.connectionShutdownFn(this.handle, connectionId, 0n); + } catch { + // Best-effort: the connection may already be gone. + } + if (onFault) { + try { + onFault(error); + } catch (handlerError) { + // The fault handler itself is caller-supplied code; a throw here + // must not escape into the pump any more than the original fault did. + this.reportUnhandledDispatchError(handlerError); + } + } else { + this.reportUnhandledDispatchError(error); + } + } + + private reportUnhandledDispatchError(error: unknown): void { + // eslint-disable-next-line no-console + console.error("CultMesh QUIC native event dispatch failed with no fault handler registered:", error); + } + + private async nextEvent( + timeoutMs: number, + payload: Uint8Array | null, + payloadCapacity: number, + ): Promise<{ status: number; event: NativeEventStruct | null; required: number }> { + // Reuses the buffers allocated once in the constructor instead of + // allocating fresh ones per call: the pump awaits each `nextEventAsync` + // before issuing the next one, so at most one call is ever in flight + // against them, and both are fully decoded here before the buffers can + // be reused by the pump's next call. + const status = await this.lib.nextEventAsync( + this.handle, + timeoutMs, + this.outEventBuffer, + payload, + payloadCapacity, + this.outRequiredBuffer, + ); + const decoded = + status === 1 ? (this.lib.koffi.decode(this.outEventBuffer, this.lib.event) as NativeEventStruct) : null; + return { + status, + event: decoded && { + ...decoded, + listener_id: toBigInt64(decoded.listener_id), + connection_id: toBigInt64(decoded.connection_id), + stream_id: toBigInt64(decoded.stream_id), + code: toBigInt64(decoded.code), + }, + required: this.lib.koffi.decode(this.outRequiredBuffer, "int32_t") as number, + }; + } + + private async pump(): Promise { + while (!this.closed) { + let first: Awaited>; + try { + first = await this.nextEvent(250, null, 0); + } catch { + if (this.closed) return; + continue; + } + if (this.closed) return; + if (first.status === 0 || first.status === CULTMESH_QUIC_RESULT_BAD_CALL) continue; + if (first.status === 1 && first.event) { + this.dispatch(first.event, new Uint8Array(0)); + continue; + } + if (first.status === 2 && first.required > 0) { + const buffer = Buffer.alloc(first.required); + let second: Awaited>; + try { + second = await this.nextEvent(0, buffer, buffer.length); + } catch { + if (this.closed) return; + continue; + } + if (second.status === 1 && second.event) { + this.dispatch(second.event, buffer.subarray(0, second.event.payload_length)); + } + continue; + } + } + } +} diff --git a/packages/cultmesh-ts/src/realtime-quic.ts b/packages/cultmesh-ts/src/realtime-quic.ts new file mode 100644 index 00000000..c368c96f --- /dev/null +++ b/packages/cultmesh-ts/src/realtime-quic.ts @@ -0,0 +1,1329 @@ +// The Node QUIC realtime plane: a consumer transport and connector over the +// native v2 bridge, and the session manager that binds an advertised route to +// a connected, authority-verified transport. +// +// Provider and consumer semantics (which stream kind carries which delivery +// mode, the generation filter, the certificate pin decision) are pinned rule +// for rule against the C# reference, `src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs` +// (the connector) and `src/GameCult.Mesh/CultMeshSessions.cs` (the session +// manager's binding, ordering and racing). +// +// This module never touches `koffi` directly; every native call goes through +// `CultMeshQuicNativeRuntime` in `realtime-quic-native.ts`, which loads it +// lazily. No Odin WebSocket rendezvous lives here (Q7, ruled A): a lookup +// source is a port this module takes, not a transport it owns. + +import { createHash } from "node:crypto"; +import { createSecureContext } from "node:tls"; + +import { + routeCertificateView, + trimCSharp, + verifyAuthorityRoute, + type CultMeshAuthorityTrustPolicy, +} from "cultnet-ts"; +import type { CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; + +import { + decodeRealtimeFrame, + encodeRealtimeFrame, + CULTMESH_REALTIME_CONNECTION_CLOSE_CODE, + type CultMeshRealtimeFrame, +} from "./realtime-wire"; +import { + CultMeshQuicNativeRuntime, + CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, + CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED, + CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN, + CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, + CULTMESH_QUIC_EVENT_STREAM_FRAME, + CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE, + CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN, + CULTMESH_QUIC_EVENT_STREAM_STARTED, + CULTMESH_QUIC_SEND_CANCELED, + CULTMESH_QUIC_STREAM_LATEST_ONLY, + CULTMESH_QUIC_STREAM_RELIABLE, + type CultMeshQuicNativeEvent, +} from "./realtime-quic-native"; + +/** `CultMeshProtocols.RealtimeState.Value` in the C# reference. */ +export const CULTMESH_REALTIME_STATE_PROTOCOL_ID = "cultmesh.realtime_state.v1"; + +/** The QUIC realtime connector's discovery scheme; `CultMeshQuicRealtimeTransportConnector.Scheme`. */ +export const CULTMESH_QUIC_REALTIME_SCHEME = "cultmesh-state+quic"; + +/** Stable provider identity inside one Verse; `CultMeshSessionTarget` in the C# reference. */ +export interface CultMeshRealtimeTarget { + readonly verseId: string; + readonly authorityRuntimeId: string; +} + +/** A physical route candidate; the TS analogue of `CultMeshTransportCandidate`. */ +export interface CultMeshRealtimeCandidate { + readonly endpoint: string; + readonly authorityRuntimeId: string; + readonly priority: number; + readonly generation: string; +} + +/** Transport-neutral realtime plane; the TS analogue of `ICultMeshRealtimeTransport`. */ +export interface CultMeshRealtimeTransport { + readonly transportId: string; + readonly endpoint: string; + sendFrame(frame: CultMeshRealtimeFrame): Promise; + receiveFrame(signal?: AbortSignal): Promise; + dispose(): void; + isVerifiedFor( + verseId: string, + authorityRuntimeId: string, + protocolId: string, + routeGeneration: string, + ): boolean; + /** + * Registers a handler invoked once this transport becomes unusable for any + * reason (explicit `dispose()`, a peer-initiated shutdown, or an internal + * fault). A session manager uses this to evict a dead cached session so the + * next `connect()` redials, mirroring `CultMeshSessions.InvalidateRealtimeSession`. + */ + onDisposed?(handler: () => void): void; +} + +/** Creates realtime transports; the TS analogue of `ICultMeshRealtimeTransportConnector`. */ +export interface CultMeshRealtimeTransportConnector { + readonly connectorId: string; + readonly priority: number; + canConnect(candidate: CultMeshRealtimeCandidate): boolean; + connect( + candidate: CultMeshRealtimeCandidate, + target: CultMeshRealtimeTarget, + signal?: AbortSignal, + ): Promise; +} + +/** A route source the session manager resolves against; Q7's lookup-source port. */ +export interface ICultMeshRealtimeLookupSource { + resolve(target: CultMeshRealtimeTarget): Promise; +} + +/** `CultMeshQuicRealtimeTransportConnector.TryParseEndpoint` (`:98-109`). */ +export function parseQuicRealtimeEndpoint( + endpoint: string, +): { host: string; port: number; certificateSha256?: string } { + let parsed: URL; + try { + parsed = new URL(endpoint); + } catch { + throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); + } + if (parsed.protocol.toLowerCase() !== `${CULTMESH_QUIC_REALTIME_SCHEME}:`) { + throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); + } + if (!parsed.hostname) { + throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); + } + const port = parsed.port ? Number.parseInt(parsed.port, 10) : Number.NaN; + if (!Number.isInteger(port) || port <= 0) { + throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); + } + const pin = parsed.searchParams.get("cert-sha256") ?? undefined; + return { host: parsed.hostname, port, certificateSha256: pin ?? undefined }; +} + +function certificateSha256Hex(der: Uint8Array): string { + return createHash("sha256").update(der).digest("hex"); +} + +/** Shared by `CultMeshQuicRealtimeTransport.sendFrame` and `CultMeshQuicRealtimeProvider.broadcast`: one message, not two copies that can drift. */ +const CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE = + "The native MsQuic connector exposes streams but not QUIC datagrams; unreliable delivery is not supported."; + +interface PendingSend { + resolve(): void; + reject(error: Error): void; +} + +interface LatestGeneration { + producerEpoch: bigint; + sequence: bigint; +} + +function compareGeneration(a: LatestGeneration, b: LatestGeneration): number { + if (a.producerEpoch !== b.producerEpoch) return a.producerEpoch < b.producerEpoch ? -1 : 1; + if (a.sequence === b.sequence) return 0; + return a.sequence < b.sequence ? -1 : 1; +} + +/** A direct entry, or a coalesced `key` to resolve against `pending` at delivery time. */ +type InboxToken = { readonly value: T } | { readonly key: string }; + +/** + * Mirrors the C# reference's `CultMeshRealtimeInbox` + * (`CultMeshQuicRealtimeTransport.cs:598-644`): publishing under `key` + * coalesces to at most one pending entry per key, keeping that key's + * first-arrival queue position while a later publish overwrites its value; + * publishing with no `key` always queues, unbounded. `receive` drains + * whatever is queued before a `complete(error)` rejects further calls. + * Shared by the transport's inbound queue, the provider's fan-in, and (via + * a pump reading it instead of `receive` callers) each peer's outbox. + */ +class CultMeshRealtimeInbox { + private readonly pending = new Map(); + private readonly ready: InboxToken[] = []; + private readonly waiters: { resolve(value: T): void; reject(error: Error): void }[] = []; + private completed = false; + private terminalError: Error | undefined; + + /** Entries queued but not yet delivered: one per ready key, or one per unkeyed entry. Test-visible. */ + get size(): number { + return this.ready.length; + } + + /** Publishes `value`, coalescing on `key` when given. Returns `false` once `complete()` has run. */ + publish(value: T, key?: string): boolean { + if (this.completed) return false; + if (key === undefined) { + this.deliver({ value }); + return true; + } + const alreadyPending = this.pending.has(key); + this.pending.set(key, value); + if (!alreadyPending) this.deliver({ key }); + return true; + } + + /** Resolves the oldest ready entry, or waits for one; rejects once `complete(error)` has drained the backlog. */ + async receive(signal?: AbortSignal): Promise { + if (this.ready.length > 0) return this.take(this.ready.shift()!); + if (this.completed) throw this.terminalError ?? new Error("CultMesh realtime inbox is completed."); + if (signal?.aborted) throw new Error("CultMesh realtime inbox receive aborted."); + return await new Promise((resolve, reject) => { + const onAbort = (): void => { + const index = this.waiters.indexOf(waiter); + if (index >= 0) this.waiters.splice(index, 1); + reject(new Error("CultMesh realtime inbox receive aborted.")); + }; + const waiter = { + resolve: (value: T) => { + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: (error: Error) => { + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }; + signal?.addEventListener("abort", onAbort, { once: true }); + this.waiters.push(waiter); + }); + } + + /** Completes the inbox: entries already queued still drain via `receive`; every call after that rejects with `error`. */ + complete(error?: Error): void { + if (this.completed) return; + this.completed = true; + this.terminalError = error; + for (const waiter of this.waiters.splice(0, this.waiters.length)) { + waiter.reject(error ?? new Error("CultMesh realtime inbox is completed.")); + } + } + + private deliver(token: InboxToken): void { + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve(this.take(token)); + return; + } + this.ready.push(token); + } + + private take(token: InboxToken): T { + if ("value" in token) return token.value; + const value = this.pending.get(token.key)!; + this.pending.delete(token.key); + return value; + } +} + +/** Configures certificate acceptance for an outbound QUIC realtime connection. */ +export interface CultMeshQuicRealtimeConnectorOptions { + readonly handshakeTimeoutMs?: number; + readonly validateProviderCertificate?: ( + target: CultMeshRealtimeTarget, + der: Uint8Array, + ) => boolean; +} + +/** Creates Node-native MsQuic realtime connections. `ConnectorId` is `"msquic-realtime"`. */ +export class CultMeshQuicRealtimeConnector implements CultMeshRealtimeTransportConnector { + private readonly options: CultMeshQuicRealtimeConnectorOptions; + + constructor(options: CultMeshQuicRealtimeConnectorOptions = {}) { + this.options = options; + } + + readonly connectorId = "msquic-realtime"; + readonly priority = 0; + + canConnect(candidate: CultMeshRealtimeCandidate): boolean { + try { + parseQuicRealtimeEndpoint(candidate.endpoint); + return true; + } catch { + return false; + } + } + + async connect( + candidate: CultMeshRealtimeCandidate, + target: CultMeshRealtimeTarget, + signal?: AbortSignal, + ): Promise { + if (signal?.aborted) { + throw new Error("CultMesh QUIC connect aborted."); + } + const { host, port, certificateSha256 } = parseQuicRealtimeEndpoint(candidate.endpoint); + const runtime = await CultMeshQuicNativeRuntime.open(); + let connectionId: bigint; + try { + connectionId = runtime.connectionOpen(host, port); + } catch (error) { + await runtime.release(); + throw error; + } + + const handshakeTimeoutMs = this.options.handshakeTimeoutMs ?? 10_000; + const validator = this.options.validateProviderCertificate; + + return await new Promise((resolve, reject) => { + let settled = false; + + // Every failure path below — an already-aborted signal, the handshake + // timeout, a later abort, and any handshake-time error — settles here + // exactly once: shut the native connection down, drop the runtime + // reference, and reject. + const fail = (error: Error): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + runtime.offConnectionEvent(connectionId); + runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); + void runtime.release(); + reject(error); + }; + + const succeed = (transport: CultMeshRealtimeTransport): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + resolve(transport); + }; + + // Honour a signal already aborted by the time we reach here (it fired + // before `addEventListener` could observe it, so it never would). + if (signal?.aborted) { + fail(new Error("CultMesh QUIC connect aborted.")); + return; + } + + const timer = setTimeout( + () => fail(new Error(`CultMesh QUIC handshake with '${candidate.endpoint}' timed out.`)), + handshakeTimeoutMs, + ); + const onAbort = (): void => fail(new Error("CultMesh QUIC connect aborted.")); + signal?.addEventListener("abort", onAbort, { once: true }); + + // Told by the runtime when this connection faults mid-dispatch (a + // throwing validator during the handshake, or a malformed frame / + // stream-kind mismatch afterwards): reject the still-pending connect, + // or hand the fault to the connected transport. + const onFault = (error: Error): void => { + if (!settled) { + fail(error); + return; + } + transport.fault(error); + }; + + const transport = new CultMeshQuicRealtimeTransport( + candidate.endpoint, + runtime, + connectionId, + target, + candidate.generation, + ); + + runtime.onConnectionEvent( + connectionId, + (event) => { + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { + const der = event.payload; + const accepted = validator + ? validator(target, der) + : certificateSha256 !== undefined && + certificateSha256Hex(der).toLowerCase() === certificateSha256.toLowerCase(); + try { + runtime.connectionCertificateComplete(connectionId, accepted); + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))); + return; + } + if (!accepted) { + fail(new Error(`CultMesh QUIC provider certificate for '${candidate.endpoint}' was rejected.`)); + } + return; + } + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN && !settled) { + const reason = Buffer.from(event.payload).toString("utf8"); + fail(new Error(`CultMesh QUIC connection closed during handshake: ${reason || event.code}`)); + return; + } + transport.handleConnectionEvent(event); + if (!settled && event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) { + succeed(transport); + } + }, + onFault, + ); + }); + } +} + +/** + * `CultMeshQuicRealtimeTransport`, over the native v2 bridge. `TransportId` + * is `"msquic-realtime"`. + */ +export class CultMeshQuicRealtimeTransport implements CultMeshRealtimeTransport { + readonly transportId = "msquic-realtime"; + readonly endpoint: string; + + private readonly runtime: CultMeshQuicNativeRuntime; + private readonly connectionId: bigint; + private readonly target: CultMeshRealtimeTarget; + private readonly routeGeneration: string; + /** Serializes writes to the reliable-ordered outbound stream: each send chains onto this tail. */ + private reliableSendTail: Promise = Promise.resolve(); + private readonly pendingSends = new Map(); + /** Peer-opened stream id to its declared kind, pruned on `STREAM_SHUTDOWN`; see `trackedStreamCount`. */ + private readonly streamKinds = new Map(); + /** Generation staleness filter: the newest `(channel, body)` generation delivered so far. */ + private readonly latestGenerations = new Map(); + /** Inbound frame queue: bounded to one pending latest-only frame per key. */ + private readonly inbox = new CultMeshRealtimeInbox(); + private reliableOutboundStreamId: bigint | undefined; + private disposed = false; + private terminalError: Error | undefined; + private readonly disposalHandlers = new Set<() => void>(); + + constructor( + endpoint: string, + runtime: CultMeshQuicNativeRuntime, + connectionId: bigint, + target: CultMeshRealtimeTarget, + routeGeneration: string, + ) { + this.endpoint = endpoint; + this.runtime = runtime; + this.connectionId = connectionId; + this.target = target; + this.routeGeneration = routeGeneration ?? ""; + } + + isVerifiedFor( + verseId: string, + authorityRuntimeId: string, + protocolId: string, + routeGeneration: string, + ): boolean { + return ( + this.target.verseId === verseId && + this.target.authorityRuntimeId === authorityRuntimeId && + CULTMESH_REALTIME_STATE_PROTOCOL_ID === protocolId && + this.routeGeneration === routeGeneration + ); + } + + /** Number of peer-opened streams still tracked for their kind. Test-visible: proves `STREAM_SHUTDOWN` prunes `streamKinds` instead of leaking one entry per stream for the life of the connection. */ + get trackedStreamCount(): number { + return this.streamKinds.size; + } + + async sendFrame(frame: CultMeshRealtimeFrame): Promise { + if (this.disposed) throw new Error("CultMesh QUIC realtime transport is disposed."); + if (frame.delivery === "unreliable") { + throw new Error(CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE); + } + return this.sendEncodedFrame(frame.delivery, encodeRealtimeFrame(frame)); + } + + /** + * @internal The primitive `sendFrame` itself calls, after encoding. Exposed + * so a caller that already holds the encoded bytes for many peers at once + * — `CultMeshQuicRealtimeProvider.broadcast` fanning a frame out to every + * peer, and each peer's `CultMeshQuicRealtimeProviderOutbox` pump — sends + * them without re-encoding (and re-validating) per peer. + */ + async sendEncodedFrame(delivery: "reliable-ordered" | "latest-only", encoded: Uint8Array): Promise { + if (this.disposed) throw new Error("CultMesh QUIC realtime transport is disposed."); + if (delivery === "reliable-ordered") { + const send = this.reliableSendTail.then(async () => { + if (this.reliableOutboundStreamId === undefined) { + this.reliableOutboundStreamId = this.runtime.streamOpen( + this.connectionId, + CULTMESH_QUIC_STREAM_RELIABLE, + ); + } + await this.sendOnStream(this.reliableOutboundStreamId, encoded, false); + }); + // The tail must advance even if this send rejects, so the next queued + // send is not blocked forever on a settled promise; the caller still + // observes the rejection through `send` itself. + this.reliableSendTail = send.catch(() => {}); + return send; + } + // latest-only: a fresh stream per send, mirroring the C# reference. + const streamId = this.runtime.streamOpen(this.connectionId, CULTMESH_QUIC_STREAM_LATEST_ONLY); + await this.sendOnStream(streamId, encoded, true); + } + + /** + * A pending send registered after `cleanup()` already ran (a `sendFrame` + * queued behind `reliableSendTail` whose microtask lands after `dispose()` + * or a fault settles this transport) would otherwise sit in `pendingSends` + * forever: `cleanup()`'s own reject sweep already ran once and is guarded + * against running again, and `offConnectionEvent` means no later native + * event can resolve it either. Checking `this.disposed` here closes that + * window: any send that loses the race against disposal rejects + * immediately instead of registering an entry nothing will ever settle. + */ + private sendOnStream(streamId: bigint, encoded: Uint8Array, fin: boolean): Promise { + return new Promise((resolve, reject) => { + if (this.disposed) { + reject(this.terminalError ?? new Error("CultMesh QUIC realtime transport is disposed.")); + return; + } + this.pendingSends.set(streamId, { resolve, reject }); + try { + this.runtime.streamSendFrame(streamId, encoded, fin); + } catch (error) { + this.pendingSends.delete(streamId); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + async receiveFrame(signal?: AbortSignal): Promise { + return this.inbox.receive(signal); + } + + /** Registers a handler invoked once, when this transport becomes unusable. */ + onDisposed(handler: () => void): void { + if (this.disposed) { + handler(); + return; + } + this.disposalHandlers.add(handler); + } + + dispose(): void { + if (this.disposed) return; + try { + this.runtime.connectionShutdown(this.connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); + } catch { + // Best-effort: the connection may already be gone. + } + this.cleanup(); + } + + /** + * @internal Called by the connector when the native runtime faults this + * connection mid-dispatch (a malformed frame, a stream-kind mismatch, or a + * throwing caller callback). Mirrors the C# reference's + * `_received.Complete(error)` / `_shutdown.Cancel()`: pending and future + * `receiveFrame` calls reject with `error`, and the connection is torn + * down. The native side has already shut the connection down and removed + * its listener by the time this runs. + */ + fault(error: Error): void { + this.cleanup(error); + } + + /** + * Runs exactly once, however the transport becomes unusable: explicit + * `dispose()`, a peer-initiated `CONNECTION_SHUTDOWN`, or `fault()`. + * Releases the runtime reference before running any `onDisposed` handler, + * so a handler that throws (or blocks) can never delay or skip the + * release; every handler runs in its own try/catch, so one throwing + * handler cannot stop the rest from running. Also rejects every pending + * and future receive/send, so none of those paths can leak the reference + * or hang a waiter forever. + */ + private cleanup(error?: Error): void { + if (this.disposed) return; + this.disposed = true; + this.terminalError = error; + this.runtime.offConnectionEvent(this.connectionId); + const rejection = error ?? new Error("CultMesh QUIC realtime transport is disposed."); + for (const [, pending] of this.pendingSends) pending.reject(rejection); + this.pendingSends.clear(); + this.inbox.complete(rejection); + void this.runtime.release(); + const handlers = [...this.disposalHandlers]; + this.disposalHandlers.clear(); + for (const handler of handlers) { + try { + handler(); + } catch (handlerError) { + // eslint-disable-next-line no-console + console.error("CultMesh QUIC realtime transport onDisposed handler threw:", handlerError); + } + } + } + + /** @internal Routed here by the connector's connection-event registration. */ + handleConnectionEvent(event: CultMeshQuicNativeEvent): void { + switch (event.type) { + case CULTMESH_QUIC_EVENT_STREAM_STARTED: + this.streamKinds.set(event.streamId, event.streamKind); + return; + case CULTMESH_QUIC_EVENT_STREAM_FRAME: + this.onStreamFrame(event); + return; + case CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN: + this.streamKinds.delete(event.streamId); + return; + case CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE: { + const pending = this.pendingSends.get(event.streamId); + if (!pending) return; + this.pendingSends.delete(event.streamId); + if (event.code === BigInt(CULTMESH_QUIC_SEND_CANCELED)) { + pending.reject(new Error("CultMesh QUIC send was canceled.")); + } else { + pending.resolve(); + } + return; + } + case CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN: + this.cleanup(new Error("CultMesh QUIC connection was closed by the remote peer.")); + return; + default: + return; + } + } + + /** + * `ReadStreamAsync`'s stream-kind/delivery consistency check (`:519-524`). + * Throwing here is intentional: the native runtime's dispatch loop (fix 1) + * catches it, faults only this connection, and keeps the pump and every + * other connection running. + */ + private onStreamFrame(event: CultMeshQuicNativeEvent): void { + const kind = this.streamKinds.get(event.streamId); + const frame = decodeRealtimeFrame(event.payload); + if (kind === CULTMESH_QUIC_STREAM_RELIABLE && frame.delivery !== "reliable-ordered") { + throw new Error("Reliable QUIC stream carried incompatible delivery semantics."); + } + if (kind === CULTMESH_QUIC_STREAM_LATEST_ONLY && frame.delivery !== "latest-only") { + throw new Error("Latest-only QUIC stream carried incompatible delivery semantics."); + } + this.publishReceived(frame); + } + + /** + * `PublishReceived`'s per-`(channel, body)` generation filter (`:538-560`), + * plus the inbox's own coalescing: a latest-only frame overwrites the + * still-pending frame for its key rather than queuing beside it, so an + * unread backlog holds at most one frame per `(channel, body)`. + * Reliable-ordered frames queue directly, unbounded, exactly as the C# + * reference's unbounded channel does. + */ + private publishReceived(frame: CultMeshRealtimeFrame): void { + if (frame.delivery === "latest-only") { + const key = frame.channelId + "\u001f" + frame.bodyId; + const candidate: LatestGeneration = { producerEpoch: frame.producerEpoch, sequence: frame.sequence }; + const current = this.latestGenerations.get(key); + if (current && compareGeneration(candidate, current) <= 0) return; + this.latestGenerations.set(key, candidate); + this.inbox.publish(frame, key); + return; + } + this.inbox.publish(frame); + } +} + +/** + * `TryPublishLatest` plus `SendPublishedFramesAsync` (`:363-367`, `:421-439`), + * over a coalescing outbox (`:572-618`). Owns one peer's outbound + * `latest-only` traffic: `publish` never blocks (it just replaces whatever is + * still pending for the frame's `(channel, body)` key and, if a pump is not + * already draining, starts one), and the pump opens a fresh kind-2 stream per + * frame through the peer's own `sendFrame` (unchanged from the consumer path + * above), sending with `fin` and waiting for `send_complete` before it takes + * the next ready key. A stalled peer therefore accumulates at most one + * pending frame per key and never blocks a caller's `broadcast`. A send + * failure evicts the peer once, the same way a reliable-ordered send failure + * does in `CultMeshQuicRealtimeProvider.broadcast`. + */ +interface OutboxEntry { + readonly frame: CultMeshRealtimeFrame; + readonly encoded: Uint8Array; +} + +class CultMeshQuicRealtimeProviderOutbox { + private readonly inbox = new CultMeshRealtimeInbox(); + private readonly transport: CultMeshQuicRealtimeTransport; + private readonly onSendFailure: (error: Error) => void; + private pumping = false; + private disposed = false; + + constructor(transport: CultMeshQuicRealtimeTransport, onSendFailure: (error: Error) => void) { + this.transport = transport; + this.onSendFailure = onSendFailure; + } + + /** + * Enqueues an already-encoded `frame`, coalescing on `(channelId, + * bodyId)`. Never blocks. Takes the encoded bytes rather than encoding its + * own copy so every peer's outbox reuses the one encode `broadcast` (or a + * retained-frame seed) already did for this frame. + */ + publish(frame: CultMeshRealtimeFrame, encoded: Uint8Array): void { + if (this.disposed) return; + const key = frame.channelId + "\u001f" + frame.bodyId; + this.inbox.publish({ frame, encoded }, key); + if (!this.pumping) void this.pump(); + } + + dispose(): void { + this.disposed = true; + this.inbox.complete(); + } + + /** One in flight per peer: the next ready key is taken only after `send_complete`, mirroring `SendPublishedFramesAsync`. */ + private async pump(): Promise { + this.pumping = true; + try { + for (;;) { + if (this.disposed) return; + let entry: OutboxEntry; + try { + entry = await this.inbox.receive(); + } catch { + return; + } + try { + await this.transport.sendEncodedFrame("latest-only", entry.encoded); + } catch (error) { + this.dispose(); + this.onSendFailure(error instanceof Error ? error : new Error(String(error))); + return; + } + } + } finally { + this.pumping = false; + } + } +} + +/** Stable placeholder target for a provider's accepted peer connections: `isVerifiedFor` is a consumer-side concept the provider never calls. */ +const CULTMESH_PROVIDER_PEER_TARGET: CultMeshRealtimeTarget = { verseId: "", authorityRuntimeId: "" }; + +/** Configures a `CultMeshQuicRealtimeProvider` listener. */ +export interface CultMeshQuicRealtimeProviderOptions { + readonly host: string; + readonly port: number; + readonly serverCertificate: { readonly pkcs12: Uint8Array; readonly password?: string }; + readonly handshakeTimeoutMs?: number; + /** + * The host advertised in `advertisedEndpoint`, when it differs from the + * bind address `host` — the usual case for a public service: `host` is + * typically `0.0.0.0`, an empty string, or an IPv6 wildcard (`::`), none of + * which describe a route a client can dial, and the native listener (which + * `host` also configures) only accepts IP addresses in the first place, not + * a DNS name. Defaults to `host`. + */ + readonly advertisedHost?: string; + /** + * When `false` (the default: every existing caller, test, and interop + * peer calls `receive()`), the provider fans client-originated streams + * into `receive()` as before. When `true`, the provider is publish-only: + * any stream a peer opens faults that connection at `STREAM_STARTED`, + * before a frame can be decoded or queued, so no inbox, generation-map, + * or fan-in entry is ever created for client data; `receive()` itself + * rejects. StreamPixels sets this explicitly — it closes the unbounded + * per-client memory growth Soul's probe found. + */ + readonly acceptClientFrames?: boolean; + /** + * Refuses a new connection at accept once the number of connections this + * provider is tracking (including mid-handshake ones) has reached this + * many: shut down immediately, before any transport, outbox, or + * retained-frame seeding is allocated for it. Existing peers are + * unaffected. Defaults to 1024 — far more than any single StreamPixels + * fan-out needs today, while still bounding per-connection overhead + * against a public UDP port whose pin authenticates only the server, so + * any internet client can dial in. StreamPixels sets its own value. + */ + readonly maxConnections?: number; +} + +/** + * Brackets an IPv6 literal for use in a URL authority (`[::1]:443`), the way + * `URL` itself renders one; a hostname or IPv4 literal passes through + * unchanged, and an already-bracketed literal is left alone. + */ +function formatEndpointHost(host: string): string { + if (host.startsWith("[")) return host; + return host.includes(":") ? `[${host}]` : host; +} + +/** + * Recovers the leaf certificate's DER bytes from a PKCS12 credential, to + * compute the pin a provider advertises. Node has no public PKCS12 decoder; + * `tls.createSecureContext` loads the PKCS12 through OpenSSL, and its + * internal `context.getCertificate()` is the only way to read back the DER + * bytes OpenSSL parsed out of it. Probed against the pinned Node 24 build and + * this package's own test fixture pair (`test/fixtures/quic-test.p12` and + * `quic-test-cert.der`, generated independently by `openssl pkcs12 -export`): + * the returned buffer is byte-identical to the `.der` file and its SHA-256 + * matches the pin the fixture's tests already assert. + */ +function extractLeafCertificateDer(serverCertificate: CultMeshQuicRealtimeProviderOptions["serverCertificate"]): Uint8Array { + const context = createSecureContext({ + pfx: Buffer.from(serverCertificate.pkcs12), + passphrase: serverCertificate.password ?? "", + }) as unknown as { context: { getCertificate(): Buffer | undefined } }; + const der = context.context.getCertificate(); + if (!der || der.length === 0) { + throw new Error("CultMesh QUIC provider certificate has no parseable leaf certificate."); + } + return new Uint8Array(der); +} + +/** + * The StreamPixels role: a TypeScript listener that broadcasts with an + * explicit delivery mode and cannot be backpressured by a slow consumer. + * Rule for rule, this is `CultMeshQuicRealtimeServer` (`CultMeshQuicRealtimeTransport.cs:151-310`): + * `listen` opens a listener and computes the advertised endpoint; + * `broadcast` mirrors `BroadcastAsync` (`:216-235`); `receive` mirrors + * `ReceiveAsync` fanned in from every accepted peer's inbox + * (`CultMeshRealtimeInbox.ReceiveAsync`, `:597-609`); peers are evicted on + * send failure or shutdown, and there is no reconnect logic here, matching + * the C# reference's `AcceptLoopAsync` (`:276-309`). + */ +export class CultMeshQuicRealtimeProvider { + /** `cultmesh-state+quic://:?cert-sha256=`, uppercase — the Unity connector requires the pin. */ + readonly advertisedEndpoint: string; + + private readonly runtime: CultMeshQuicNativeRuntime; + private readonly listenerId: bigint; + private readonly handshakeTimeoutMs: number; + private readonly peers = new Set(); + private readonly outboxes = new Map(); + /** The newest `latest-only` frame broadcast so far per `(channel, body)` key, already + * encoded, seeded into every peer attached after that broadcast. Reliable-ordered frames + * are events, not state, and are never retained. */ + private readonly retained = new Map(); + /** Client-to-provider fan-in, bounded the same way as the transport's inbound queue. */ + private readonly received = new CultMeshRealtimeInbox(); + /** + * Every transport `acceptConnection` has created for a still-live native + * connection, whether or not its handshake has reached CONNECTED and + * joined `peers` yet. A connection whose handshake never reaches CONNECTED + * (a rejected certificate, a stalled client) would otherwise sit on its + * own handshake timer — and hold a runtime reference — for up to + * `handshakeTimeoutMs` after the provider itself was disposed, keeping the + * process alive over a connection nothing can still reach; `dispose()` + * sweeps this set instead of only `peers` to close that gap. + */ + private readonly accepted = new Set(); + private readonly acceptClientFrames: boolean; + private readonly maxConnections: number; + private disposed = false; + + private constructor( + runtime: CultMeshQuicNativeRuntime, + listenerId: bigint, + advertisedEndpoint: string, + handshakeTimeoutMs: number, + acceptClientFrames: boolean, + maxConnections: number, + ) { + this.runtime = runtime; + this.listenerId = listenerId; + this.advertisedEndpoint = advertisedEndpoint; + this.handshakeTimeoutMs = handshakeTimeoutMs; + this.acceptClientFrames = acceptClientFrames; + this.maxConnections = maxConnections; + } + + /** Number of currently accepted peer connections. */ + get connectionCount(): number { + return this.peers.size; + } + + /** Frames fanned in from peers but not yet drained by `receive()`. Test-visible: proves the fan-in queue is bounded. */ + get receiveQueueSize(): number { + return this.received.size; + } + + static async listen(options: CultMeshQuicRealtimeProviderOptions): Promise { + const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; + const der = extractLeafCertificateDer(options.serverCertificate); + const pinHex = certificateSha256Hex(der).toUpperCase(); + const bindHost = options.host && options.host.length > 0 ? options.host : "127.0.0.1"; + const advertisedHost = options.advertisedHost && options.advertisedHost.length > 0 ? options.advertisedHost : bindHost; + + const runtime = await CultMeshQuicNativeRuntime.open(); + let listenerId: bigint; + let boundPort: number; + try { + ({ listenerId, boundPort } = runtime.listenerOpen( + options.host || null, + options.port, + options.serverCertificate.pkcs12, + options.serverCertificate.password ?? null, + )); + } catch (error) { + await runtime.release(); + throw error; + } + + const advertisedEndpoint = `${CULTMESH_QUIC_REALTIME_SCHEME}://${formatEndpointHost(advertisedHost)}:${boundPort}?cert-sha256=${pinHex}`; + const provider = new CultMeshQuicRealtimeProvider( + runtime, + listenerId, + advertisedEndpoint, + handshakeTimeoutMs, + options.acceptClientFrames ?? true, + options.maxConnections ?? 1024, + ); + runtime.onListenerEvent(listenerId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) provider.acceptConnection(event.connectionId); + }); + return provider; + } + + /** + * `BroadcastAsync` (`:216-235`). `latest-only` enqueues into each peer's + * outbox and returns as soon as every peer has accepted the frame into its + * outbox — never once any peer's I/O. `reliable-ordered` awaits every + * peer's `send_complete` (or eviction) before resolving, matching + * `Task.WhenAll` over `SendToConnectedClientAsync` (`:233-255`). + */ + async broadcast(frame: CultMeshRealtimeFrame): Promise { + if (this.disposed) throw new Error("CultMesh QUIC realtime provider is disposed."); + if (frame.delivery === "unreliable") { + throw new Error(CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE); + } + // Encode (and validate) exactly once, up front: an invalid frame throws + // here, to the caller, before touching any peer. The old per-peer encode + // inside `sendFrame` meant an invalid frame failed identically for every + // peer in turn, evicting all of them while `broadcast` itself still + // reported success. + const encoded = encodeRealtimeFrame(frame); + if (frame.delivery === "latest-only") { + const key = frame.channelId + "\u001f" + frame.bodyId; + const current = this.retained.get(key); + const candidate: LatestGeneration = { producerEpoch: frame.producerEpoch, sequence: frame.sequence }; + if (!current || compareGeneration(candidate, current.generation) > 0) { + this.retained.set(key, { frame, encoded, generation: candidate }); + } + for (const outbox of this.outboxes.values()) outbox.publish(frame, encoded); + return; + } + const sends = [...this.peers].map(async (transport) => { + try { + await transport.sendEncodedFrame("reliable-ordered", encoded); + } catch { + transport.dispose(); + } + }); + if (sends.length > 0) await Promise.all(sends); + } + + /** The next client-originated frame, fanned in from every accepted peer. */ + async receive(signal?: AbortSignal): Promise { + if (!this.acceptClientFrames) { + throw new Error( + "CultMesh QUIC realtime provider is publish-only (acceptClientFrames: false); it never accepts client frames.", + ); + } + return this.received.receive(signal); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + try { + this.runtime.listenerClose(this.listenerId); + } catch { + // Best-effort: the listener may already be gone. + } + // `accepted` is every transport this provider ever created for a native + // connection, from the moment `acceptConnection` synchronously builds + // it — whether or not its handshake ever reached CONNECTED and joined + // `peers`. A still-handshaking connection has no other owner to sweep it. + for (const transport of [...this.accepted]) transport.dispose(); + this.received.complete(new Error("CultMesh QUIC realtime provider is disposed.")); + void this.runtime.release(); + } + + /** Coalesces client-sent `latest-only` frames by `(channel, body)`, so a publish-only `receive()` caller (StreamPixels) backs up to at most one pending frame per key. */ + private async pumpReceivedFrom(transport: CultMeshQuicRealtimeTransport): Promise { + for (;;) { + let frame: CultMeshRealtimeFrame; + try { + frame = await transport.receiveFrame(); + } catch { + return; // The transport is disposed/faulted; nothing more to read. + } + if (frame.delivery === "latest-only") { + this.received.publish(frame, frame.channelId + "\u001f" + frame.bodyId); + } else { + this.received.publish(frame); + } + } + } + + /** + * Handles `LISTENER_NEW_CONNECTION`, fully synchronously: one handler owns + * this connection from this point on, so there is no async window in which + * a NEW_CONNECTION-then-CONNECTED pair (the bridge's own event order, + * `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp:731-757,627`) + * can be split across a temporary buffering handler and a later real one. + * `this.runtime.retain()` takes a reference on the provider's own + * already-open runtime instead of `await`ing a fresh + * `CultMeshQuicNativeRuntime.open()`, which is what used to force that + * split: the previous async gap left `CONNECTION_CONNECTED` dispatched + * straight to `transport.handleConnectionEvent` (which has no case for + * it) with nothing to clear the handshake timer, so a healthy peer was + * evicted at `handshakeTimeoutMs` regardless of how well-behaved the + * client was. + */ + private acceptConnection(connectionId: bigint): void { + if (this.disposed) { + this.runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); + return; + } + if (this.accepted.size >= this.maxConnections) { + // Refused before any transport, timer, or outbox exists for it: + // existing accepted connections are untouched. + this.runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); + return; + } + + const transport = new CultMeshQuicRealtimeTransport( + `quic-peer-${connectionId}`, + this.runtime.retain(), + connectionId, + CULTMESH_PROVIDER_PEER_TARGET, + "", + ); + this.accepted.add(transport); + + const handshakeTimer = setTimeout(() => transport.dispose(), this.handshakeTimeoutMs); + transport.onDisposed(() => { + clearTimeout(handshakeTimer); + this.accepted.delete(transport); + this.peers.delete(transport); + this.outboxes.get(transport)?.dispose(); + this.outboxes.delete(transport); + }); + + // CONNECTED fires at most once per connection; `attached` guards against + // ever running the join-`peers` bookkeeping twice. + let attached = false; + this.runtime.onConnectionEvent( + connectionId, + (event) => { + // Publish-only: a peer-opened stream faults this connection alone, + // before `handleConnectionEvent` can record its stream kind or + // decode a frame off it. Throwing here drives the dispatch loop's + // own fault path (`CultMeshQuicNativeRuntime.dispatch`), which + // shuts the connection down and evicts it. + if (!this.acceptClientFrames && event.type === CULTMESH_QUIC_EVENT_STREAM_STARTED) { + throw new Error( + "CultMesh QUIC provider is publish-only (acceptClientFrames: false); it does not accept inbound streams from peers.", + ); + } + transport.handleConnectionEvent(event); + if (attached || event.type !== CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) return; + attached = true; + clearTimeout(handshakeTimer); + // Cut C's retained-frame seeding happens atomically here, before the + // peer joins `peers`: nothing can observe this transport as an + // attached peer with a not-yet-seeded outbox. + const outbox = new CultMeshQuicRealtimeProviderOutbox(transport, () => transport.dispose()); + for (const { frame, encoded } of this.retained.values()) outbox.publish(frame, encoded); + this.outboxes.set(transport, outbox); + this.peers.add(transport); + if (this.acceptClientFrames) void this.pumpReceivedFrom(transport); + }, + (error) => transport.fault(error), + ); + } +} + +/** `CultMeshStaticRealtimeLookupSource`: an array of pre-resolved Verse descriptors. */ +export class CultMeshStaticRealtimeLookupSource implements ICultMeshRealtimeLookupSource { + private readonly verses: readonly CultMeshVerseDescriptorMessage[]; + + constructor(verses: readonly CultMeshVerseDescriptorMessage[]) { + this.verses = verses; + } + + async resolve(target: CultMeshRealtimeTarget): Promise { + return this.verses.filter((verse) => verse.verseId === target.verseId); + } +} + +/** Options for `CultMeshQuicRealtimeSessionManager`. */ +export interface CultMeshQuicRealtimeSessionManagerOptions { + readonly lookupSource: ICultMeshRealtimeLookupSource; + readonly trust: CultMeshAuthorityTrustPolicy; + readonly connectors: readonly CultMeshRealtimeTransportConnector[]; + readonly maxRacedCandidates?: number; +} + +/** + * Binds an advertised route to a connected, authority-verified realtime + * transport. Rule for rule, this is `CultMeshSessionManager`'s realtime path + * in the C# reference (`ConnectRealtimeAsync`, `:833-896`). + */ +export class CultMeshQuicRealtimeSessionManager { + private readonly options: CultMeshQuicRealtimeSessionManagerOptions; + private readonly sessions = new Map(); + /** Single-flight: concurrent `connect()` calls for the same key share one dial. */ + private readonly connecting = new Map>(); + /** One controller per in-flight dial, so `dispose()` and `disconnect()` can cancel it. */ + private readonly dialAborts = new Map(); + private disposed = false; + + constructor(options: CultMeshQuicRealtimeSessionManagerOptions) { + if (options.connectors.length === 0) { + throw new Error("CultMeshQuicRealtimeSessionManager requires at least one realtime connector."); + } + if ((options.maxRacedCandidates ?? 2) <= 0) { + throw new Error("maxRacedCandidates must be positive."); + } + this.options = options; + } + + private sessionKey(target: CultMeshRealtimeTarget): string { + return target.verseId + "\u001f" + target.authorityRuntimeId; + } + + /** + * Resolves, verifies, races and connects; disposes losing candidates. + * Returns the cached session for `target` when one is live. Concurrent + * calls for the same target share one dial (mirrors C#'s `Lazy`); a + * transport that dies later (peer shutdown, an internal fault, or explicit + * disposal) evicts itself from the cache so the next call redials, mirroring + * `CultMeshSessions.InvalidateRealtimeSession` (`:898-913`). + */ + async connect(target: CultMeshRealtimeTarget): Promise { + if (this.disposed) throw new Error("CultMeshQuicRealtimeSessionManager is disposed."); + const key = this.sessionKey(target); + const existing = this.sessions.get(key); + if (existing) return existing; + + const inFlight = this.connecting.get(key); + if (inFlight) return inFlight; + + const controller = new AbortController(); + this.dialAborts.set(key, controller); + const attempt = this.connectOwnedAsync(key, target, controller.signal).finally(() => { + this.connecting.delete(key); + if (this.dialAborts.get(key) === controller) this.dialAborts.delete(key); + }); + this.connecting.set(key, attempt); + return attempt; + } + + private async connectOwnedAsync( + key: string, + target: CultMeshRealtimeTarget, + signal: AbortSignal, + ): Promise { + const transport = await this.dialAsync(key, target, signal); + if (this.disposed || signal.aborted) { + transport.dispose(); + throw new Error( + this.disposed + ? "CultMeshQuicRealtimeSessionManager was disposed while connecting." + : `Dial for '${key}' was cancelled while connecting.`, + ); + } + this.sessions.set(key, transport); + transport.onDisposed?.(() => { + if (this.sessions.get(key) === transport) this.sessions.delete(key); + }); + return transport; + } + + private async dialAsync( + key: string, + target: CultMeshRealtimeTarget, + signal: AbortSignal, + ): Promise { + const verses = await this.options.lookupSource.resolve(target); + const routes = verses + .flatMap((verse) => verse.authorityRoutes ?? []) + .filter((route) => route.authorityRuntimeId === target.authorityRuntimeId) + .filter((route) => route.protocolIds.includes(CULTMESH_REALTIME_STATE_PROTOCOL_ID)); + if (routes.length === 0) { + throw new Error(`No realtime state route was advertised for '${key}'.`); + } + + const verifiedCandidates: CultMeshRealtimeCandidate[] = []; + const verificationFailures: Error[] = []; + for (const route of routes) { + const candidate: CultMeshRealtimeCandidate = { + endpoint: route.endpoint, + authorityRuntimeId: route.authorityRuntimeId, + priority: route.priority, + generation: trimCSharp(route.generation), + }; + try { + await verifyAuthorityRoute( + { + verseId: target.verseId, + authorityRuntimeId: route.authorityRuntimeId, + endpoint: route.endpoint, + protocolIds: route.protocolIds, + priority: route.priority, + generation: route.generation, + certificate: routeCertificateView(route.certificate), + }, + this.options.trust, + ); + verifiedCandidates.push(candidate); + } catch (error) { + verificationFailures.push(error instanceof Error ? error : new Error(String(error))); + } + } + if (verifiedCandidates.length === 0) { + throw new Error( + `No trusted realtime route remained for '${key}': ${verificationFailures.map((error) => error.message).join("; ")}`, + ); + } + + const tiers = new Map>(); + for (const candidate of verifiedCandidates) { + for (const connector of this.options.connectors) { + if (!connector.canConnect(candidate)) continue; + const tier = connector.priority; + const list = tiers.get(tier) ?? []; + list.push({ candidate, connector }); + tiers.set(tier, list); + } + } + if (tiers.size === 0) { + throw new Error(`No realtime connector supports an advertised route for '${key}'.`); + } + + const maxRaced = this.options.maxRacedCandidates ?? 2; + const failures: Error[] = []; + for (const tier of [...tiers.keys()].sort((a, b) => a - b)) { + if (signal.aborted) break; + const entries = [...(tiers.get(tier) ?? [])] + .sort((a, b) => a.candidate.priority - b.candidate.priority) + .slice(0, maxRaced); + const winner = await this.raceFirstSuccessAsync(key, target, entries, failures, signal); + if (winner) return winner; + } + throw new Error( + `No realtime state path connected for '${key}': ${failures.map((error) => error.message).join("; ")}`, + ); + } + + /** + * Races one tier's candidates and returns the first verified success, + * mirroring C#'s `Task.WhenAny` loop (`:851-866`): as soon as one candidate + * both connects and proves `isVerifiedFor`, the rest are aborted (an + * `AbortSignal` is threaded into `connector.connect`) and disposed once + * they settle, without blocking the return. `Promise.allSettled` here would + * instead wait out every candidate, including a dead route stalled on its + * own handshake timeout. + */ + private async raceFirstSuccessAsync( + key: string, + target: CultMeshRealtimeTarget, + entries: ReadonlyArray<{ candidate: CultMeshRealtimeCandidate; connector: CultMeshRealtimeTransportConnector }>, + failures: Error[], + outerSignal: AbortSignal, + ): Promise { + const controller = new AbortController(); + if (outerSignal.aborted) controller.abort(); + else outerSignal.addEventListener("abort", () => controller.abort(), { once: true }); + const pending = new Set( + entries.map((entry) => this.connectCandidateAsync(key, target, entry, controller.signal)), + ); + let winner: CultMeshRealtimeTransport | undefined; + while (pending.size > 0 && !winner) { + const settled = await Promise.race( + [...pending].map((attempt) => + attempt.then( + (value) => ({ attempt, ok: true as const, value }), + (error) => ({ attempt, ok: false as const, error }), + ), + ), + ); + pending.delete(settled.attempt); + if (settled.ok) { + winner = settled.value; + } else { + failures.push(settled.error instanceof Error ? settled.error : new Error(String(settled.error))); + } + } + if (!winner) return undefined; + + controller.abort(); + // The remaining candidates may already be in flight past the point an + // abort signal helps; dispose whatever they eventually produce, without + // blocking the winner's return. + for (const loser of pending) { + loser.then((transport) => transport.dispose()).catch(() => {}); + } + return winner; + } + + private async connectCandidateAsync( + key: string, + target: CultMeshRealtimeTarget, + entry: { candidate: CultMeshRealtimeCandidate; connector: CultMeshRealtimeTransportConnector }, + signal: AbortSignal, + ): Promise { + const transport = await entry.connector.connect(entry.candidate, target, signal); + if (!transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, entry.candidate.generation)) { + transport.dispose(); + throw new Error(`Transport did not prove CultMesh authority for '${key}'.`); + } + return transport; + } + + /** + * Marks the session for `target` offline, disposing its transport. Also + * cancels a dial still in flight for `target`, so a connect racing this + * call cannot cache a transport this call meant to discard. + */ + disconnect(target: CultMeshRealtimeTarget): void { + const key = this.sessionKey(target); + const transport = this.sessions.get(key); + if (transport) { + this.sessions.delete(key); + transport.dispose(); + } + this.dialAborts.get(key)?.abort(); + } + + dispose(): void { + this.disposed = true; + for (const controller of this.dialAborts.values()) controller.abort(); + this.dialAborts.clear(); + for (const transport of this.sessions.values()) transport.dispose(); + this.sessions.clear(); + } +} diff --git a/packages/cultmesh-ts/test/fixtures/README.md b/packages/cultmesh-ts/test/fixtures/README.md new file mode 100644 index 00000000..70573ff2 --- /dev/null +++ b/packages/cultmesh-ts/test/fixtures/README.md @@ -0,0 +1,27 @@ +# QUIC test fixtures + +`quic-test.p12` and `quic-test-cert.der` are a self-signed P-256 credential +used only to open a loopback MsQuic listener in tests +(`realtime-quic-native.test.ts`, `realtime-quic-consumer.test.ts`). Not a +production credential; no private key outside this pair signs anything real. + +`quic-test-cert.der`'s subject is `CN=cultmesh-quic-native-tests`, and its +SHA-256 is what the tests pin as the expected certificate pin +(`cert-sha256`). + +Generated with (mirrors the recipe the cut map records at P13): + +```sh +openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ + -keyout quic-test-key.pem -out quic-test-cert.pem \ + -subj "/CN=cultmesh-quic-native-tests" -days 3650 + +openssl x509 -in quic-test-cert.pem -outform der -out quic-test-cert.der + +openssl pkcs12 -export -inkey quic-test-key.pem -in quic-test-cert.pem \ + -out quic-test.p12 -passout pass: +``` + +Regenerate the same way if the pair ever needs replacing; nothing here +depends on today's exact bytes past what the tests read back out (the DER +cert and its SHA-256, and a PKCS12 the native bridge can load). diff --git a/packages/cultmesh-ts/test/fixtures/quic-test-cert.der b/packages/cultmesh-ts/test/fixtures/quic-test-cert.der new file mode 100644 index 00000000..5cce6f94 Binary files /dev/null and b/packages/cultmesh-ts/test/fixtures/quic-test-cert.der differ diff --git a/packages/cultmesh-ts/test/fixtures/quic-test.p12 b/packages/cultmesh-ts/test/fixtures/quic-test.p12 new file mode 100644 index 00000000..e9d03ce6 Binary files /dev/null and b/packages/cultmesh-ts/test/fixtures/quic-test.p12 differ diff --git a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts new file mode 100644 index 00000000..aadd1265 --- /dev/null +++ b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts @@ -0,0 +1,234 @@ +// The TypeScript half of the QUIC realtime interop lane. Two modes: +// +// `dial` (Cut 4): a one-shot process that dials an advertised +// `cultmesh-state+quic://` endpoint with the native connector, receives +// `--expect` frames from a peer (normally the C# managed provider started by +// `cultnet-interop.test.ts`'s "CultMesh QUIC realtime" test), and prints them +// as one JSON line. +// +// `serve` (Cut 5): the StreamPixels role. Opens a `CultMeshQuicRealtimeProvider` +// listener, prints `{status: "ready", endpoint}` (the same shape the C# +// `quic-realtime-serve` peer prints), waits for a first peer before +// broadcasting (mirroring `QuicRealtimeServeAsync`'s +// `WaitForFirstConnectionAsync`, since a broadcast to nobody would otherwise +// silently no-op), then broadcasts `--frames` frames at `--delivery` with +// `--interval-ms` between them, watching stdin for EOF as its own shutdown +// signal like the C# peer's `WatchStdinCloseAsync`. `--linger-ms` (default 0) +// keeps the listener open that long after broadcasting finishes, so the +// harness can dial a peer late and exercise Cut C's retained-state seed +// instead of the provider always tearing down the moment it is done sending. +// +// Neither mode imports `cultmesh-ts` itself (the harness drives processes, +// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the +// reverse); this script reaches the provider/connector through its own +// package's `src/`. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; + +import { + CultMeshQuicRealtimeConnector, + CultMeshQuicRealtimeProvider, + type CultMeshRealtimeTarget, +} from "../../src/realtime-quic"; +import { encodeRealtimeFrame, type CultMeshRealtimeDelivery, type CultMeshRealtimeFrame } from "../../src/realtime-wire"; + +// `__dirname` at runtime is `dist-test/test/interop`; fixtures are binary and +// are never compiled/copied there, so they are read from their source +// location, three levels up. The same self-signed test credential the unit +// tests use (`test/fixtures/README.md`): not a production credential. +const FIXTURE_P12 = join(__dirname, "..", "..", "..", "test", "fixtures", "quic-test.p12"); + +function parseArgs(argv: readonly string[]): Map { + const args = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) continue; + const name = token.slice(2); + const value = argv[index + 1]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for --${name}`); + args.set(name, value); + index += 1; + } + return args; +} + +function requireArg(args: Map, name: string): string { + const value = args.get(name); + if (!value) throw new Error(`Missing required argument --${name}`); + return value; +} + +function optionalIntArg(args: Map, name: string, fallback: number): number { + const raw = args.get(name); + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed)) throw new Error(`Argument --${name} must be an integer.`); + return parsed; +} + +function writeJsonLine(value: unknown): void { + process.stdout.write(`${JSON.stringify(value)}\n`); +} + +function writeLog(event: string, payload: Record): void { + process.stderr.write(`${JSON.stringify({ event, ...payload })}\n`); +} + +function watchStdinCloseAsync(): Promise { + return new Promise((resolve) => { + process.stdin.resume(); + process.stdin.once("end", resolve); + }); +} + +async function serveAsync(args: Map): Promise { + const frames = optionalIntArg(args, "frames", 5); + const deliveryArg = args.get("delivery") ?? "latest-only"; + if (deliveryArg !== "latest-only" && deliveryArg !== "reliable-ordered") { + throw new Error(`Unsupported --delivery '${deliveryArg}'.`); + } + const delivery = deliveryArg as CultMeshRealtimeDelivery; + const intervalMs = optionalIntArg(args, "interval-ms", 50); + const lingerMs = optionalIntArg(args, "linger-ms", 0); + const port = optionalIntArg(args, "port", 0); + + const provider = await CultMeshQuicRealtimeProvider.listen({ + host: "127.0.0.1", + port, + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + }); + writeJsonLine({ status: "ready", endpoint: provider.advertisedEndpoint }); + + try { + // A broadcast to zero peers is not an error, but it sends nothing: + // matching the C# `quic-realtime-serve` peer, wait for a first + // connection, bounded, so a peer that never shows up still exits. + const connectDeadline = Date.now() + 30_000; + while (provider.connectionCount === 0) { + if (Date.now() >= connectDeadline) throw new Error("quic-realtime-serve: no client connected within 30000ms."); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + const stdinClosed = watchStdinCloseAsync(); + let stopped = false; + void stdinClosed.then(() => { + stopped = true; + }); + for (let sequence = 1; sequence <= frames && !stopped; sequence += 1) { + const frame: CultMeshRealtimeFrame = { + channelId: "interop.quic-realtime", + schemaId: "gamecult.interop.quic_realtime_frame.v0", + bodyId: "interop:quic-realtime:frame", + producerEpoch: 1n, + sequence: BigInt(sequence), + delivery, + payload: Buffer.from(`frame-${sequence}`, "utf8"), + }; + await provider.broadcast(frame); + // Printed so the harness can compare this exact frame's TypeScript + // encoding against the same frame's C#-side re-encoding (golden bytes + // across processes), without the harness importing the codec itself. + writeLog("quic-realtime-serve", { sent: sequence, hex: Buffer.from(encodeRealtimeFrame(frame)).toString("hex") }); + if (intervalMs > 0) await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + if (lingerMs > 0 && !stopped) await new Promise((resolve) => setTimeout(resolve, lingerMs)); + } finally { + provider.dispose(); + // Mirrors the C# peer returning from `QuicRealtimeServeAsync`: once + // broadcasting is done (or stdin closed early), let the process exit on + // its own instead of keeping the event loop alive on a stdin that may + // never close. The harness still ends/kills this process explicitly in + // its own cleanup; this only lets a plain manual run exit cleanly too. + process.stdin.pause(); + } +} + +async function main(): Promise { + const [mode, ...rest] = process.argv.slice(2); + if (mode === "serve") { + await serveAsync(parseArgs(rest)); + return; + } + if (mode !== "dial") throw new Error("Expected mode: dial | serve"); + const args = parseArgs(rest); + const endpoint = requireArg(args, "endpoint"); + const expect = optionalIntArg(args, "expect", 1); + // Alternative stop condition to --expect: keep receiving (through whatever + // the consumer's own coalescing inbox delivers) until a frame with this + // sequence arrives, then stop. Lets a caller prove convergence under + // latest-only coalescing, where the frame *count* a stalled reader sees is + // not the interesting number, but the final value it converges on is. + const expectSequenceRaw = args.get("expect-sequence"); + const expectSequence = expectSequenceRaw !== undefined ? BigInt(expectSequenceRaw) : undefined; + const timeoutMs = optionalIntArg(args, "timeout-ms", 15_000); + // Simulates "a test hook on the runtime that stops calling next_event" + // (spec section 10, LatestOnly-under-a-stalled-consumer lane) at the level + // this subprocess can reach: after receiving --pause-after frames, stop + // calling receiveFrame for --pause-ms. The shared native pump keeps + // dispatching and coalescing into this transport's own inbox regardless + // (Cut 4), so not reading is externally equivalent to not polling. + const pauseAfter = optionalIntArg(args, "pause-after", -1); + const pauseMs = optionalIntArg(args, "pause-ms", 0); + + const target: CultMeshRealtimeTarget = { verseId: "interop", authorityRuntimeId: "interop.csharp-provider" }; + const connector = new CultMeshQuicRealtimeConnector(); + const transport = await connector.connect( + { endpoint, authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "interop" }, + target, + ); + writeLog("connected", { endpoint, transportId: transport.transportId }); + + const frames: Array<{ + channelId: string; + schemaId: string; + bodyId: string; + producerEpoch: string; + sequence: string; + delivery: string; + payloadHex: string; + }> = []; + try { + const deadline = Date.now() + timeoutMs; + let lastSequence: bigint | undefined; + while ( + (expectSequence === undefined && frames.length < expect) || + (expectSequence !== undefined && lastSequence !== expectSequence) + ) { + if (pauseAfter >= 0 && frames.length === pauseAfter) { + writeLog("pausing", { afterFrames: frames.length, pauseMs }); + await new Promise((resolve) => setTimeout(resolve, pauseMs)); + writeLog("resumed", { afterFrames: frames.length }); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`Timed out after receiving ${frames.length} frames (last sequence ${lastSequence}).`); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), remaining); + try { + const frame = await transport.receiveFrame(controller.signal); + lastSequence = frame.sequence; + frames.push({ + channelId: frame.channelId, + schemaId: frame.schemaId, + bodyId: frame.bodyId, + producerEpoch: frame.producerEpoch.toString(), + sequence: frame.sequence.toString(), + delivery: frame.delivery, + payloadHex: Buffer.from(frame.payload).toString("hex"), + }); + } finally { + clearTimeout(timer); + } + } + } finally { + transport.dispose(); + } + + writeJsonLine({ endpoint, transportId: transport.transportId, frames }); +} + +main().catch((error) => { + writeLog("fatal", { error: error instanceof Error ? (error.stack ?? error.message) : String(error) }); + process.exitCode = 1; +}); diff --git a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts new file mode 100644 index 00000000..8a2063fe --- /dev/null +++ b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts @@ -0,0 +1,1074 @@ +// The TypeScript QUIC realtime consumer, proven two ways: directly against a +// raw listener opened through the native runtime (no provider semantics yet, +// mirrors `CultMeshNativeQuicRealtimeTransportTests.cs`), and through +// `CultMeshQuicRealtimeSessionManager` with a static lookup source and routes +// signed with `node:crypto`, mirroring `CultMeshQuicRealtimeTransportTests.cs` +// and `CultMeshSessions.cs`'s realtime binding. +// +// Skips instead of failing when no native bridge is available; see +// `realtime-quic-native.test.ts` for why. + +import assert from "node:assert/strict"; +import { createHash, generateKeyPairSync, sign as cryptoSign, type KeyObject } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +import { + canonicalRoute, + type CultMeshAuthorityRouteView, + type CultMeshAuthorityTrustPolicy, + type CultMeshP256PublicKey, +} from "cultnet-ts"; +import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; + +import { + CultMeshQuicNativeRuntime, + CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, + CULTMESH_QUIC_STREAM_LATEST_ONLY, + CULTMESH_QUIC_STREAM_RELIABLE, +} from "../src/realtime-quic-native"; +import { + CultMeshQuicRealtimeConnector, + CultMeshQuicRealtimeSessionManager, + CultMeshStaticRealtimeLookupSource, + CULTMESH_REALTIME_STATE_PROTOCOL_ID, + type CultMeshQuicRealtimeTransport, + type CultMeshRealtimeCandidate, + type CultMeshRealtimeTarget, + type CultMeshRealtimeTransport, + type CultMeshRealtimeTransportConnector, +} from "../src/realtime-quic"; +import { encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire"; +import { nativeBridgeAvailable } from "./support/native-bridge"; + +// __dirname at runtime is dist-test/test; fixtures are binary and are never +// compiled/copied there, so they are read from their source location, two +// levels up. +const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); +const FIXTURE_DER = join(__dirname, "..", "..", "test", "fixtures", "quic-test-cert.der"); + +function fixturePinHex(): string { + return createHash("sha256").update(readFileSync(FIXTURE_DER)).digest("hex"); +} + +/** A bare loopback listener over the raw native runtime, no provider semantics. */ +class RawQuicListener { + private constructor( + private readonly runtime: CultMeshQuicNativeRuntime, + private readonly listenerId: bigint, + readonly boundPort: number, + ) {} + + static async open(): Promise { + const runtime = await CultMeshQuicNativeRuntime.open(); + try { + const pkcs12 = readFileSync(FIXTURE_P12); + const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); + return new RawQuicListener(runtime, listenerId, boundPort); + } catch (error) { + // A runtime opened above and never handed to a caller would otherwise + // leak: the shared runtime's refcount never reaches zero, its MsQuic + // registration and worker threads never tear down, and a process that + // hits this on every one of several tests never exits on its own. + await runtime.release(); + throw error; + } + } + + /** Waits for one inbound connection and returns its native connection id. */ + async acceptOnce(): Promise { + return await new Promise((resolve) => { + this.runtime.onListenerEvent(this.listenerId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); + }); + }); + } + + /** Sends one frame to an accepted connection on a fresh stream of `kind`. */ + sendFrame(connectionId: bigint, frame: CultMeshRealtimeFrame, kind: number): void { + const streamId = this.runtime.streamOpen(connectionId, kind); + this.runtime.streamSendFrame(streamId, encodeRealtimeFrame(frame), true); + } + + /** Sends arbitrary bytes (not necessarily a valid encoded frame) on a fresh stream of `kind`. */ + sendRaw(connectionId: bigint, bytes: Uint8Array, kind: number): void { + const streamId = this.runtime.streamOpen(connectionId, kind); + this.runtime.streamSendFrame(streamId, bytes, true); + } + + /** Shuts down one accepted connection from this side, as a peer disconnect. */ + shutdownConnection(connectionId: bigint, code = 0n): void { + this.runtime.connectionShutdown(connectionId, code); + } + + async close(): Promise { + this.runtime.listenerClose(this.listenerId); + await this.runtime.release(); + } +} + +/** + * `assert.rejects`, but safe against a mutant or a real regression that + * makes the promise resolve instead: a transport that unexpectedly connects + * is disposed before the assertion fails, rather than left live with an open + * native connection and an outstanding runtime reference. A bare + * `assert.rejects(connector.connect(...))` hung the whole file exactly this + * way under M1 (the certificate-accept mutation): three tests failed their + * assertion correctly, but each left a connected, undisposed transport + * behind, and Node's test runner refuses to exit while any promise or + * native handle is still outstanding. + */ +async function assertRejectsAndDisposes( + promise: Promise<{ dispose(): void }>, + match?: RegExp, +): Promise { + let resolved: { dispose(): void } | undefined; + try { + resolved = await promise; + } catch (error) { + if (match) assert.match(error instanceof Error ? error.message : String(error), match); + return; + } + resolved.dispose(); + assert.fail("expected the promise to reject, but it resolved (the resolved value has been disposed)"); +} + +/** + * `transport.receiveFrame()`, bounded: a fault-path regression that leaves a + * malformed frame undelivered-but-not-rejected would otherwise hang this + * call (and, through a bare `assert.rejects`, the whole test file) forever, + * rather than fail the one assertion it was supposed to fail. + */ +function receiveFrameOrTimeout(transport: { receiveFrame(signal?: AbortSignal): Promise }, ms = 3_000): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ms); + return transport.receiveFrame(controller.signal).finally(() => clearTimeout(timer)); +} + +function testFrame(overrides: Partial = {}): CultMeshRealtimeFrame { + return { + channelId: "aetheria.entities", + schemaId: "eve.entity_soa.v1", + bodyId: "body:aetheria:entities", + producerEpoch: 7n, + sequence: 1n, + delivery: "latest-only", + payload: new Uint8Array([3, 4, 5]), + ...overrides, + }; +} + +test("connect with the correct pin succeeds and receives a frame", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const accepted = listener.acceptOnce(); + const transport = await connector.connect(candidate, target); + try { + assert.equal(transport.transportId, "msquic-realtime"); + const connectionId = await accepted; + listener.sendFrame(connectionId, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const received = await transport.receiveFrame(); + assert.equal(received.bodyId, "body:aetheria:entities"); + assert.equal(received.sequence, 1n); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("connect refuses a wrong advertised pin", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${"0".repeat(64)}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); + } finally { + await listener.close(); + } +}); + +test("connect refuses a missing certificate pin when no validator is supplied", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); + } finally { + await listener.close(); + } +}); + +test("a caller-supplied validator observes the target identity and the DER bytes", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + let observedTarget: CultMeshRealtimeTarget | undefined; + let observedDerLength = 0; + const connector = new CultMeshQuicRealtimeConnector({ + validateProviderCertificate: (validatorTarget, der) => { + observedTarget = validatorTarget; + observedDerLength = der.byteLength; + return true; + }, + }); + const transport = await connector.connect(candidate, target); + transport.dispose(); + assert.deepEqual(observedTarget, target); + assert.ok(observedDerLength > 0); + } finally { + await listener.close(); + } +}); + +test("a late older generation is dropped by the receive-side filter", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const accepted = listener.acceptOnce(); + const transport = await connector.connect(candidate, target); + try { + const connectionId = await accepted; + listener.sendFrame(connectionId, testFrame({ producerEpoch: 8n, sequence: 0n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const latest = await transport.receiveFrame(); + assert.equal(latest.producerEpoch, 8n); + assert.equal(latest.sequence, 0n); + + listener.sendFrame(connectionId, testFrame({ producerEpoch: 7n, sequence: 999n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const staleTimeout = new AbortController(); + const timer = setTimeout(() => staleTimeout.abort(), 300); + await assert.rejects(transport.receiveFrame(staleTimeout.signal)); + clearTimeout(timer); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); + +// --------------------------------------------------------------------------- +// Trust negatives, through the session manager with a static lookup source. +// --------------------------------------------------------------------------- + +function base64UrlToBase64(value: string): string { + let base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + while (base64.length % 4 !== 0) base64 += "="; + return base64; +} + +let odinRootSequence = 0; + +function generateOdinRoot(): { privateKey: KeyObject; publicKey: CultMeshP256PublicKey } { + const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); + const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string }; + odinRootSequence += 1; + return { + privateKey, + publicKey: { + keyId: `odin-root-${odinRootSequence}`, + x: base64UrlToBase64(jwk.x), + y: base64UrlToBase64(jwk.y), + }, + }; +} + +interface SignedRouteOptions { + readonly verseId: string; + readonly authorityRuntimeId: string; + readonly endpoint: string; + readonly protocolIds: readonly string[]; + readonly privateKey: KeyObject; + readonly odinKeyId: string; + readonly priority?: number; + readonly generation?: string; + readonly issuedAtUnixMilliseconds?: number; + readonly expiresAtUnixMilliseconds?: number; + readonly providerKey?: CultMeshP256PublicKey; + readonly corruptSignature?: boolean; +} + +function signRoute(options: SignedRouteOptions): CultMeshAuthorityRouteMessage { + const priority = options.priority ?? 0; + const generation = options.generation ?? ""; + const issuedAt = options.issuedAtUnixMilliseconds ?? Date.now() - 60_000; + const expiresAt = options.expiresAtUnixMilliseconds ?? Date.now() + 3_600_000; + const providerKey = options.providerKey ?? { keyId: "provider-1", x: "unused", y: "unused" }; + const view: CultMeshAuthorityRouteView = { + verseId: options.verseId, + authorityRuntimeId: options.authorityRuntimeId, + endpoint: options.endpoint, + protocolIds: options.protocolIds, + priority, + generation, + certificate: { + providerKey, + odinKeyId: options.odinKeyId, + issuedAtUnixMilliseconds: issuedAt, + expiresAtUnixMilliseconds: expiresAt, + signature: "", + }, + }; + const payload = canonicalRoute(view); + const signatureBytes = options.corruptSignature + ? Buffer.alloc(64, 0x41) + : (cryptoSign("sha256", Buffer.from(payload), { key: options.privateKey, dsaEncoding: "ieee-p1363" }) as Buffer); + return { + authorityRuntimeId: options.authorityRuntimeId, + endpoint: options.endpoint, + protocolIds: [...options.protocolIds], + priority, + generation, + certificate: { + providerKeyId: providerKey.keyId, + providerPublicKeyX: providerKey.x, + providerPublicKeyY: providerKey.y, + odinKeyId: options.odinKeyId, + issuedAtUnixMilliseconds: issuedAt, + expiresAtUnixMilliseconds: expiresAt, + signature: signatureBytes.toString("base64"), + }, + }; +} + +function verseWithRoute(verseId: string, route: CultMeshAuthorityRouteMessage): CultMeshVerseDescriptorMessage { + return { + verseId, + displayName: verseId, + authorityModel: "operator-cluster", + compatibility: { transportVersion: "cultmesh.v1", rulesHash: "test", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, + discoveryEndpoints: [route.endpoint], + authorityRuntimeIds: [route.authorityRuntimeId], + authorityRoutes: [route], + }; +} + +const unreachableConnector: CultMeshRealtimeTransportConnector = { + connectorId: "unreachable", + priority: 0, + canConnect: () => true, + connect: async () => { + throw new Error("a trust-refused route must never reach a connector"); + }, +}; + +test("trust negative (a): an expired certificate is refused", async () => { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const root = generateOdinRoot(); + const route = signRoute({ + verseId: target.verseId, + authorityRuntimeId: target.authorityRuntimeId, + endpoint: "cultmesh-state+quic://127.0.0.1:9443", + protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], + privateKey: root.privateKey, + odinKeyId: root.publicKey.keyId, + issuedAtUnixMilliseconds: Date.now() - 120_000, + expiresAtUnixMilliseconds: Date.now() - 60_000, + }); + const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), + trust, + connectors: [unreachableConnector], + }); + await assertRejectsAndDisposes(manager.connect(target), /valid|expired/i); +}); + +test("trust negative (b): an unsigned remote route is refused", async () => { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const route: CultMeshAuthorityRouteMessage = { + authorityRuntimeId: target.authorityRuntimeId, + endpoint: "cultmesh-state+quic://198.51.100.7:9443", + protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], + priority: 0, + generation: "gen-1", + }; + const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] }; + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), + trust, + connectors: [unreachableConnector], + }); + await assertRejectsAndDisposes(manager.connect(target), /Odin-signed/i); +}); + +test("trust negative (c): a route signed by an untrusted Odin root is refused", async () => { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const signer = generateOdinRoot(); + const trustedRoot = generateOdinRoot(); + const route = signRoute({ + verseId: target.verseId, + authorityRuntimeId: target.authorityRuntimeId, + endpoint: "cultmesh-state+quic://127.0.0.1:9443", + protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], + privateKey: signer.privateKey, + odinKeyId: signer.publicKey.keyId, + }); + const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [trustedRoot.publicKey] }; + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), + trust, + connectors: [unreachableConnector], + }); + await assertRejectsAndDisposes(manager.connect(target), /not trusted/i); +}); + +test("trust negative (d): a route that verifies but pins the wrong certificate is refused at the transport", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const root = generateOdinRoot(); + const route = signRoute({ + verseId: target.verseId, + authorityRuntimeId: target.authorityRuntimeId, + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${"0".repeat(64)}`, + protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], + privateKey: root.privateKey, + odinKeyId: root.publicKey.keyId, + }); + const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), + trust, + connectors: [new CultMeshQuicRealtimeConnector()], + }); + await assertRejectsAndDisposes(manager.connect(target), /certificate|rejected/i); + } finally { + await listener.close(); + } +}); + +test("trust negative (e): a transport that fails isVerifiedFor is disposed and rejected", async () => { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const root = generateOdinRoot(); + const route = signRoute({ + verseId: target.verseId, + authorityRuntimeId: target.authorityRuntimeId, + endpoint: "cultmesh-state+quic://127.0.0.1:9443", + protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], + privateKey: root.privateKey, + odinKeyId: root.publicKey.keyId, + generation: "route-generation-1", + }); + const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; + + let disposed = false; + const staleTransport: CultMeshRealtimeTransport = { + transportId: "msquic-realtime", + endpoint: route.endpoint, + async sendFrame() {}, + async receiveFrame() { + return testFrame(); + }, + dispose() { + disposed = true; + }, + // A replayed/stale generation: the transport proves a different route + // generation than the one the session manager just verified. + isVerifiedFor: () => false, + }; + const staleConnector: CultMeshRealtimeTransportConnector = { + connectorId: "stale", + priority: 0, + canConnect: () => true, + connect: async () => staleTransport, + }; + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), + trust, + connectors: [staleConnector], + }); + + await assertRejectsAndDisposes(manager.connect(target), /did not prove|No realtime state path/i); + assert.equal(disposed, true, "the session manager must dispose a transport that fails isVerifiedFor"); +}); + +test("M3: a real connected transport's isVerifiedFor rejects a generation other than the one it connected with", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "route-generation-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const transport = await connector.connect(candidate, target); + try { + assert.equal( + transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, "route-generation-1"), + true, + "the generation it actually connected with must verify", + ); + assert.equal( + transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, "route-generation-2"), + false, + "a mutated/replayed generation must not verify against a real transport", + ); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("trust negative (e2): a real transport whose generation no longer matches the freshly verified route is disposed", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const root = generateOdinRoot(); + const signedRoute = signRoute({ + verseId: target.verseId, + authorityRuntimeId: target.authorityRuntimeId, + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], + privateKey: root.privateKey, + odinKeyId: root.publicKey.keyId, + generation: "route-generation-1", + }); + const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; + const realConnector = new CultMeshQuicRealtimeConnector(); + let connectedTransport: CultMeshRealtimeTransport | undefined; + // A real transport, connected for real against the raw listener, but + // stamped with a generation different from the one the session manager + // just verified — the way a stale cached transport would look after its + // route rotated underneath it. + const replayConnector: CultMeshRealtimeTransportConnector = { + connectorId: "replay", + priority: 0, + canConnect: (c) => realConnector.canConnect(c), + connect: async (c, t, signal) => { + const accepted = listener.acceptOnce(); + const transport = await realConnector.connect({ ...c, generation: "route-generation-1-stale" }, t, signal); + await accepted; + connectedTransport = transport; + return transport; + }, + }; + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, signedRoute)]), + trust, + connectors: [replayConnector], + }); + + await assertRejectsAndDisposes(manager.connect(target), /did not prove|No realtime state path/i); + assert.ok(connectedTransport, "a real transport must have connected"); + // Real cleanup ran: receiveFrame on the disposed transport rejects. + await assert.rejects(connectedTransport!.receiveFrame(), /disposed/i); + } finally { + await listener.close(); + } +}); + +test("the advertised certificate pin is matched case-insensitively (M2)", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex().toUpperCase()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const transport = await connector.connect(candidate, target); + transport.dispose(); + } finally { + await listener.close(); + } +}); + +// --------------------------------------------------------------------------- +// Fix batch: pump/dispatch isolation (1), peer-shutdown cleanup (3), the +// bounded receive backlog and stream-kind pruning (6). +// --------------------------------------------------------------------------- + +test("fix 1 / M4: a stream-kind/delivery mismatch faults only that connection; the pump keeps running", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; + + // A reliable stream carrying a frame whose own delivery byte says + // latest-only: `onStreamFrame` throws, which must fault only this + // connection. + const connectorA = new CultMeshQuicRealtimeConnector(); + const acceptedA = listener.acceptOnce(); + const transportA = await connectorA.connect( + { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, + target, + ); + try { + const connectionIdA = await acceptedA; + listener.sendFrame(connectionIdA, testFrame({ delivery: "latest-only" }), CULTMESH_QUIC_STREAM_RELIABLE); + // Bounded: if the mismatch check regresses (never faults), receiveFrame() + // waits forever for a frame that will not arrive, and a bare + // `assert.rejects` would hang this test, and the whole file, right along + // with it (as M4 did before this bound existed). + await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); + // Future receives on the faulted transport reject immediately, with the + // same error, rather than hanging. + await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); + } finally { + transportA.dispose(); + } + + // A second, independent connection through the same process-wide runtime + // and pump must still work: the fault above did not bring it down. + const connectorB = new CultMeshQuicRealtimeConnector(); + const acceptedB = listener.acceptOnce(); + const transportB = await connectorB.connect( + { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, + target, + ); + try { + const connectionIdB = await acceptedB; + listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const received = await transportB.receiveFrame(); + assert.equal(received.bodyId, "body:aetheria:entities"); + } finally { + transportB.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("M4b: a latest-only stream carrying a reliable-ordered frame faults only that connection", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; + + // The mirror image of the M4 test above: a latest-only stream carrying + // a frame whose own delivery byte says reliable-ordered. + const connectorA = new CultMeshQuicRealtimeConnector(); + const acceptedA = listener.acceptOnce(); + const transportA = await connectorA.connect( + { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, + target, + ); + try { + const connectionIdA = await acceptedA; + listener.sendFrame(connectionIdA, testFrame({ delivery: "reliable-ordered" }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); + } finally { + transportA.dispose(); + } + + // A second, independent connection still works. + const connectorB = new CultMeshQuicRealtimeConnector(); + const acceptedB = listener.acceptOnce(); + const transportB = await connectorB.connect( + { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, + target, + ); + try { + const connectionIdB = await acceptedB; + listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const received = await transportB.receiveFrame(); + assert.equal(received.bodyId, "body:aetheria:entities"); + } finally { + transportB.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("fix 1: a throwing onDisposed handler cannot crash the pump when a malformed frame faults a connection", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; + + const connectorA = new CultMeshQuicRealtimeConnector(); + const acceptedA = listener.acceptOnce(); + const transportA = await connectorA.connect( + { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, + target, + ); + // A throwing onDisposed handler, registered before the fault: fix 1 + // must run it inside its own try/catch so it cannot escape cleanup(), + // and must have already released the runtime reference regardless. + let secondHandlerRan = false; + transportA.onDisposed?.(() => { + throw new Error("first onDisposed handler throws on purpose"); + }); + transportA.onDisposed?.(() => { + secondHandlerRan = true; + }); + + try { + const connectionIdA = await acceptedA; + // Random bytes on a reliable stream: not a valid encoded frame, so + // decodeRealtimeFrame throws inside onStreamFrame, which native's + // dispatch loop (fix 1's native half) catches and faults only this + // connection. Bounded: if the fault path regresses and never faults + // this connection, receiveFrame() would otherwise wait forever for a + // frame that will not arrive. + listener.sendRaw(connectionIdA, new Uint8Array([9, 9, 9, 9, 9, 9, 9, 9]), CULTMESH_QUIC_STREAM_RELIABLE); + await assert.rejects(receiveFrameOrTimeout(transportA)); + assert.equal(secondHandlerRan, true, "a throwing handler must not stop the next onDisposed handler from running"); + } finally { + transportA.dispose(); + } + + // The process (and this test file's pump) survives, and a second, + // independent connection through the same runtime still works. + const connectorB = new CultMeshQuicRealtimeConnector(); + const acceptedB = listener.acceptOnce(); + const transportB = await connectorB.connect( + { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, + target, + ); + try { + const connectionIdB = await acceptedB; + listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const received = await transportB.receiveFrame(); + assert.equal(received.bodyId, "body:aetheria:entities"); + } finally { + transportB.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("fix 1: a throwing onDisposed handler on a peer shutdown still releases the runtime and runs every handler", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const accepted = listener.acceptOnce(); + const transport = await connector.connect(candidate, target); + try { + const connectionId = await accepted; + + let secondHandlerRan = false; + transport.onDisposed?.(() => { + throw new Error("first onDisposed handler throws on purpose"); + }); + transport.onDisposed?.(() => { + secondHandlerRan = true; + }); + + const before = CultMeshQuicNativeRuntime.refCount; + listener.shutdownConnection(connectionId); + // Wait for cleanup() to run (driven by the pump's CONNECTION_SHUTDOWN + // dispatch), rather than asserting immediately. Bounded: if the + // shutdown-cleanup path regresses (F3) and never rejects, this would + // otherwise hang instead of failing the assertion below it. + await assert.rejects(receiveFrameOrTimeout(transport)); + assert.equal( + CultMeshQuicNativeRuntime.refCount, + before - 1, + "the runtime reference must be released even though a handler threw", + ); + assert.equal(secondHandlerRan, true, "every onDisposed handler must still run despite an earlier one throwing"); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("M10: an equal generation delivered after the first was already consumed is dropped, not redelivered", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const accepted = listener.acceptOnce(); + const transport = await connector.connect(candidate, target); + try { + const connectionId = await accepted; + + // Send one frame and consume it, so the coalescer no longer holds + // anything pending for this key: the generation filter's own record + // (`latestGenerations`) is the only thing left that could still know + // this generation was already seen. Sending a frame back-to-back with + // an unconsumed duplicate would mask a broken filter behind the + // coalescer overwriting the pending value with an identical one; this + // sequencing is what M10 needs to be observable at all (Soul's note). + listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const first = await transport.receiveFrame(); + assert.equal(first.sequence, 3n); + + // The exact same generation, sent again after consumption: the + // filter must drop it outright rather than deliver it as new. + listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + await new Promise((resolve) => setTimeout(resolve, 200)); + + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), 300); + await assert.rejects( + transport.receiveFrame(timeout.signal), + "an equal generation already consumed must not be redelivered", + ); + clearTimeout(timer); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); + +// F7 ("a canceled send must not resolve as success") is covered in +// realtime-quic-native.test.ts, at the native binding layer, not here. +// A consumer-level scenario was attempted first: prime the reliable stream, +// start a second send, and shut the connection down from the peer mid-flight. +// It proved unreliable on this host regardless of payload size or added +// delay before the shutdown call — MsQuic here appears to accept and locally +// complete a `StreamSend` (marking it as a plain, non-canceled +// SEND_COMPLETE) faster than any JS-observable window can reliably land a +// shutdown ahead of it, and a delay long enough to guarantee the shutdown +// wins just lets the send finish first instead. This is recorded as not yet +// reached at the consumer layer, not as unreachable: the native-layer test +// asserts the exact field (`code`, decoded through the same struct decode +// F7 mutates) that this scenario would also exercise, so the fix itself is +// defended; only the end-to-end SEND_CANCELED proof through the consumer's +// own `sendFrame()` promise is the open gap. + +test("fix 3: a peer-initiated shutdown rejects a pending receive and cleans up exactly once", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const accepted = listener.acceptOnce(); + const transport = await connector.connect(candidate, target); + try { + const connectionId = await accepted; + + // Bounded: this is F3's own mutant target (removing the + // CONNECTION_SHUTDOWN cleanup call), and a broken cleanup path means + // this receive never settles. + const pendingReceive = receiveFrameOrTimeout(transport); + listener.shutdownConnection(connectionId); + await assert.rejects(pendingReceive, /closed by the remote peer/i); + + // Cleanup already ran: a further explicit dispose is a harmless no-op, + // and a fresh receive rejects immediately instead of hanging forever + // (the leak fix 3 exists for). + transport.dispose(); + await assert.rejects(receiveFrameOrTimeout(transport), /closed by the remote peer/i); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("fix 6: a slow latest-only reader accumulates at most one pending frame per key, and an equal generation is dropped", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const accepted = listener.acceptOnce(); + const transport = await connector.connect(candidate, target); + try { + const connectionId = await accepted; + + // Three increasing-sequence frames for the same (channel, body), + // published while nothing reads: an unbounded queue would deliver all + // three in order; the coalescing inbox keeps only the newest. + listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 1n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 2n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + // An equal generation, sent last: it must be dropped outright, not + // queued as a fourth pending frame. + listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + + // Give the pump time to process all four sends before reading. + await new Promise((resolve) => setTimeout(resolve, 200)); + + const first = await transport.receiveFrame(); + assert.equal(first.sequence, 3n, "only the newest pending frame for the key is kept"); + + const timeout = new AbortController(); + const timer = setTimeout(() => timeout.abort(), 300); + await assert.rejects(transport.receiveFrame(timeout.signal), "nothing else should be pending"); + clearTimeout(timer); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("fix 6: a stream shutdown prunes its entry so streamKinds does not grow without bound", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const accepted = listener.acceptOnce(); + const transport = await connector.connect(candidate, target); + try { + const connectionId = await accepted; + listener.sendFrame(connectionId, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const received = await transport.receiveFrame(); + assert.equal(received.sequence, 1n); + + // The server-side stream the frame arrived on is a fresh latest-only + // stream the listener opened and finished with `fin: true`; the native + // bridge emits its own shutdown event once MsQuic completes teardown. + // Wait for that STREAM_SHUTDOWN to actually land, then assert the + // internal map was pruned, not just that a second frame still works + // (F6kinds's removed prune line has no behavioural symptom until a + // stream id is reused, which QUIC never does within one connection). + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal( + (transport as CultMeshQuicRealtimeTransport).trackedStreamCount, + 0, + "streamKinds must be pruned once the stream shuts down, not accumulate one entry per stream", + ); + + listener.sendFrame(connectionId, testFrame({ sequence: 2n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); + const second = await transport.receiveFrame(); + assert.equal(second.sequence, 2n); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); + +test("P8: a reliable send still queued behind reliableSendTail rejects instead of hanging when disposal wins the race", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const listener = await RawQuicListener.open(); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + const transport = await connector.connect(candidate, target); + try { + // Prime the reliable outbound stream and let it fully settle, so its + // id is already cached: a second reliable send then chains straight + // onto `sendOnStream` with no `streamOpen` call of its own in between + // (a `streamOpen` against an already-shut-down connection fails with + // its own native error, which would mask the guard this test targets). + await transport.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 1n })); + + // `sendFrame` chains onto `reliableSendTail`, which resolves as a + // microtask; calling `dispose()` synchronously right after, in the + // same turn, guarantees `cleanup()` has already set `disposed = true` + // by the time that microtask runs `sendOnStream`. Without the + // `this.disposed` guard `sendOnStream` registers (P8's target), this + // send would sit in `pendingSends` forever: `cleanup()`'s reject sweep + // already ran once before this send was ever registered, and no later + // native event can reach it either, since `offConnectionEvent` already + // ran too. + const send = transport.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 2n })); + transport.dispose(); + // Bounded: without the guard, `send` never settles (nothing left to + // reject it), so a bare `assert.rejects(send, ...)` would hang this + // whole file instead of failing this one test by assertion. Racing + // against a timeout that rejects with a different message turns a + // regression into a prompt, loud assertion mismatch. + const bounded = Promise.race([ + send, + new Promise((_, reject) => + setTimeout(() => reject(new Error("P8 guard timed out instead of rejecting")), 3_000), + ), + ]); + await assert.rejects(bounded, /disposed/i); + } finally { + transport.dispose(); + } + } finally { + await listener.close(); + } +}); diff --git a/packages/cultmesh-ts/test/realtime-quic-native.test.ts b/packages/cultmesh-ts/test/realtime-quic-native.test.ts new file mode 100644 index 00000000..039d9047 --- /dev/null +++ b/packages/cultmesh-ts/test/realtime-quic-native.test.ts @@ -0,0 +1,388 @@ +// The Cut 3 FFI smoke, now run against `CultMeshQuicNativeRuntime` instead of +// raw koffi calls: open a runtime, open a listener with a real credential, +// connect a real loopback connection, exchange one frame's worth of raw +// bytes, and close everything down. This proves the binding's declared ABI +// matches the shipped bridge; frame semantics (delivery, generation) are +// `realtime-quic-consumer.test.ts`'s job. +// +// Skips instead of failing when no native bridge is available: set +// CULTMESH_QUIC_NATIVE_DIR to a directory `scripts/build-quic-native.ps1` or +// `scripts/build-quic-native.sh` built, or run from the committed +// `packages/cultmesh-ts/native/` tree once a later cut lands it. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +import { + CultMeshQuicNativeRuntime, + CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, + CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, + CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED, + CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN, + CULTMESH_QUIC_EVENT_STREAM_STARTED, + CULTMESH_QUIC_EVENT_STREAM_FRAME, + CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE, + CULTMESH_QUIC_STREAM_RELIABLE, +} from "../src/realtime-quic-native"; +import { nativeBridgeAvailable } from "./support/native-bridge"; + +// __dirname at runtime is dist-test/test; fixtures are binary and are never +// compiled/copied there, so they are read from their source location, two +// levels up. +const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); + +async function waitFor(collect: (resolve: (value: T) => void) => void, timeoutMs = 5_000): Promise { + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timed out waiting for a native QUIC event")), timeoutMs); + collect((value) => { + clearTimeout(timer); + resolve(value); + }); + }); +} + +test("CultMeshQuicNativeRuntime opens and closes with no listener or connection", async (t) => { + if (!nativeBridgeAvailable()) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + const runtime = await CultMeshQuicNativeRuntime.open(); + await runtime.release(); +}); + +test("CultMeshQuicNativeRuntime opens and closes a listener with a real PKCS12 credential", async (t) => { + if (!nativeBridgeAvailable()) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + const runtime = await CultMeshQuicNativeRuntime.open(); + try { + const pkcs12 = readFileSync(FIXTURE_P12); + const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); + assert.ok(boundPort > 0); + runtime.listenerClose(listenerId); + } finally { + await runtime.release(); + } +}); + +test("a full loopback connection delivers one raw frame end to end", async (t) => { + if (!nativeBridgeAvailable()) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + const runtime = await CultMeshQuicNativeRuntime.open(); + try { + const pkcs12 = readFileSync(FIXTURE_P12); + const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); + + const accepted = waitFor((resolve) => { + runtime.onListenerEvent(listenerId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); + }); + }); + + const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort); + const clientConnected = waitFor((resolve) => { + runtime.onConnectionEvent(clientConnectionId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { + runtime.connectionCertificateComplete(clientConnectionId, true); + return; + } + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); + }); + }); + + const serverConnectionId = await accepted; + const serverFrame = waitFor((resolve) => { + runtime.onConnectionEvent(serverConnectionId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_STREAM_STARTED) { + assert.equal(event.streamKind, CULTMESH_QUIC_STREAM_RELIABLE); + return; + } + if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload); + }); + }); + + await clientConnected; + const streamId = runtime.streamOpen(clientConnectionId, CULTMESH_QUIC_STREAM_RELIABLE); + const sendComplete = waitFor((resolve) => { + runtime.onConnectionEvent(clientConnectionId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE) resolve(); + }); + }); + runtime.streamSendFrame(streamId, new Uint8Array([1, 2, 3, 4]), true); + await sendComplete; + + const payload = await serverFrame; + assert.deepEqual(Array.from(payload), [1, 2, 3, 4]); + + runtime.connectionShutdown(clientConnectionId, 0n); + runtime.connectionShutdown(serverConnectionId, 0n); + runtime.listenerClose(listenerId); + } finally { + await runtime.release(); + } +}); + +test("F7: a 64-bit event code round-trips exactly through the struct decode", async (t) => { + if (!nativeBridgeAvailable()) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + const runtime = await CultMeshQuicNativeRuntime.open(); + try { + const pkcs12 = readFileSync(FIXTURE_P12); + const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); + + // `code` (a `uint64_t`, the same field `CULTMESH_QUIC_SEND_CANCELED` is + // compared against on the stream-send path) is exercised here through + // CONNECTION_SHUTDOWN instead, whose code is an application-chosen value + // this test fully controls: two values above 2^53 (where koffi's decode + // stops being a plain safe-integer `number`) prove the whole + // encode-native-decode-coerce path is exact, deterministically, with no + // dependency on MsQuic's own send-completion timing. + for (const code of [(1n << 53n) + 1n, (1n << 62n) - 1n]) { + const accepted = waitFor((resolve) => { + runtime.onListenerEvent(listenerId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); + }); + }); + const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort); + const clientConnected = waitFor((resolve) => { + runtime.onConnectionEvent(clientConnectionId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { + runtime.connectionCertificateComplete(clientConnectionId, true); + return; + } + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); + }); + }); + const serverConnectionId = await accepted; + await clientConnected; + + const seenOnClient = waitFor((resolve) => { + runtime.onConnectionEvent(clientConnectionId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN) resolve(event.code); + }); + }); + runtime.connectionShutdown(serverConnectionId, code); + const seen = await seenOnClient; + assert.equal(typeof seen, "bigint", "a decoded event code must always be bigint, never number"); + assert.equal(seen, code, `shutdown code ${code} must round-trip exactly through the struct decode`); + } + + runtime.listenerClose(listenerId); + } finally { + await runtime.release(); + } +}); + +test( + "P10b: a throwing onFault handler is isolated — the pump survives and other connections' handlers still run", + { timeout: 10_000 }, + async (t) => { + if (!nativeBridgeAvailable()) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + const runtime = await CultMeshQuicNativeRuntime.open(); + try { + const pkcs12 = readFileSync(FIXTURE_P12); + const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); + + // Connection B: a normal, fully connected loopback pair, established + // before A ever faults — so proving B still works afterward exercises + // an already-running connection's handler, not a second accept cycle + // racing whatever state A's fault left behind. + const acceptedB = waitFor((resolve) => { + runtime.onListenerEvent(listenerId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); + }); + }); + const clientB = runtime.connectionOpen("127.0.0.1", boundPort); + const clientBConnected = waitFor((resolve) => { + runtime.onConnectionEvent(clientB, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { + runtime.connectionCertificateComplete(clientB, true); + return; + } + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); + }); + }); + const serverBId = await acceptedB; + const serverBFrame = waitFor((resolve) => { + runtime.onConnectionEvent(serverBId, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload); + }); + }); + await clientBConnected; + + // Connection A, opened only after B is fully connected: both its + // listener and its onFault handler throw on the first event delivered + // to it. Without `faultConnection`'s own try/catch isolating a + // throwing `onFault` (P10b's target), that throw would escape the + // pump's dispatch loop and take every other connection — including + // the already-live B — down with it, instead of staying scoped to A. + // + // The server-side connection listener is registered synchronously, + // inside the LISTENER_NEW_CONNECTION callback itself, rather than + // after an `await`: the native bridge can dispatch a connection's next + // event (CERTIFICATE_RECEIVED, or CONNECTED) before a later `await`'s + // continuation ever runs, and an event with no listener registered for + // its connection id is silently dropped (`dispatch`'s own + // `if (!entry) return`) — the same async-registration gap fix 1 closed + // for the provider's own accept path. + const serverAFaulted = waitFor((resolve) => { + runtime.onListenerEvent(listenerId, (event) => { + if (event.type !== CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) return; + runtime.onConnectionEvent( + event.connectionId, + () => { + throw new Error("P10b: connection A's listener always throws."); + }, + () => { + resolve(); + throw new Error("P10b: connection A's onFault also throws."); + }, + ); + }); + }); + const clientA = runtime.connectionOpen("127.0.0.1", boundPort); + runtime.onConnectionEvent(clientA, (event) => { + if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { + runtime.connectionCertificateComplete(clientA, true); + } + }); + await serverAFaulted; + + // Prove B's handler is still live after A's onFault threw: send a + // frame on B and confirm the server side still receives it. + const streamId = runtime.streamOpen(clientB, CULTMESH_QUIC_STREAM_RELIABLE); + runtime.streamSendFrame(streamId, new Uint8Array([9, 9, 9]), true); + const payload = await serverBFrame; + assert.deepEqual( + Array.from(payload), + [9, 9, 9], + "connection B must still deliver frames after A's onFault threw", + ); + + runtime.connectionShutdown(clientA, 0n); + runtime.connectionShutdown(clientB, 0n); + runtime.connectionShutdown(serverBId, 0n); + runtime.listenerClose(listenerId); + } finally { + await runtime.release(); + } + }, +); + +test("fix 2: release() refuses a call with no outstanding reference", async (t) => { + if (!nativeBridgeAvailable()) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + const runtime = await CultMeshQuicNativeRuntime.open(); + await runtime.release(); + await assert.rejects(runtime.release(), /no outstanding reference/i); +}); + +test("negative grep: no koffi callbacks, .async only on nextEvent", () => { + // __dirname at runtime is dist-test/test; the source lives two levels up, + // in src/, since only .js output is mirrored under dist-test. + const source = readFileSync(join(__dirname, "..", "..", "src", "realtime-quic-native.ts"), "utf8"); + // `.async` is referenced (not necessarily called with a literal "(" at the + // reference site: it is handed to `promisify`, which calls it) only for + // `nextEvent`, the ABI's single blocking export. + const asyncUses = [...source.matchAll(/\.async\b/g)]; + assert.ok(asyncUses.length > 0, "expected at least one .async use for nextEvent"); + for (const match of asyncUses) { + const before = source.slice(Math.max(0, match.index! - 40), match.index!); + assert.match(before, /nextEvent/i, "the only .async reference must be nextEvent"); + } + assert.doesNotMatch(source, /koffi\.register/, "no koffi callbacks"); +}); + +// The measurement below runs in its own `node --expose-gc` child process +// instead of in this test's own process: by the time this test would run, +// ~150 earlier tests in the suite have already grown and partly freed the +// glibc heap, so a real per-call native leak can land in already-mapped free +// space and never show up as RSS growth in *this* process (confirmed by hand: +// the in-process version of this test passed even with `nextEvent` mutated +// back to allocating fresh koffi buffers every call). A freshly spawned +// process has no such history, matching the ~8 MiB (fixed) vs ~30 MiB +// (pre-fix) split measured in isolation. +const MEMORY_PROBE_SCRIPT = ` +const { CultMeshQuicNativeRuntime } = require(process.argv[1]); +(async () => { + const runtime = await CultMeshQuicNativeRuntime.open(); + const nextEvent = runtime.nextEvent.bind(runtime); + try { + for (let i = 0; i < 2000; i += 1) await nextEvent(0, null, 0); + global.gc(); + const before = process.memoryUsage().rss; + const ITERATIONS = 200000; + for (let i = 0; i < ITERATIONS; i += 1) await nextEvent(0, null, 0); + global.gc(); + const after = process.memoryUsage().rss; + process.stdout.write(JSON.stringify({ before, after, growthBytes: after - before, ITERATIONS })); + } finally { + await runtime.release(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + +test( + "memory: sustained nextEvent calls do not leak koffi-allocated out-parameter buffers", + { timeout: 60_000 }, + (t) => { + if (!nativeBridgeAvailable()) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + + // `nextEvent` is private; the compiled module path is resolved the same + // way this file's own `import` resolves it, and handed to the child by + // argv so the child needs no path logic of its own. + const modulePath = require.resolve("../src/realtime-quic-native"); + const result = spawnSync(process.execPath, ["--expose-gc", "-e", MEMORY_PROBE_SCRIPT, modulePath], { + encoding: "utf8", + env: process.env, + }); + if (result.status !== 0 || typeof result.stdout !== "string" || result.stdout.trim().length === 0) { + if (/--expose-gc/.test(result.stderr ?? "")) { + t.skip("this Node build does not support --expose-gc."); + return; + } + assert.fail( + `memory probe child process failed (status ${String(result.status)}):\n${result.stderr ?? "(no stderr)"}`, + ); + } + + const { before, after, growthBytes, ITERATIONS } = JSON.parse(result.stdout.trim()) as { + before: number; + after: number; + growthBytes: number; + ITERATIONS: number; + }; + // Measured on Yggdrasil, same build and host, in a dedicated process: + // fixed code (0 `koffi.alloc` calls in the loop): ~8.0 MiB RSS growth. + // pre-fix code (400k `koffi.alloc` calls, never freed): ~29.9 MiB. + // 18 MiB sits well above the fixed baseline and well below the leaking + // one, so the bound stays robust to ordinary run-to-run noise while + // still catching the regression. + const BOUND_BYTES = 18 * 1024 * 1024; + assert.ok( + growthBytes < BOUND_BYTES, + `RSS grew by ${growthBytes} bytes (${(growthBytes / 1024 / 1024).toFixed(2)} MiB, from ${before} to ${after}) ` + + `over ${ITERATIONS} nextEvent calls; expected it to stay under ${BOUND_BYTES / 1024 / 1024} MiB once ` + + "out-parameter buffers are reused instead of allocated per call.", + ); + }, +); diff --git a/packages/cultmesh-ts/test/realtime-quic-provider.test.ts b/packages/cultmesh-ts/test/realtime-quic-provider.test.ts new file mode 100644 index 00000000..8a541641 --- /dev/null +++ b/packages/cultmesh-ts/test/realtime-quic-provider.test.ts @@ -0,0 +1,1083 @@ +// `CultMeshQuicRealtimeProvider`, Cut 5's StreamPixels role, exercised +// end-to-end against the real native bridge and the real consumer connector +// from Cut 4 (no mocks): reliable ordering in both directions +// (`CultMeshQuicRealtimeTransportTests.cs:71-110`), latest-only coalescing +// per `(channel, body)` key, a non-blocking broadcast, the uppercase pin, and +// eviction on peer shutdown. +// +// Skips instead of failing when no native bridge is available; see +// `realtime-quic-native.test.ts` for why. + +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import test from "node:test"; + +import { + CultMeshQuicNativeRuntime, +} from "../src/realtime-quic-native"; +import { + CultMeshQuicRealtimeConnector, + CultMeshQuicRealtimeProvider, + CultMeshQuicRealtimeTransport as CultMeshQuicRealtimeTransportClass, + parseQuicRealtimeEndpoint, + type CultMeshRealtimeCandidate, + type CultMeshRealtimeTarget, + type CultMeshRealtimeTransport, +} from "../src/realtime-quic"; +import type { CultMeshRealtimeFrame } from "../src/realtime-wire"; +import { nativeBridgeAvailable } from "./support/native-bridge"; + +// __dirname at runtime is dist-test/test; fixtures are binary and are never +// compiled/copied there, so they are read from their source location, two +// levels up. +const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); + +/** + * `assert.rejects`, but safe against a mutant or a real regression that + * makes the promise resolve instead: a transport that unexpectedly connects + * is disposed before the assertion fails, so a broken certificate check + * cannot hang this file the way M1 did before realtime-quic-consumer.test.ts + * gained this same helper. + */ +async function assertRejectsAndDisposes( + promise: Promise<{ dispose(): void }>, + match?: RegExp, +): Promise { + let resolved: { dispose(): void } | undefined; + try { + resolved = await promise; + } catch (error) { + if (match) assert.match(error instanceof Error ? error.message : String(error), match); + return; + } + resolved.dispose(); + assert.fail("expected the promise to reject, but it resolved (the resolved value has been disposed)"); +} + +function testFrame(overrides: Partial = {}): CultMeshRealtimeFrame { + return { + channelId: "aetheria.entities", + schemaId: "eve.entity_soa.v1", + bodyId: "body:aetheria:entities", + producerEpoch: 7n, + sequence: 1n, + delivery: "latest-only", + payload: new Uint8Array([3, 4, 5]), + ...overrides, + }; +} + +async function startProvider(): Promise { + return await CultMeshQuicRealtimeProvider.listen({ + host: "127.0.0.1", + port: 0, + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + }); +} + +async function dialProvider(provider: CultMeshQuicRealtimeProvider): Promise { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + endpoint: provider.advertisedEndpoint, + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + return await connector.connect(candidate, target); +} + +async function waitUntil(predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("timed out waiting for a provider condition"); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +/** + * Waits for `CultMeshQuicNativeRuntime.refCount` to stop moving: `dispose()` + * releases its runtime reference fire-and-forget (`void this.runtime.release()`), + * so a just-finished test's cleanup can still be landing when the next test + * starts. A test that snapshots refCount as a baseline without waiting for + * this first can catch that in-flight decrement mid-test and see refCount + * undershoot its own "before" value instead of returning to it. + */ +async function waitForRefCountToStabilize(quietMs = 200, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + let last = CultMeshQuicNativeRuntime.refCount; + let stableSince = Date.now(); + for (;;) { + await new Promise((resolve) => setTimeout(resolve, 20)); + const current = CultMeshQuicNativeRuntime.refCount; + if (current !== last) { + last = current; + stableSince = Date.now(); + } else if (Date.now() - stableSince >= quietMs) { + return current; + } + if (Date.now() >= deadline) return current; + } +} + +test("the advertised endpoint carries an uppercase cert-sha256 pin the Unity connector requires", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + assert.match( + provider.advertisedEndpoint, + /^cultmesh-state\+quic:\/\/127\.0\.0\.1:\d+\?cert-sha256=[0-9A-F]{64}$/, + ); + // Not merely uppercase-tolerant: it must not contain a lowercase hex digit. + const pin = new URL(provider.advertisedEndpoint).searchParams.get("cert-sha256")!; + assert.equal(pin, pin.toUpperCase()); + assert.notEqual(pin, pin.toLowerCase()); + } finally { + provider.dispose(); + } +}); + +test("reliable-ordered delivers frames in order, provider to consumer and consumer to provider", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); + await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 2n })); + const firstAtConsumer = await consumer.receiveFrame(); + const secondAtConsumer = await consumer.receiveFrame(); + assert.deepEqual([firstAtConsumer.sequence, secondAtConsumer.sequence], [1n, 2n]); + + await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 3n })); + await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 4n })); + const firstAtProvider = await provider.receive(); + const secondAtProvider = await provider.receive(); + assert.deepEqual([firstAtProvider.sequence, secondAtProvider.sequence], [3n, 4n]); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("latest-only broadcast coalesces back-to-back sends for the same key to the newest frame, and never blocks on I/O", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + // Three broadcasts for the same (channelId, bodyId) key, issued back to + // back with no await between them. `broadcast` for latest-only never + // awaits any I/O (mutating it to await the peer's send would make this + // loop, and the assertion below, run after real network round trips + // instead of within one JS turn): the synchronous middle publish is + // therefore always coalesced away, deterministically, not by luck of + // network timing. Mutating the coalescing key (e.g. dropping bodyId) + // would let sequence 200 leak through as a separate delivered frame + // instead of overwriting 100. + const before = Date.now(); + const first = provider.broadcast(testFrame({ sequence: 1n })); + const second = provider.broadcast(testFrame({ sequence: 100n })); + const third = provider.broadcast(testFrame({ sequence: 200n })); + const afterCallsReturned = Date.now(); + // All three promises exist before any of them could possibly have + // waited on the peer's round trip: the loop above ran in well under a + // network round trip's worth of time. + assert.ok(afterCallsReturned - before < 50, "broadcast must not block on any peer's I/O"); + await Promise.all([first, second, third]); + + // The consumer's own receive-side inbox (Cut 4) also coalesces + // latest-only frames it has not yet read, so whether 1 is delivered as + // its own frame or is itself coalesced away by 200 arriving first + // depends on read timing; that is Cut 4's contract, not this test's. + // What this test owns is the provider's outbox: 100 must never appear, + // and the last frame delivered must be 200. + const received: bigint[] = []; + let last: bigint | undefined; + while (last !== 200n) { + const frame = await consumer.receiveFrame(); + received.push(frame.sequence); + last = frame.sequence; + } + assert.ok(!received.includes(100n), `100 must be coalesced away, got ${received}`); + assert.ok(received.length <= 2, `expected at most 2 delivered frames, got ${received}`); + assert.equal(last, 200n); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("a stalled peer's outbox holds at most one pending frame per key across many broadcasts", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + // 200 broadcasts, none awaited individually, none read by the consumer + // until they are all issued: mirrors the interop lane's stalled-reader + // scenario at unit-test speed. `broadcast` completing this loop at all + // (bounded time) proves it never blocks on the unread peer. + const started = Date.now(); + const sends: Promise[] = []; + for (let sequence = 1; sequence <= 200; sequence += 1) { + sends.push(provider.broadcast(testFrame({ sequence: BigInt(sequence) }))); + } + await Promise.all(sends); + assert.ok(Date.now() - started < 2_000, "200 broadcasts to one key must complete quickly, unread"); + + const receivedSequences: bigint[] = []; + let last: bigint | undefined; + while (last !== 200n) { + const frame = await consumer.receiveFrame(); + receivedSequences.push(frame.sequence); + last = frame.sequence; + } + assert.equal(last, 200n); + // Coalescing means far fewer than 200 frames were actually put on the + // wire; the exact count depends on scheduling, but it must be small. + assert.ok(receivedSequences.length < 200, `expected coalescing, got ${receivedSequences.length} frames`); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test( + "20,000 client latest-only frames on one key leave at most 1 pending in the provider's receive queue", + { timeout: 120_000 }, + async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + // The exact scenario Soul's probe reproduced against the unbounded + // `readyFrames` array this replaced: a publish-only StreamPixels-style + // consumer of `receive()` never drains the provider's fan-in queue, + // so a client hammering one key must not be able to grow it past one + // entry no matter how many frames it sends. + const total = 20_000; + const sends: Promise[] = []; + for (let sequence = 1; sequence <= total; sequence += 1) { + sends.push(consumer.sendFrame(testFrame({ delivery: "latest-only", sequence: BigInt(sequence) }))); + } + await Promise.all(sends); + await waitUntil(() => provider.receiveQueueSize > 0, 30_000); + // Let any trailing send_complete events land before asserting the + // queue has settled, so this isn't racing the last few deliveries. + await new Promise((resolve) => setTimeout(resolve, 200)); + + assert.equal( + provider.receiveQueueSize, + 1, + `one client key must coalesce to at most one pending frame, got ${provider.receiveQueueSize}`, + ); + const frame = await provider.receive(); + assert.equal(frame.sequence, BigInt(total), "the one pending frame must be the newest generation sent"); + assert.equal(provider.receiveQueueSize, 0); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } + }, +); + +test( + "many distinct client latest-only keys stay bounded by the key count, not the frame count", + async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + const keyCount = 50; + const sendsPerKey = 20; + const sends: Promise[] = []; + for (let key = 0; key < keyCount; key += 1) { + for (let sequence = 1; sequence <= sendsPerKey; sequence += 1) { + sends.push( + consumer.sendFrame( + testFrame({ + delivery: "latest-only", + bodyId: `body:${key}`, + sequence: BigInt(sequence), + }), + ), + ); + } + } + await Promise.all(sends); + await waitUntil(() => provider.receiveQueueSize > 0, 10_000); + await new Promise((resolve) => setTimeout(resolve, 200)); + + assert.equal( + provider.receiveQueueSize, + keyCount, + `the queue must hold exactly one pending frame per distinct key, got ${provider.receiveQueueSize}`, + ); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } + }, +); + +test("reliable-ordered client frames are delivered in order at the provider, never coalesced", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + const total = 500; + for (let sequence = 1; sequence <= total; sequence += 1) { + await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: BigInt(sequence) })); + } + await waitUntil(() => provider.receiveQueueSize === total, 10_000); + + const received: bigint[] = []; + for (let i = 0; i < total; i += 1) { + received.push((await provider.receive()).sequence); + } + const expected = Array.from({ length: total }, (_, i) => BigInt(i + 1)); + assert.deepEqual(received, expected, "reliable-ordered frames must never be coalesced or reordered"); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("frames already queued in the provider's receive queue drain before dispose's rejection", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 1n })); + await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 2n })); + await waitUntil(() => provider.receiveQueueSize === 2); + + // Dispose while two frames are still sitting in the queue, unread. + provider.dispose(); + + const first = await provider.receive(); + const second = await provider.receive(); + assert.deepEqual([first.sequence, second.sequence], [1n, 2n], "already-queued frames must drain first"); + await assert.rejects(() => provider.receive(), /disposed/, "only a call past the drained backlog rejects"); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("unreliable broadcast fails closed", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + await assert.rejects(provider.broadcast(testFrame({ delivery: "unreliable" })), /datagrams/i); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("a peer is evicted from the provider on connection shutdown", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + await waitUntil(() => provider.connectionCount === 1); + consumer.dispose(); + await waitUntil(() => provider.connectionCount === 0); + } finally { + provider.dispose(); + } +}); + +test("a reliable-ordered send failure evicts the peer without broadcast() throwing to the caller", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + await waitUntil(() => provider.connectionCount === 1); + consumer.dispose(); + // Keep broadcasting reliable-ordered frames into the dying/dead peer's + // outbound stream for a short window: somewhere in this window the + // send to that peer fails (either the native stream call itself, or + // the peer's own CONNECTION_SHUTDOWN cleanup rejecting it), and + // CultMeshQuicRealtimeProvider.broadcast()'s per-peer try/catch must + // dispose it rather than let the rejection propagate or leave a dead + // peer counted forever. + const deadline = Date.now() + 2_000; + let sequence = 0n; + while (Date.now() < deadline && provider.connectionCount > 0) { + sequence += 1n; + // Must never throw: a removed subtraction of the catch's dispose() + // call would not itself make broadcast() throw, but a regression + // that let the rejection propagate instead of being caught at all + // would, and this loop would then fail loudly instead of hanging. + await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence })); + } + assert.equal(provider.connectionCount, 0, "the dead peer must be evicted, not counted forever"); + } finally { + provider.dispose(); + } +}); + +test("connectionCount reflects accepted peers, and dispose releases the runtime for a fresh listen", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + assert.equal(provider.connectionCount, 0); + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + } finally { + consumer.dispose(); + provider.dispose(); + } + // A leaked runtime reference from any earlier path (accept, handshake + // timeout, dispose) would leave the shared runtime unable to open a fresh + // listener cleanly; opening and tearing down a second provider proves + // release ran on every path exercised above. + const again = await startProvider(); + again.dispose(); +}); + +test("the runtime reference is released when extracting the certificate fails, before any listener opens", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const before = CultMeshQuicNativeRuntime.refCount; + await assert.rejects( + CultMeshQuicRealtimeProvider.listen({ + host: "127.0.0.1", + port: 0, + // Not a PKCS12 credential at all: extractLeafCertificateDer() throws + // before CultMeshQuicNativeRuntime.open() is ever called, so there is + // no reference to leak on this path; this pins that (trivial but + // real) invariant rather than exercising listen()'s own + // `catch { await runtime.release(); throw error; }`, which the next + // test covers with a listener-level failure instead. + serverCertificate: { pkcs12: new Uint8Array([1, 2, 3, 4]), password: "" }, + }), + ); + assert.equal(CultMeshQuicNativeRuntime.refCount, before, "must not leak a reference that was never opened"); +}); + +test("the runtime reference is released when the listener itself fails to open", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const holder = await startProvider(); + try { + const boundPort = new URL(holder.advertisedEndpoint).port; + const before = CultMeshQuicNativeRuntime.refCount; + // A real, valid PKCS12 (so extractLeafCertificateDer succeeds and + // CultMeshQuicNativeRuntime.open() actually runs), but the port + // `holder` already has bound: listenerOpen() itself must fail, driving + // listen()'s `catch { await runtime.release(); throw error; }`. + await assert.rejects( + CultMeshQuicRealtimeProvider.listen({ + host: "127.0.0.1", + port: Number(boundPort), + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + }), + ); + assert.equal( + CultMeshQuicNativeRuntime.refCount, + before, + "a listenerOpen() failure must release the reference listen() had already opened", + ); + } finally { + holder.dispose(); + } +}); + +test( + "the runtime reference is released on dispose with a pending send in flight", + { timeout: 10_000 }, + async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const before = CultMeshQuicNativeRuntime.refCount; + const provider = await startProvider(); + const consumer = await dialProvider(provider); + await waitUntil(() => provider.connectionCount === 1); + + // Start a reliable-ordered broadcast and dispose before it can possibly + // have settled; dispose() must tear the peer down (and release its + // runtime reference) regardless of the outstanding send. `broadcast()` + // must itself never hang: an explicit `{ timeout }` above fails this + // test loudly, rather than the whole file, if it ever does. + const broadcasting = provider.broadcast(testFrame({ delivery: "reliable-ordered" })).catch(() => {}); + provider.dispose(); + await broadcasting; + consumer.dispose(); + await waitUntil(() => CultMeshQuicNativeRuntime.refCount === before); + }, +); + +test( + "dispose() evicts a connection still mid-handshake, not just fully attached peers", + { timeout: 10_000 }, + async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + // A long handshake timeout: without dispose() sweeping still-pending + // accepts (fixed alongside this test), a connection whose handshake + // never reaches CONNECTED — here, the client rejects the provider's + // certificate — would sit on its own handshake timer, and once + // attachPeer's own CultMeshQuicNativeRuntime.open() resolves, hold a + // runtime reference, for up to this long after dispose() returns. Found + // this way: mutating the certificate-pin comparison to be + // case-sensitive (M2) made every provider test that dials with an + // uppercase-pinned endpoint reject during the handshake, and the whole + // file then hung for over two minutes past its last visible test. + // Earlier tests' dispose() releases fire-and-forget; wait for the + // shared refCount to actually settle before treating it as a baseline + // (see waitForRefCountToStabilize's own doc comment). + const before = await waitForRefCountToStabilize(); + const provider = await CultMeshQuicRealtimeProvider.listen({ + host: "127.0.0.1", + port: 0, + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + handshakeTimeoutMs: 30_000, + }); + try { + const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + const candidate: CultMeshRealtimeCandidate = { + // A pin that can never match this provider's real certificate: the + // connector rejects it during CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, + // before the connection ever reaches CONNECTED. + endpoint: provider.advertisedEndpoint.replace(/cert-sha256=[0-9A-F]+/, `cert-sha256=${"0".repeat(64)}`), + authorityRuntimeId: target.authorityRuntimeId, + priority: 0, + generation: "gen-1", + }; + const connector = new CultMeshQuicRealtimeConnector(); + // If the certificate check ever regresses into accepting everything + // (M1), this resolves into a fully attached peer instead of rejecting; + // assertRejectsAndDisposes() disposes the client-side transport either + // way, but only the outer `finally` below disposes the provider itself + // (and the peer it would then hold), which is what actually closes + // the file-hanging gap M1 found here. + await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); + + // The provider's own accept flow is asynchronous (attachPeer awaits a + // runtime reference before it can even see the rejection); give it a + // moment to actually start before disposing, so this test exercises a + // real in-flight pending accept rather than one that never began. + await new Promise((resolve) => setTimeout(resolve, 100)); + + const disposedAt = Date.now(); + provider.dispose(); + await waitUntil(() => CultMeshQuicNativeRuntime.refCount === before, 5_000); + assert.ok( + Date.now() - disposedAt < 5_000, + "dispose() must evict a mid-handshake connection promptly, not wait out its 30s handshake timeout", + ); + } finally { + provider.dispose(); + } + }, +); + +test("a peer attaching after two latest-only broadcasts for one key receives exactly the newer frame", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + await provider.broadcast(testFrame({ sequence: 1n, payload: new Uint8Array([1]) })); + await provider.broadcast(testFrame({ sequence: 2n, payload: new Uint8Array([2]) })); + + // Attach after both broadcasts have already resolved: nothing in this + // peer's outbox came from `broadcast()`'s own fan-out, only from the + // retained seed on attach. + const consumer = await dialProvider(provider); + try { + const seeded = await consumer.receiveFrame(); + assert.equal(seeded.sequence, 2n, "a late joiner must be seeded with the newest retained generation"); + assert.deepEqual(seeded.payload, new Uint8Array([2])); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("retention keeps the newer generation when an older one broadcasts afterward", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + await provider.broadcast(testFrame({ sequence: 5n, payload: new Uint8Array([5]) })); + // An older generation broadcast after a newer one must not overwrite the + // retained frame: mutating the retention comparison to a plain + // last-write-wins assignment would regress this. + await provider.broadcast(testFrame({ sequence: 3n, payload: new Uint8Array([3]) })); + + const consumer = await dialProvider(provider); + try { + const seeded = await consumer.receiveFrame(); + assert.equal(seeded.sequence, 5n, "an older generation must not replace the retained newer one"); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("a reliable-ordered frame is never retained and is not delivered to a late joiner", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const firstConsumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 9n })); + assert.deepEqual((await firstConsumer.receiveFrame()).sequence, 9n); + } finally { + firstConsumer.dispose(); + } + await waitUntil(() => provider.connectionCount === 0); + + // A second peer attaches after the reliable-ordered frame above and + // after a real latest-only frame, so the assertion below proves the + // reliable-ordered frame specifically was never retained, rather than + // merely proving the provider had nothing at all to seed. + await provider.broadcast(testFrame({ delivery: "latest-only", sequence: 1n, payload: new Uint8Array([1]) })); + const secondConsumer = await dialProvider(provider); + try { + const seeded = await secondConsumer.receiveFrame(); + assert.equal(seeded.delivery, "latest-only"); + assert.equal(seeded.sequence, 1n, "only the latest-only frame may be seeded, never the reliable-ordered one"); + } finally { + secondConsumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test( + "fix 1: a peer stays connected and keeps receiving well past handshakeTimeoutMs", + { timeout: 10_000 }, + async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + // Must fail on the pre-fix code: the buffering-handler/attachPeer swap + // there loses `state.connected` to whichever handler happens to be + // registered when CONNECTED actually arrives, and the handshake timer + // evicts a fully healthy peer at this deadline regardless. + const handshakeTimeoutMs = 500; + const provider = await CultMeshQuicRealtimeProvider.listen({ + host: "127.0.0.1", + port: 0, + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + handshakeTimeoutMs, + }); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + await new Promise((resolve) => setTimeout(resolve, handshakeTimeoutMs * 3)); + assert.equal(provider.connectionCount, 1, "a connected peer must not be evicted by the handshake timer"); + + await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); + const received = await consumer.receiveFrame(); + assert.equal(received.sequence, 1n, "a peer past the handshake deadline must still receive frames"); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } + }, +); + +test("fix 2: an invalid frame throws from broadcast, and connectionCount is unchanged", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + // A 70,000-byte channelId exceeds the wire's 0xffff identity limit: + // `encodeRealtimeFrame` throws. Before fix 2, encoding happened once + // per peer inside `sendFrame`, so the same failure evicted every peer + // while `broadcast()` itself reported success. + await assert.rejects( + provider.broadcast(testFrame({ delivery: "reliable-ordered", channelId: "x".repeat(70_000) })), + /exceeds the QUIC wire limit/i, + ); + assert.equal(provider.connectionCount, 1, "an invalid frame must not evict any peer"); + + // The peer must still be usable afterward: prove the connection itself + // survived, not merely that the count field wasn't decremented. + await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 42n })); + assert.equal((await consumer.receiveFrame()).sequence, 42n); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("fix 2: a latest-only invalid frame also throws from broadcast without evicting any peer", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + await assert.rejects( + provider.broadcast(testFrame({ delivery: "latest-only", channelId: "x".repeat(70_000) })), + /exceeds the QUIC wire limit/i, + ); + assert.equal(provider.connectionCount, 1, "an invalid latest-only frame must not evict any peer"); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("fix 3: advertisedHost is used in place of the bind address, and round-trips through the module's parser", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await CultMeshQuicRealtimeProvider.listen({ + host: "0.0.0.0", + port: 0, + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + advertisedHost: "streampixels.gamecult.org", + }); + try { + assert.match( + provider.advertisedEndpoint, + /^cultmesh-state\+quic:\/\/streampixels\.gamecult\.org:\d+\?cert-sha256=[0-9A-F]{64}$/, + ); + const parsed = parseQuicRealtimeEndpoint(provider.advertisedEndpoint); + assert.equal(parsed.host, "streampixels.gamecult.org"); + assert.equal(parsed.port, Number(new URL(provider.advertisedEndpoint).port)); + assert.ok(parsed.certificateSha256); + } finally { + provider.dispose(); + } +}); + +test("fix 3: an IPv6 bind advertises a bracketed literal that round-trips through the module's parser", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await CultMeshQuicRealtimeProvider.listen({ + host: "::1", + port: 0, + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + }); + try { + assert.match( + provider.advertisedEndpoint, + /^cultmesh-state\+quic:\/\/\[::1\]:\d+\?cert-sha256=[0-9A-F]{64}$/, + ); + // `URL`'s own `hostname` keeps the brackets for an IPv6 literal (unlike + // the unbracketed form `formatEndpointHost` accepted as input); the + // round trip's job is proving the endpoint parses at all, not stripping + // brackets the WHATWG URL parser itself never strips. + const parsed = parseQuicRealtimeEndpoint(provider.advertisedEndpoint); + assert.equal(parsed.host, "[::1]"); + } finally { + provider.dispose(); + } +}); + +test("fix 4: receive() with an already-aborted signal rejects immediately instead of hanging", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const controller = new AbortController(); + controller.abort(); + await assert.rejects(provider.receive(controller.signal), /aborted/i); + } finally { + provider.dispose(); + } +}); + +test("P3: a latest-only send failure evicts the peer without broadcast() throwing to the caller", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + await waitUntil(() => provider.connectionCount === 1); + consumer.dispose(); + // Mirrors the existing reliable-ordered eviction test, but for the + // outbox's own send failure path (`CultMeshQuicRealtimeProviderOutbox.pump`'s + // catch, which disposes the peer and reports failure through + // `onSendFailure`) rather than `broadcast()`'s direct per-peer catch. + // + // What made this flaky: unlike the reliable-ordered version, + // `broadcast()` never awaits a latest-only peer's actual send — it only + // hands the frame to that peer's outbox and returns (see + // `CultMeshQuicRealtimeProvider.broadcast`). A `while (Date.now() < + // deadline)` loop therefore does no real I/O per iteration; it just + // spins encoding and enqueueing frames as fast as the CPU allows, + // starving the event-loop turns the native pump needs to actually + // observe the dead connection and fire `send_complete`/ + // `CONNECTION_SHUTDOWN`. Under any extra load in the test process + // (exactly what running under mutation testing adds, regardless of + // which line was mutated) that starvation can burn the whole wall-clock + // bound without ever landing an attempt past the point the connection + // is actually dead — a failure with nothing to do with the mutant under + // test. Raising the bound only buys more spinning, not more real + // attempts. Fixed by decoupling "how many attempts to seed" (a small, + // fixed count, each with a real yield) from "how long to wait for the + // result" (a single `waitUntil` afterward, which is what actually + // bounds this test). + for (let sequence = 0n; sequence < 20n && provider.connectionCount > 0; sequence += 1n) { + await provider.broadcast(testFrame({ delivery: "latest-only", sequence, bodyId: `body:${sequence}` })); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + await waitUntil(() => provider.connectionCount === 0); + assert.equal(provider.connectionCount, 0, "the dead peer must be evicted, not counted forever"); + } finally { + provider.dispose(); + } +}); + +test("I1: an inbox key keeps its first-arrival position when a newer frame overwrites it", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + // Key A arrives first, then key B, then A is republished with a new + // value while still unread: `CultMeshRealtimeInbox.publish`'s + // coalescing must overwrite A's *value* in place, not move it to the + // back of the queue behind B. `received` is exactly that inbox, + // reached through the provider's client fan-in. + await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:A", sequence: 1n })); + await waitUntil(() => provider.receiveQueueSize === 1); + await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:B", sequence: 1n })); + await waitUntil(() => provider.receiveQueueSize === 2); + await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:A", sequence: 2n })); + // Still 2: the second A publish must coalesce onto the already-queued + // A entry, not add a third. + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(provider.receiveQueueSize, 2, "a republish of a still-pending key must not grow the queue"); + + const first = await provider.receive(); + const second = await provider.receive(); + assert.equal(first.bodyId, "body:A", "A must still drain first: its position must survive the overwrite"); + assert.equal(first.sequence, 2n, "A's value must be the newer, overwritten one"); + assert.equal(second.bodyId, "body:B"); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("L1: the outbox keeps one send in flight per peer; the next frame dequeues only after send_complete", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + // Prototype-patched for the life of this test, restored in `finally`: + // the provider builds its peer transport internally, so this is the + // only vantage point outside the module from which to observe + // concurrency of `sendEncodedFrame` calls, which is what + // `CultMeshQuicRealtimeProviderOutbox.pump` serializes one-at-a-time + // against `send_complete`. + const original = CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame; + let active = 0; + let maxActive = 0; + CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame = function ( + this: InstanceType, + delivery: "reliable-ordered" | "latest-only", + encoded: Uint8Array, + ) { + active += 1; + maxActive = Math.max(maxActive, active); + return original.call(this, delivery, encoded).finally(() => { + active -= 1; + }); + }; + try { + const total = 20; + const sends: Promise[] = []; + for (let key = 0; key < total; key += 1) { + sends.push(provider.broadcast(testFrame({ bodyId: `body:${key}`, sequence: 1n }))); + } + await Promise.all(sends); + + const seen = new Set(); + while (seen.size < total) { + const frame = await consumer.receiveFrame(); + seen.add(frame.bodyId); + } + } finally { + CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame = original; + } + assert.equal(maxActive, 1, "the outbox pump must never have two sends in flight for one peer at once"); + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("P15: broadcast() after dispose rejects instead of touching a torn-down provider", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + provider.dispose(); + await assert.rejects(provider.broadcast(testFrame()), /disposed/i); +}); + +test("P17: an accept arriving after dispose is refused, not attached as a peer", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await startProvider(); + const runtime = await CultMeshQuicNativeRuntime.open(); + let connectionId: bigint | undefined; + try { + // A real native connection handle this test owns outright (never dialed + // anywhere reachable, so shutting it down is safe): stands in for the + // handle a genuine NEW_CONNECTION event would have carried, without + // fabricating a bogus native pointer. `acceptConnection` is private; + // reaching it directly is what lets this test drive the exact race + // (an accept event landing after `dispose()` has already run) without + // depending on real listener-close timing to reproduce it. + connectionId = runtime.connectionOpen("127.0.0.1", 1); + provider.dispose(); + // This synthetic connection never reaches CONNECTED either way (it was + // never dialed anywhere), so `connectionCount` alone cannot distinguish + // "refused" from "attached but still handshaking". `retain()` is + // synchronous and only the attach path calls it: a stable `refCount` + // across the call proves no transport (and no runtime reference) was + // ever allocated for this connection. + const beforeRefCount = CultMeshQuicNativeRuntime.refCount; + assert.doesNotThrow(() => + (provider as unknown as { acceptConnection(id: bigint): void }).acceptConnection(connectionId!), + ); + assert.equal( + CultMeshQuicNativeRuntime.refCount, + beforeRefCount, + "a post-dispose accept must never retain a runtime reference for a new transport", + ); + assert.equal(provider.connectionCount, 0, "a post-dispose accept must never join peers"); + } finally { + if (connectionId !== undefined) { + try { + runtime.connectionShutdown(connectionId, 0n); + } catch { + // Best-effort: acceptConnection's own disposed-path may already have shut it down. + } + } + await runtime.release(); + } +}); + +test("fix 1: a publish-only provider faults a client that sends to it, and its memory stays empty", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await CultMeshQuicRealtimeProvider.listen({ + host: "127.0.0.1", + port: 0, + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + acceptClientFrames: false, + }); + try { + const consumer = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + // receive() itself rejects clearly rather than hanging forever. + await assert.rejects(provider.receive(), /publish-only/i); + + // Any inbound stream from the client faults that connection. The + // client's own `sendFrame` promise settles on its *local* stream + // send completing (MsQuic hands the bytes off and reports + // send_complete without waiting for the peer to process them), so it + // may well resolve even though the server already faulted its side — + // the proof this test owns is the server's state, not the client's + // send outcome, so the client send is fired and ignored either way. + void consumer.sendFrame(testFrame({ delivery: "latest-only" })).catch(() => {}); + await waitUntil(() => provider.connectionCount === 0); + assert.equal(provider.receiveQueueSize, 0, "no frame from a faulted publish-only client may be queued"); + + // The provider itself is otherwise healthy: broadcasting still works + // for a freshly attached peer. + const second = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); + assert.equal((await second.receiveFrame()).sequence, 1n); + } finally { + second.dispose(); + } + } finally { + consumer.dispose(); + } + } finally { + provider.dispose(); + } +}); + +test("fix 2: a connection beyond maxConnections is refused and existing peers are unaffected", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + const provider = await CultMeshQuicRealtimeProvider.listen({ + host: "127.0.0.1", + port: 0, + serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, + maxConnections: 1, + }); + try { + const first = await dialProvider(provider); + try { + await waitUntil(() => provider.connectionCount === 1); + + // Refused at accept: never reaches CONNECTED, never becomes a peer. + await assertRejectsAndDisposes(dialProvider(provider), /certificate|rejected|closed|shut|timed out/i); + // Give the refusal a moment to be visible either way, then prove the + // existing peer was never touched. + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(provider.connectionCount, 1, "the existing peer must be unaffected by a refused connection"); + + await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 7n })); + assert.equal((await first.receiveFrame()).sequence, 7n, "the existing peer must still be fully usable"); + } finally { + first.dispose(); + } + } finally { + provider.dispose(); + } +}); diff --git a/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts b/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts new file mode 100644 index 00000000..f7958570 --- /dev/null +++ b/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts @@ -0,0 +1,43 @@ +// Regression test for fix 2: CultMeshQuicNativeRuntime.release() must await +// the pump loop's exit before calling cultmesh_quic_runtime_close, never +// after. Reverting that order (close before awaiting the pump) reliably +// crashes this test under a saturated libuv thread pool and MALLOC_PERTURB_, +// at every pool size tried by hand while verifying this batch; restored +// immediately afterward, never committed. +// +// UV_THREADPOOL_SIZE and MALLOC_PERTURB_ must be set before Node starts, so +// this spawns a child `node` process running test/support/release-uaf-probe.js +// with that environment, once per pool size, and asserts it exits 0. Kept to +// one scenario (see the probe's own header) so the whole test stays bounded +// to a few seconds. + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import test from "node:test"; + +import { nativeBridgeAvailable } from "./support/native-bridge"; + +const PROBE_SCRIPT = join(__dirname, "..", "..", "test", "support", "release-uaf-probe.js"); + +test("fix 2 regression: release()'s ordering survives a saturated thread pool at pool sizes 1, 2 and 4", async (t) => { + if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); + + for (const poolSize of [1, 2, 4]) { + const result = spawnSync(process.execPath, [PROBE_SCRIPT], { + env: { + ...process.env, + UV_THREADPOOL_SIZE: String(poolSize), + MALLOC_PERTURB_: "165", + }, + encoding: "utf8", + timeout: 60_000, + }); + assert.equal( + result.status, + 0, + `pool size ${poolSize}: probe exited status=${result.status} signal=${result.signal}\n` + + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +}); diff --git a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts b/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts new file mode 100644 index 00000000..e1843466 --- /dev/null +++ b/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts @@ -0,0 +1,358 @@ +// `CultMeshQuicRealtimeSessionManager`'s own rules, isolated from the native +// bridge with fake connectors/transports: eviction on transport death (fix +// 4), single-flight (fix 4), the disposed guard (fix 4), first-success racing +// with abort of the losers (fix 5), the `protocolIds` filter (M7), the +// `authorityRuntimeId` filter (M8), the session key including +// `authorityRuntimeId` (M9), and the `maxRacedCandidates` cap (M6). +// +// Routes use `trust.mode = "local-development"` over loopback endpoints, so +// `verifyAuthorityRoute` short-circuits without a signed certificate +// (`cultmesh-authority.ts:99`): these tests are about the session manager's +// own binding/racing/caching rules, not route trust, which +// `realtime-quic-consumer.test.ts`'s trust negatives already pin. + +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { CultMeshAuthorityTrustPolicy } from "cultnet-ts"; +import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; + +import { + CultMeshQuicRealtimeSessionManager, + CultMeshStaticRealtimeLookupSource, + CULTMESH_REALTIME_STATE_PROTOCOL_ID, + type CultMeshRealtimeCandidate, + type CultMeshRealtimeTarget, + type CultMeshRealtimeTransport, + type CultMeshRealtimeTransportConnector, +} from "../src/realtime-quic"; + +const trust: CultMeshAuthorityTrustPolicy = { mode: "local-development", odinRoots: [] }; + +function route(overrides: Partial = {}): CultMeshAuthorityRouteMessage { + return { + authorityRuntimeId: "service:aetheria.daemon", + endpoint: "cultmesh-state+quic://127.0.0.1:9443", + protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], + priority: 0, + generation: "gen-1", + ...overrides, + }; +} + +function verseWithRoutes(verseId: string, routes: readonly CultMeshAuthorityRouteMessage[]): CultMeshVerseDescriptorMessage { + return { + verseId, + displayName: verseId, + authorityModel: "operator-cluster", + compatibility: { transportVersion: "cultmesh.v1", rulesHash: "test", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, + discoveryEndpoints: routes.map((r) => r.endpoint), + authorityRuntimeIds: [...new Set(routes.map((r) => r.authorityRuntimeId))], + authorityRoutes: [...routes], + }; +} + +interface FakeTransportHandle { + readonly transport: CultMeshRealtimeTransport; + disposed(): boolean; +} + +/** A transport that never resolves `receiveFrame` and tracks its own disposal. */ +function fakeTransport(endpoint: string, verified = true): FakeTransportHandle { + let disposed = false; + const disposalHandlers: Array<() => void> = []; + const transport: CultMeshRealtimeTransport = { + transportId: "fake", + endpoint, + async sendFrame() {}, + async receiveFrame() { + return await new Promise(() => {}); + }, + dispose() { + if (disposed) return; + disposed = true; + for (const handler of disposalHandlers) handler(); + }, + isVerifiedFor: () => verified, + onDisposed(handler) { + if (disposed) { + handler(); + return; + } + disposalHandlers.push(handler); + }, + }; + return { transport, disposed: () => disposed }; +} + +/** A connector that resolves immediately (unless `delayMs` is given) with one fake transport per call. */ +function spyConnector( + connectorId: string, + priority: number, + options: { delayMs?: number; fails?: boolean; ignoreAbort?: boolean } = {}, +): { + connector: CultMeshRealtimeTransportConnector; + calls: CultMeshRealtimeCandidate[]; + handles: FakeTransportHandle[]; + /** Timestamps at which this connector's abort signal actually fired. */ + aborts: number[]; +} { + const calls: CultMeshRealtimeCandidate[] = []; + const handles: FakeTransportHandle[] = []; + const aborts: number[] = []; + const connector: CultMeshRealtimeTransportConnector = { + connectorId, + priority, + canConnect: () => true, + connect: async (candidate, _target, signal) => { + calls.push(candidate); + if (options.delayMs) { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, options.delayMs); + if (!options.ignoreAbort) { + signal?.addEventListener("abort", () => { + clearTimeout(timer); + aborts.push(Date.now()); + reject(new Error(`${connectorId} connect aborted`)); + }); + } + }); + } + if (options.fails) throw new Error(`${connectorId} refused to connect`); + const handle = fakeTransport(candidate.endpoint); + handles.push(handle); + return handle.transport; + }, + }; + return { connector, calls, handles, aborts }; +} + +const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; + +test("fix 4: connect() caches a session and evicts it once the transport dies, so the next connect redials", async () => { + const { connector, calls } = spyConnector("fake", 0); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), + trust, + connectors: [connector], + }); + + const first = await manager.connect(target); + assert.equal(await manager.connect(target), first, "a live session is returned from cache, not redialed"); + assert.equal(calls.length, 1); + + first.dispose(); + const second = await manager.connect(target); + assert.notEqual(second, first, "a dead session is evicted, so the next connect redials"); + assert.equal(calls.length, 2); +}); + +test("fix 4: concurrent connect() calls for the same target share one dial (single-flight)", async () => { + const { connector, calls } = spyConnector("fake", 0, { delayMs: 50 }); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), + trust, + connectors: [connector], + }); + + const [a, b, c] = await Promise.all([manager.connect(target), manager.connect(target), manager.connect(target)]); + assert.equal(a, b); + assert.equal(b, c); + assert.equal(calls.length, 1, "three concurrent callers must share one dial"); +}); + +test("fix 4/low: dispose() aborts a dial in flight instead of letting it land and discarding the result", async () => { + // A long delay: if `dispose()` did not cancel the dial, this test would + // have to wait it out (or race a timing-dependent assertion) to observe + // that the eventually-created transport gets disposed instead of cached. + const { connector, handles } = spyConnector("fake", 0, { delayMs: 5_000 }); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), + trust, + connectors: [connector], + }); + + const pending = manager.connect(target); + const start = Date.now(); + manager.dispose(); + await assert.rejects(pending); + assert.ok( + Date.now() - start < 1_000, + "dispose() must cancel the in-flight dial rather than waiting out the connector's own timeout", + ); + assert.equal(handles.length, 0, "an aborted dial must never construct a transport only to immediately discard it"); + await assert.rejects(manager.connect(target), /disposed/i, "a disposed manager refuses further connects"); +}); + +test("fix 4/low: disconnect() during a dial does not let the dial cache a session once it lands", async () => { + const { connector, handles } = spyConnector("fake", 0, { delayMs: 30 }); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), + trust, + connectors: [connector], + }); + + const pending = manager.connect(target); + manager.disconnect(target); + await assert.rejects(pending); + // Whether or not the aborted dial still managed to construct a transport + // (a race against the abort), it must end up disposed, never cached. + await new Promise((resolve) => setTimeout(resolve, 60)); + for (const handle of handles) assert.equal(handle.disposed(), true, "a cancelled dial's transport must be disposed"); + + // The manager itself is still usable: a fresh connect redials and succeeds. + const { connector: connector2 } = spyConnector("fake", 0); + const manager2 = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), + trust, + connectors: [connector2], + }); + const fresh = await manager2.connect(target); + assert.ok(fresh); +}); + +test("fix 5: the race returns the first verified success and aborts the losers", async () => { + const winner = spyConnector("fast", 0, { delayMs: 10 }); + const loser = spyConnector("slow", 0, { delayMs: 5_000 }); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([ + verseWithRoutes(target.verseId, [ + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), + ]), + ]), + trust, + connectors: [winner.connector, loser.connector], + maxRacedCandidates: 2, + }); + + const startedAt = Date.now(); + const transport = await manager.connect(target); + const elapsedMs = Date.now() - startedAt; + assert.ok(elapsedMs < 1_000, `connect() must return on the first success, not wait out the slow route (took ${elapsedMs}ms)`); + assert.equal(transport.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); +}); + +test("F5abort: a losing candidate is told to abort promptly, not left to run out its own timeout", async () => { + const winner = spyConnector("fast", 0, { delayMs: 10 }); + const loser = spyConnector("slow", 0, { delayMs: 5_000 }); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([ + verseWithRoutes(target.verseId, [ + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), + ]), + ]), + trust, + connectors: [winner.connector, loser.connector], + maxRacedCandidates: 2, + }); + + const startedAt = Date.now(); + await manager.connect(target); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal(loser.aborts.length, 1, "the losing candidate's connect() must observe an abort signal"); + assert.ok( + loser.aborts[0]! - startedAt < 1_000, + "the abort must arrive promptly after the winner is decided, not after the loser's own 5s timeout", + ); +}); + +test("F5loser: a losing candidate that connects anyway (past the point abort helps) is disposed, not left dangling", async () => { + const winner = spyConnector("fast", 0, { delayMs: 10 }); + // Ignores the abort signal entirely, simulating a candidate already past + // the point where cancellation helps: it still produces a real transport. + const loser = spyConnector("slow", 0, { delayMs: 80, ignoreAbort: true }); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([ + verseWithRoutes(target.verseId, [ + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), + ]), + ]), + trust, + connectors: [winner.connector, loser.connector], + maxRacedCandidates: 2, + }); + + const transport = await manager.connect(target); + assert.equal(transport.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal(loser.handles.length, 1, "the loser still connects despite racing to lose"); + assert.equal( + loser.handles[0]!.disposed(), + true, + "a losing candidate that connects after the race is decided must be disposed, never left connected", + ); +}); + +test("M6: maxRacedCandidates caps how many candidates in a tier are attempted", async () => { + const spy = spyConnector("fake", 0); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([ + verseWithRoutes(target.verseId, [ + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001", priority: 0 }), + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002", priority: 1 }), + route({ endpoint: "cultmesh-state+quic://127.0.0.1:9003", priority: 2 }), + ]), + ]), + trust, + connectors: [spy.connector], + maxRacedCandidates: 1, + }); + + await manager.connect(target); + assert.equal(spy.calls.length, 1, "only the top maxRacedCandidates entries in the tier are attempted"); + assert.equal(spy.calls[0]!.endpoint, "cultmesh-state+quic://127.0.0.1:9001", "the lowest route priority races first"); +}); + +test("M7: a route whose protocolIds omit the realtime-state protocol is not a candidate", async () => { + const spy = spyConnector("fake", 0); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([ + verseWithRoutes(target.verseId, [route({ protocolIds: ["cultmesh.other_protocol.v1"] })]), + ]), + trust, + connectors: [spy.connector], + }); + + await assert.rejects(manager.connect(target), /No realtime state route was advertised/); + assert.equal(spy.calls.length, 0); +}); + +test("M8: a route advertised for a different authorityRuntimeId is not a candidate", async () => { + const spy = spyConnector("fake", 0); + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([ + verseWithRoutes(target.verseId, [route({ authorityRuntimeId: "service:other.daemon" })]), + ]), + trust, + connectors: [spy.connector], + }); + + await assert.rejects(manager.connect(target), /No realtime state route was advertised/); + assert.equal(spy.calls.length, 0); +}); + +test("M9: the session key includes authorityRuntimeId, so two authorities in the same Verse get separate sessions", async () => { + const spy = spyConnector("fake", 0); + const targetA: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:a.daemon" }; + const targetB: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:b.daemon" }; + const manager = new CultMeshQuicRealtimeSessionManager({ + lookupSource: new CultMeshStaticRealtimeLookupSource([ + verseWithRoutes("aetheria", [ + route({ authorityRuntimeId: "service:a.daemon", endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), + route({ authorityRuntimeId: "service:b.daemon", endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), + ]), + ]), + trust, + connectors: [spy.connector], + }); + + const sessionA = await manager.connect(targetA); + const sessionB = await manager.connect(targetB); + assert.notEqual(sessionA, sessionB); + assert.equal(sessionA.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); + assert.equal(sessionB.endpoint, "cultmesh-state+quic://127.0.0.1:9002"); + assert.equal(spy.calls.length, 2); +}); diff --git a/packages/cultmesh-ts/test/support/native-bridge.ts b/packages/cultmesh-ts/test/support/native-bridge.ts new file mode 100644 index 00000000..74929836 --- /dev/null +++ b/packages/cultmesh-ts/test/support/native-bridge.ts @@ -0,0 +1,20 @@ +// Shared by every QUIC test file that skips instead of failing when no native +// bridge is built: true only when both the bridge and its `msquic` dependency +// are present under `CULTMESH_QUIC_NATIVE_DIR`. + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export function nativeBridgeAvailable(): boolean { + const dir = process.env.CULTMESH_QUIC_NATIVE_DIR; + if (!dir) return false; + try { + const bridge = process.platform === "win32" ? "gamecult_mesh_quic_native.dll" : "libgamecult_mesh_quic_native.so"; + const dependency = process.platform === "win32" ? "msquic.dll" : "libmsquic.so.2"; + readFileSync(join(dir, bridge)); + readFileSync(join(dir, dependency)); + return true; + } catch { + return false; + } +} diff --git a/packages/cultmesh-ts/test/support/release-uaf-probe.js b/packages/cultmesh-ts/test/support/release-uaf-probe.js new file mode 100644 index 00000000..88d0b29c --- /dev/null +++ b/packages/cultmesh-ts/test/support/release-uaf-probe.js @@ -0,0 +1,79 @@ +"use strict"; +// Fix 2 regression probe: CultMeshQuicNativeRuntime.release() must await the +// pump loop's exit BEFORE calling cultmesh_quic_runtime_close, never after. +// The bridge header forbids any host call beginning once +// cultmesh_quic_runtime_close has started; the old order let a queued, +// not-yet-started nextEvent call begin after close and run against freed +// memory. +// +// Plain JS, not TypeScript: this file is spawned directly as a child `node` +// process (see ../release-uaf.test.ts) so that UV_THREADPOOL_SIZE and +// MALLOC_PERTURB_ are in effect before Node's own thread pool and allocator +// initialize, which setting them from within an already-running process +// cannot achieve. It requires the compiled dist/ output directly, exactly +// like the package's own compiled tests do. +// +// Kept to one scenario (bare open/release cycles, including a fresh open +// while the previous runtime is still closing) so the probe stays bounded to +// a few seconds; it is the scenario that exercises release()'s ordering +// directly, without a listener or a peer. +const path = require("node:path"); +const { pbkdf2 } = require("node:crypto"); + +const root = path.join(__dirname, "..", ".."); +const native = require(path.join(root, "dist", "realtime-quic-native.js")); +const RT = native.CultMeshQuicNativeRuntime; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +let saturating = false; +function saturate(n) { + saturating = true; + const one = () => { + if (!saturating) return; + pbkdf2("x", "y", 20000, 32, "sha256", () => one()); + }; + for (let i = 0; i < n; i += 1) one(); +} +function stopSaturate() { + saturating = false; +} + +const CYCLES = Number(process.env.UAF_PROBE_CYCLES || 20); + +(async () => { + const pool = Number(process.env.UV_THREADPOOL_SIZE || 4); + saturate(pool * 4); + try { + for (let i = 0; i < CYCLES; i += 1) { + const rt = await RT.open(); + if (i % 3 === 0) await sleep(Math.random() * 5); + if (i % 2 === 0) { + // Release the old runtime and open a fresh one without waiting for + // the release to finish: the fresh open can land while the old + // runtime is still closing, which is exactly the overlap fix 2's + // ordering protects. + const releasing = rt.release(); + const rt2 = await RT.open(); + await sleep(Math.random() * 3); + await releasing; + await rt2.release(); + } else { + await rt.release(); + } + } + } finally { + stopSaturate(); + } + if (RT.refCount !== 0) { + console.error(`FAIL: refCount=${RT.refCount} after ${CYCLES} open/release cycles, want 0`); + process.exitCode = 1; + return; + } + console.log(`OK: ${CYCLES} open/release cycles at pool=${pool}, refCount=0`); +})().catch((error) => { + console.error("FAIL: probe threw", error); + process.exitCode = 1; +}); diff --git a/packages/cultmesh-ts/tsconfig.json b/packages/cultmesh-ts/tsconfig.json index c817d123..18302259 100644 --- a/packages/cultmesh-ts/tsconfig.json +++ b/packages/cultmesh-ts/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "CommonJS", - "moduleResolution": "Node", + "module": "Node16", + "moduleResolution": "Node16", "strict": true, "declaration": true, "declarationMap": true, diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts index 31cbcb61..c260cf70 100644 --- a/packages/cultnet-ts/src/cultmesh-authority.ts +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -6,7 +6,7 @@ // through the same functions; the C# reference is // `src/GameCult.Mesh/CultMeshAuthorityProof.cs`. -import type { CultMeshSessionOpenMessage } from "./contracts"; +import type { CultMeshRouteCertificateMessage, CultMeshSessionOpenMessage } from "./contracts"; export interface CultMeshAuthorityIdentity { verseId: string; @@ -36,6 +36,30 @@ export interface CultMeshAuthorityRouteCertificate { signature: string; } +/** + * Maps an advertised route's wire-shaped certificate to the view + * `verifyAuthorityRoute` and the transports consume. Shared by the QUIC + * realtime session manager (`cultmesh-ts`) and the browser WebSocket client + * (`cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage` + * certificate shape and were computing this mapping independently. + */ +export function routeCertificateView( + certificate: CultMeshRouteCertificateMessage | undefined, +): CultMeshAuthorityRouteCertificate | undefined { + if (!certificate) return undefined; + return { + providerKey: { + keyId: certificate.providerKeyId, + x: certificate.providerPublicKeyX, + y: certificate.providerPublicKeyY, + }, + odinKeyId: certificate.odinKeyId, + issuedAtUnixMilliseconds: certificate.issuedAtUnixMilliseconds, + expiresAtUnixMilliseconds: certificate.expiresAtUnixMilliseconds, + signature: certificate.signature, + }; +} + export type CultMeshAuthorityTrustMode = "authenticated-remote" | "local-development"; export interface CultMeshAuthorityTrustPolicy { diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 073d2587..84bef769 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -73,6 +73,7 @@ export { isNullOrWhiteSpaceCSharp, isProtectedEndpoint, isUnsignedCertificate, + routeCertificateView, trimCSharp, verifyAuthorityRoute, verifyP256, diff --git a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts index 51bea1ff..756ef97d 100644 --- a/packages/cultnet-ts/test/interop/cultnet-interop.test.ts +++ b/packages/cultnet-ts/test/interop/cultnet-interop.test.ts @@ -7,7 +7,7 @@ import { existsSync, rmSync } from "node:fs"; import { connect as connectTcp, createServer } from "node:net"; import { homedir, networkInterfaces, tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; -import type { Readable } from "node:stream"; +import type { Readable, Writable } from "node:stream"; import { test } from "node:test"; import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; @@ -57,6 +57,10 @@ const csharpDllPath = resolve( "net10.0", "GameCult.Networking.InteropPeer.dll", ); +const cultMeshTsRoot = resolve(cultLibRoot, "packages", "cultmesh-ts"); +const cultMeshQuicPeerScript = resolve(cultMeshTsRoot, "dist-test", "test", "interop", "cultmesh-quic-peer.js"); +const quicNativeTestsProject = resolve(cultLibRoot, "tests", "GameCult.Mesh.Quic.Native.Tests", "GameCult.Mesh.Quic.Native.Tests.csproj"); +const quicManagedTestsProject = resolve(cultLibRoot, "tests", "GameCult.Mesh.Quic.Tests", "GameCult.Mesh.Quic.Tests.csproj"); const rustBinaryPath = resolve( cultnetRsRoot, "target", @@ -72,6 +76,7 @@ const processCleanupTimeoutMs = 5_000; let rustInteropPeerBuild: Promise | undefined; let csharpInteropPeerBuild: Promise | undefined; let kotlinInteropPeerBuild: Promise | undefined; +let cultMeshTsInteropPeerBuild: Promise | undefined; function resolvePythonCommand(): string { const codexPythonCommand = join(homedir(), ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "python", "python.exe"); @@ -392,6 +397,320 @@ transport.close() raise SystemExit("timed out waiting for TypeScript RUDP schema-v0 message") `; +test("CultMesh QUIC realtime: C# managed provider and TypeScript consumer", async (t) => { + if (!process.env.CULTMESH_QUIC_NATIVE_DIR) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + logInteropPhase("quic-realtime", "build peers"); + await buildCSharpInteropPeer(); + await buildCultMeshTsQuicPeer(); + + const servers: RunningServeProcess[] = []; + t.after(async () => { + for (const server of servers) { + // The C# quic-realtime-serve peer watches stdin for EOF as its own + // shutdown signal; end it explicitly before killing so a lingering pipe + // never masks whether the process exited on its own. + server.child.stdin?.end(); + server.child.kill(); + } + await delay(processCleanupTimeoutMs); + }); + + logInteropPhase("quic-realtime", "start csharp QUIC provider"); + servers.push(await spawnServeProcess("csharp-quic", { + command: dotnetCommand, + args: [ + csharpDllPath, + "quic-realtime-serve", + "--frames", "5", + "--delivery", "latest-only", + "--interval-ms", "50", + ], + cwd: cultLibRoot, + stdin: "pipe", + })); + const ready = (await servers[servers.length - 1].ready) as { status: string; endpoint: string }; + assert.equal(ready.status, "ready"); + assert.match(ready.endpoint, /^cultmesh-state\+quic:\/\/127\.0\.0\.1:\d+\?cert-sha256=[0-9A-Fa-f]{64}$/); + + logInteropPhase("quic-realtime", "typescript dials the csharp QUIC provider"); + const tsDial = await runJsonCommand("ts-quic-dial", process.execPath, [ + cultMeshQuicPeerScript, + "dial", + "--endpoint", ready.endpoint, + "--expect", "5", + ], cultMeshTsRoot, { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }); + + assert.equal(tsDial.transportId, "msquic-realtime"); + assert.equal(tsDial.frames.length, 5); + for (const [index, frame] of tsDial.frames.entries()) { + assert.equal(frame.delivery, "latest-only"); + assert.equal(frame.bodyId, "interop:quic-realtime:frame"); + assert.equal(Buffer.from(frame.payloadHex, "hex").toString("utf8"), `frame-${index + 1}`); + } +}); + +// --------------------------------------------------------------------------- +// Cut 5: the TypeScript QUIC realtime provider (the StreamPixels role). Four +// lanes, spec section 10. Lane 1 is the headline: it proves the Unity +// overlay's transport, and is gated to Windows because the native connector +// under test (`CultMeshNativeQuicRealtimeTransportConnector`) is. +// --------------------------------------------------------------------------- + +test("CultMesh QUIC realtime: TypeScript provider and C# native connector (the Unity path)", async (t) => { + if (process.platform !== "win32") { + t.skip("The native connector is Windows-only; this lane runs on Windows."); + return; + } + if (!process.env.CULTMESH_QUIC_NATIVE_DIR) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + logInteropPhase("quic-provider-native", "build the typescript quic peer"); + await buildCultMeshTsQuicPeer(); + + const servers: RunningServeProcess[] = []; + t.after(async () => { + for (const server of servers) { + server.child.stdin?.end(); + server.child.kill(); + } + await delay(processCleanupTimeoutMs); + }); + + logInteropPhase("quic-provider-native", "start the typescript QUIC provider"); + servers.push(await spawnServeProcess("ts-quic-provider", { + command: process.execPath, + args: [cultMeshQuicPeerScript, "serve", "--frames", "5", "--delivery", "latest-only"], + cwd: cultMeshTsRoot, + env: { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }, + stdin: "pipe", + })); + const ready = (await servers[servers.length - 1].ready) as { status: string; endpoint: string }; + assert.equal(ready.status, "ready"); + assert.match(ready.endpoint, /^cultmesh-state\+quic:\/\/127\.0\.0\.1:\d+\?cert-sha256=[0-9A-F]{64}$/); + + logInteropPhase("quic-provider-native", "dotnet test: the native connector dials the typescript provider"); + const { stdout } = await runDotnetTestFilter( + quicNativeTestsProject, + "NativeConnectorReceivesFromExternalProvider", + { CULTMESH_NATIVE_EXTERNAL_ENDPOINT: ready.endpoint }, + ); + // Real output observed from a live run (NUnit3TestExecutor via VSTest, not + // Microsoft.Testing.Platform's newer console format): "Test Run + // Successful." plus "Passed: N", never the literal word "Passed!". + assert.match(stdout, /Test Run Successful\./); + assert.match(stdout, /Passed:\s*1/, `expected exactly one passed test.\n${stdout}`); + assert.doesNotMatch(stdout, /Skipped:\s*1/, `the env var should have made the test run, not skip.\n${stdout}`); +}); + +// Cut C headline: a late-joining C# native connector receives the TS +// provider's retained frame. The TS provider's own trigger peer (a `dial` +// subprocess, needed only because the provider waits for a first connection +// before it broadcasts anything) receives every frame and disconnects before +// the native connector dials, so nothing here is a live retransmission: only +// Cut C's retained seed can deliver a frame to the late C# connector. +test("CultMesh QUIC realtime: a late-joining C# native connector receives the retained frame from a TypeScript provider", async (t) => { + if (process.platform !== "win32") { + t.skip("The native connector is Windows-only; this lane runs on Windows."); + return; + } + if (!process.env.CULTMESH_QUIC_NATIVE_DIR) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + logInteropPhase("quic-provider-retention", "build the typescript quic peer"); + await buildCultMeshTsQuicPeer(); + + const servers: RunningServeProcess[] = []; + t.after(async () => { + for (const server of servers) { + server.child.stdin?.end(); + server.child.kill(); + } + await delay(processCleanupTimeoutMs); + }); + + logInteropPhase("quic-provider-retention", "start the typescript QUIC provider"); + servers.push(await spawnServeProcess("ts-quic-provider-retention", { + command: process.execPath, + args: [ + cultMeshQuicPeerScript, + "serve", + "--frames", "3", + "--delivery", "latest-only", + "--interval-ms", "20", + "--linger-ms", "60000", + ], + cwd: cultMeshTsRoot, + env: { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }, + stdin: "pipe", + })); + const ready = (await servers[servers.length - 1].ready) as { status: string; endpoint: string }; + assert.equal(ready.status, "ready"); + + logInteropPhase("quic-provider-retention", "trigger peer receives every frame, then disconnects"); + const trigger = await runJsonCommand("ts-quic-dial-trigger", process.execPath, [ + cultMeshQuicPeerScript, + "dial", + "--endpoint", ready.endpoint, + "--expect", "3", + ], cultMeshTsRoot, { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }); + assert.equal(trigger.frames.length, 3); + + logInteropPhase("quic-provider-retention", "dotnet test: the native connector dials late, after the trigger peer is gone"); + const { stdout } = await runDotnetTestFilter( + quicNativeTestsProject, + "NativeConnectorReceivesTheRetainedFrameWhenJoiningLate", + { + CULTMESH_NATIVE_EXTERNAL_ENDPOINT: ready.endpoint, + CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD: "frame-3", + }, + ); + assert.match(stdout, /Test Run Successful\./); + assert.match(stdout, /Passed:\s*1/, `expected exactly one passed test.\n${stdout}`); + assert.doesNotMatch(stdout, /Skipped:\s*1/, `the env vars should have made the test run, not skip.\n${stdout}`); +}); + +test("CultMesh QUIC realtime: TypeScript provider and C# managed connector, with golden bytes", async (t) => { + if (!process.env.CULTMESH_QUIC_NATIVE_DIR) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + logInteropPhase("quic-provider-managed", "build the typescript quic peer"); + await buildCultMeshTsQuicPeer(); + + const servers: RunningServeProcess[] = []; + t.after(async () => { + for (const server of servers) { + server.child.stdin?.end(); + server.child.kill(); + } + await delay(processCleanupTimeoutMs); + }); + + logInteropPhase("quic-provider-managed", "start the typescript QUIC provider"); + servers.push(await spawnServeProcess("ts-quic-provider-managed", { + command: process.execPath, + args: [cultMeshQuicPeerScript, "serve", "--frames", "5", "--delivery", "latest-only"], + cwd: cultMeshTsRoot, + env: { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }, + stdin: "pipe", + })); + const server = servers[servers.length - 1]; + const ready = (await server.ready) as { status: string; endpoint: string }; + assert.equal(ready.status, "ready"); + + logInteropPhase("quic-provider-managed", "dotnet test: the managed connector dials the typescript provider"); + const { stdout } = await runDotnetTestFilter( + quicManagedTestsProject, + "ManagedConnectorReceivesFromExternalProvider", + { CULTMESH_NATIVE_EXTERNAL_ENDPOINT: ready.endpoint }, + ); + // Real output observed from a live run (NUnit3TestExecutor via VSTest, not + // Microsoft.Testing.Platform's newer console format): "Test Run + // Successful." plus "Passed: N", never the literal word "Passed!". + assert.match(stdout, /Test Run Successful\./); + assert.match(stdout, /Passed:\s*1/, `expected exactly one passed test.\n${stdout}`); + assert.doesNotMatch(stdout, /Skipped:\s*1/, `the env var should have made the test run, not skip.\n${stdout}`); + + // `dotnet test --logger "console;verbosity=detailed"` prints a passed + // test's TestContext.Out lines twice (once live-streamed, once again under + // "Standard Output Messages:"), so matching SEQUENCE and HEX independently + // risks pairing a sequence from one copy with a hex from the other. The C# + // test writes them as one WriteLine call each, back to back; requiring them + // adjacent guarantees whichever pair matches first is self-consistent. + const goldenMatch = /CULTMESH_GOLDEN_SEQUENCE=(\d+)\s*\r?\n\s*CULTMESH_GOLDEN_HEX=([0-9A-Fa-f]+)/.exec(stdout); + assert.ok(goldenMatch, `expected the C# test to print its golden sequence and hex lines together.\n${stdout}`); + const csharpSequence = Number(goldenMatch![1]); + const csharpHex = goldenMatch![2].toLowerCase(); + + // Which frame the managed connector's single ReceiveAsync() call actually + // got is not pinned to frame 1: latest-only coalesces on both the + // provider's outbox and the connector's own receive-side inbox, so a slow + // start can land on a later sequence. Match the typescript peer's logged + // encoding of that same sequence rather than assuming the first one sent. + const tsSentLine = server.stderr + .join("") + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) as { event?: string; sent?: number; hex?: string }; + } catch { + return undefined; + } + }) + .find((entry) => entry?.event === "quic-realtime-serve" && entry.sent === csharpSequence); + assert.ok(tsSentLine?.hex, `expected the typescript peer to log frame ${csharpSequence}'s hex encoding`); + + assert.equal(csharpHex, tsSentLine!.hex, "TypeScript and C# must encode the same frame identically"); +}); + +test("CultMesh QUIC realtime: latest-only under a stalled consumer converges without blocking the provider", async (t) => { + if (!process.env.CULTMESH_QUIC_NATIVE_DIR) { + t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); + return; + } + logInteropPhase("quic-provider-stalled", "build the typescript quic peer"); + await buildCultMeshTsQuicPeer(); + + const servers: RunningServeProcess[] = []; + t.after(async () => { + for (const server of servers) { + server.child.stdin?.end(); + server.child.kill(); + } + await delay(processCleanupTimeoutMs); + }); + + logInteropPhase("quic-provider-stalled", "start the typescript QUIC provider"); + const started = Date.now(); + servers.push(await spawnServeProcess("ts-quic-provider-stalled", { + command: process.execPath, + args: [cultMeshQuicPeerScript, "serve", "--frames", "200", "--delivery", "latest-only", "--interval-ms", "1"], + cwd: cultMeshTsRoot, + env: { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }, + stdin: "pipe", + })); + const ready = (await servers[servers.length - 1].ready) as { status: string; endpoint: string }; + + logInteropPhase("quic-provider-stalled", "dial twice: one healthy, one paused mid-stream"); + const env = { CULTMESH_QUIC_NATIVE_DIR: process.env.CULTMESH_QUIC_NATIVE_DIR ?? "" }; + const [healthy, paused] = await Promise.all([ + runJsonCommand("ts-quic-dial-healthy", process.execPath, [ + cultMeshQuicPeerScript, + "dial", + "--endpoint", ready.endpoint, + "--expect-sequence", "200", + "--timeout-ms", "20000", + ], cultMeshTsRoot, env), + runJsonCommand("ts-quic-dial-paused", process.execPath, [ + cultMeshQuicPeerScript, + "dial", + "--endpoint", ready.endpoint, + "--expect-sequence", "200", + "--pause-after", "5", + "--pause-ms", "500", + "--timeout-ms", "20000", + ], cultMeshTsRoot, env), + ]); + + // The provider's 200 broadcasts (bounded per-frame by --interval-ms 1, so + // ~200ms of pacing) plus the 500ms pause plus process/network overhead + // must still land well inside the bound below regardless of the pause: the + // provider itself was never blocked by the paused consumer. + assert.ok(Date.now() - started < 15_000, "the provider's broadcasts must not be blocked by a stalled peer"); + + assert.equal(healthy.frames.at(-1).sequence, "200"); + assert.equal(paused.frames.at(-1).sequence, "200"); + // Coalescing means the paused consumer saw far fewer than 200 frames. + assert.ok(paused.frames.length < 200, `expected coalescing under the pause, got ${paused.frames.length} frames`); +}); + test("CultNet TS/Rust/C#/Python peers discover each other and exchange raw state over the shared schema-v0 lane", async (t) => { logInteropPhase("schema-v0", "build peers"); await buildInteropPeers(); @@ -2434,6 +2753,49 @@ async function buildCSharpInteropPeer(): Promise { await csharpInteropPeerBuild; } +async function buildCultMeshTsQuicPeer(): Promise { + cultMeshTsInteropPeerBuild ??= (async () => { + const tsc = resolve(cultLibRoot, "node_modules", "typescript", "bin", "tsc"); + await execFileAsync(process.execPath, [tsc, "-p", "tsconfig.json"], { cwd: cultMeshTsRoot }); + await execFileAsync(process.execPath, [tsc, "-p", "tsconfig.test.json"], { cwd: cultMeshTsRoot }); + })(); + await cultMeshTsInteropPeerBuild; +} + +/** + * Runs `dotnet test` against one project, filtered to a single test by name, + * with extra environment (normally `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`). + * `verbosity=detailed` is what makes VSTest's console logger print a passed + * test's own `TestContext.Out` lines (the golden-bytes hex line, section + * 10's lane 4) instead of only failures. + */ +async function runDotnetTestFilter( + projectPath: string, + testName: string, + env: NodeJS.ProcessEnv, +): Promise<{ stdout: string; stderr: string }> { + try { + return await execFileAsync( + dotnetCommand, + [ + "test", + projectPath, + "--filter", + `FullyQualifiedName~${testName}`, + "--logger", + "console;verbosity=detailed", + "-nologo", + ], + { cwd: cultLibRoot, env: { ...process.env, ...env }, timeout: jsonCommandTimeoutMs }, + ); + } catch (error) { + const execError = error as { stdout?: string; stderr?: string; message: string }; + throw new Error( + `dotnet test ${testName} failed.\nstdout:\n${execError.stdout ?? ""}\nstderr:\n${execError.stderr ?? execError.message}`, + ); + } +} + async function buildKotlinInteropPeer(): Promise { kotlinInteropPeerBuild ??= execFileAsync( "powershell", @@ -2479,21 +2841,35 @@ interface ServeCommand { args: string[]; cwd: string; env?: NodeJS.ProcessEnv; + /** + * Defaults to "ignore" (every existing serve mode's stdin is unused, so it + * is closed at spawn). "pipe" keeps stdin open until the caller ends it: the + * C# `quic-realtime-serve` mode watches stdin for EOF as its own shutdown + * signal (`WatchStdinCloseAsync`, `Program.cs:1261-1269`), so spawning it + * with "ignore" hands it an already-closed stdin and it tears down before + * sending anything. Only that mode should ask for "pipe"; changing the + * default would affect every other serve process spawned here. + */ + stdin?: "ignore" | "pipe"; } interface RunningServeProcess { name: string; - child: ChildProcessByStdio; + child: ChildProcessByStdio; ready: Promise; stderr: string[]; } async function spawnServeProcess(name: string, command: ServeCommand): Promise { + // A non-literal first stdio element (it varies per caller) loses `spawn`'s + // typed-tuple overload entirely, including for the two fixed "pipe" + // elements, so this is asserted rather than inferred: stdout/stderr are + // always piped here, and only stdin's presence varies. const child = spawn(command.command, command.args, { cwd: command.cwd, env: { ...process.env, ...command.env }, - stdio: ["ignore", "pipe", "pipe"], - }); + stdio: [command.stdin ?? "ignore", "pipe", "pipe"], + }) as ChildProcessByStdio; const stderr: string[] = []; let stdoutBuffer = ""; diff --git a/scripts/quic-native-linux-dev.Dockerfile b/scripts/quic-native-linux-dev.Dockerfile index 44b7c67e..7f9decdc 100644 --- a/scripts/quic-native-linux-dev.Dockerfile +++ b/scripts/quic-native-linux-dev.Dockerfile @@ -28,11 +28,48 @@ FROM debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9 # ca-certificates and dpkg fetch and unpack the pinned libmsquic deb; # util-linux carries setarch, needed to re-execute the TSan scenarios under # ADDR_NO_RANDOMIZE. The four libraries are MsQuic's own runtime dependencies, -# which the bridge does not vendor. +# which the bridge does not vendor. gnupg is needed only to fetch the NodeSource +# key below. RUN apt-get update \ && apt-get install -y --no-install-recommends \ - bash ca-certificates cmake curl dpkg g++ ninja-build util-linux \ + bash ca-certificates cmake curl dpkg g++ ninja-build util-linux gnupg \ libssl3t64 libnuma1 libxdp1 libnl-route-3-200 \ && rm -rf /var/lib/apt/lists/* +# Node 24 and the .NET 10 SDK: the Cut 4 QUIC realtime plane's own npm tests +# and the C#-to-TS interop lane run in this image too, so it needs to build the +# bridge and then exercise it from both runtimes without a second image. +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ + && bash /tmp/dotnet-install.sh --channel 10.0 --install-dir /usr/share/dotnet \ + && rm /tmp/dotnet-install.sh \ + && ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet +ENV DOTNET_ROOT=/usr/share/dotnet +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 + +# .NET's own System.Net.Quic P/Invokes into "libmsquic" and resolves it +# through the loader's normal search path, not through this repo's +# CULTMESH_QUIC_NATIVE_DIR (that is the Node bridge's own binary directory, +# never the system loader path). Without this, the interop lane's C# peer +# fails QuicListener.IsSupported with PlatformNotSupportedException. `dpkg -i` +# (rather than the build script's `dpkg-deb -x` into a build-local directory) +# installs the same pinned package into /usr/lib/x86_64-linux-gnu and runs +# ldconfig, so it is resolvable process-wide. Same version, same digest as +# scripts/build-quic-native.sh. +RUN curl -fsSL -o /tmp/libmsquic.deb \ + https://packages.microsoft.com/debian/13/prod/pool/main/libm/libmsquic/libmsquic_2.5.9_amd64.deb \ + && echo "1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba /tmp/libmsquic.deb" | sha256sum -c - \ + && dpkg -i /tmp/libmsquic.deb \ + && rm /tmp/libmsquic.deb \ + && ldconfig +# This minimal Debian image carries no ICU package, and .NET's runtime +# FailFasts on startup without one. The interop peer already parses every +# argument through CultureInfo.InvariantCulture, so globalization-invariant +# mode costs it nothing; installing an ICU package would be the alternative, +# but Microsoft's own guidance for a minimal container is this flag. +ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 + WORKDIR /src diff --git a/scripts/test-typescript-package-closure.mjs b/scripts/test-typescript-package-closure.mjs index 5f00aa47..02278760 100644 --- a/scripts/test-typescript-package-closure.mjs +++ b/scripts/test-typescript-package-closure.mjs @@ -39,6 +39,25 @@ try { const files = packed[0].files.map(file => file.path); assert.ok(files.includes("dist/index.js"), `${packageName} tarball is missing dist/index.js`); assert.ok(files.includes("dist/index.d.ts"), `${packageName} tarball is missing dist/index.d.ts`); + if (packageName === "cultmesh-ts") { + // The committed native binaries only reach a consumer if `files` (and + // therefore the packed tarball) actually carries them; a fresh clone, + // an `npm pack` tarball and the deploy `git archive` all depend on it. + // Assert on every host: the tarball carries both platforms, and the + // host only decides which one loads. + const requiredNativeFiles = [ + "native/win32-x64/gamecult_mesh_quic_native.dll", + "native/win32-x64/msquic.dll", + "native/linux-x64/libgamecult_mesh_quic_native.so", + "native/linux-x64/libmsquic.so.2", + "native/SHA256SUMS", + "native/MANIFEST.txt", + "native/MSQUIC-LICENSE.txt", + ]; + for (const requiredNativeFile of requiredNativeFiles) { + assert.ok(files.includes(requiredNativeFile), `cultmesh-ts tarball is missing ${requiredNativeFile}`); + } + } tarballs.push(join(tarballRoot, packed[0].filename)); } diff --git a/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs b/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs index 91af6d7c..7d1c058b 100644 --- a/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs +++ b/src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs @@ -158,6 +158,16 @@ public sealed class CultMeshQuicRealtimeServer : IAsyncDisposable private readonly CancellationTokenSource _shutdown = new(); private readonly ConcurrentDictionary _clients = new(); private readonly CultMeshRealtimeInbox _received = new(); + /// Guards together with client registration, so a newly + /// accepted client's seed and a concurrent broadcast's retained update are totally ordered: + /// a client only ever becomes visible to already carrying the + /// newest retained generation, and can never be seeded with a frame older than one it already + /// received via broadcast. + private readonly object _retainedGate = new(); + /// The newest `latest-only` frame broadcast so far per `(channel, body)` key, seeded + /// into every client accepted after that broadcast. Reliable-ordered frames are events, not + /// state, and are never retained. + private readonly Dictionary _retained = new(StringComparer.Ordinal); private readonly TaskCompletionSource _backgroundFailure = new( TaskCreationOptions.RunContinuationsAsynchronously); private readonly Task _acceptLoop; @@ -222,10 +232,22 @@ public sealed class CultMeshQuicRealtimeServer : IAsyncDisposable if (frame.Delivery == CultMeshRealtimeDelivery.LatestOnly) { - foreach (var client in _clients.Keys) + var key = frame.ChannelId + "\u001f" + frame.BodyId; + var candidate = new CultMeshQuicRealtimeTransport.CultMeshGeneration(frame.ProducerEpoch, frame.Sequence); + lock (_retainedGate) { - if (client.TryPublishLatest(frame)) continue; - if (_clients.TryRemove(client, out _)) client.Dispose(); + if (!_retained.TryGetValue(key, out var current) || + candidate.CompareTo(new CultMeshQuicRealtimeTransport.CultMeshGeneration( + current.ProducerEpoch, current.Sequence)) > 0) + { + _retained[key] = frame; + } + + foreach (var client in _clients.Keys) + { + if (client.TryPublishLatest(frame)) continue; + if (_clients.TryRemove(client, out _)) client.Dispose(); + } } return; } @@ -284,7 +306,11 @@ public sealed class CultMeshQuicRealtimeServer : IAsyncDisposable connection.RemoteEndPoint?.ToString() ?? "quic-peer", connection, frame => _received.Publish(frame)); - _clients.TryAdd(transport, 0); + lock (_retainedGate) + { + _clients.TryAdd(transport, 0); + foreach (var retainedFrame in _retained.Values) transport.TryPublishLatest(retainedFrame); + } _ = transport.Completion.ContinueWith( completed => { @@ -559,7 +585,7 @@ internal sealed class CultMeshQuicRealtimeTransport : ICultMeshRealtimeTransport _received.Publish(frame); } - private readonly record struct CultMeshGeneration(long ProducerEpoch, long Sequence) : IComparable + internal readonly record struct CultMeshGeneration(long ProducerEpoch, long Sequence) : IComparable { public int CompareTo(CultMeshGeneration other) { diff --git a/src/GameCult.Mesh.Quic/README.md b/src/GameCult.Mesh.Quic/README.md index 1aab43c5..afdfd248 100644 --- a/src/GameCult.Mesh.Quic/README.md +++ b/src/GameCult.Mesh.Quic/README.md @@ -66,7 +66,8 @@ var frame = await session.ReceiveAsync(cancellationToken); - `ReliableOrdered` uses one ordered QUIC stream. - `LatestOnly` uses independent streams and keeps at most one pending frame per channel and body. A newer `(producer epoch, sequence)` generation replaces - queued state. + queued state. The provider also retains the newest `LatestOnly` frame per + `(channel, body)` and delivers it to each client as soon as it attaches. - `Unreliable` fails closed in this adapter because `System.Net.Quic` does not expose QUIC datagrams. Use a native MsQuic connector when datagram semantics are required. diff --git a/src/GameCult.Mesh/docs/transport-planes.md b/src/GameCult.Mesh/docs/transport-planes.md index fea2b69b..eddb9794 100644 --- a/src/GameCult.Mesh/docs/transport-planes.md +++ b/src/GameCult.Mesh/docs/transport-planes.md @@ -55,7 +55,11 @@ broadcast enqueues into a keyed, coalescing outbox owned by each physical peer and returns without waiting for network delivery. The peer's transport worker owns stream writes and eviction after disconnect or send failure. Thus a slow, stalled, or departed client cannot backpressure a simulation tick, while a -healthy peer still converges on the newest generation. Reliable-ordered +healthy peer still converges on the newest generation. The provider also +retains the newest latest-only frame per `(channel, body)` and seeds it into +each newly attached client, bounded by the number of distinct keys, so a peer +that connects or reconnects after a broadcast still converges on current +state without a service-side replay loop. Reliable-ordered broadcast remains completion-bearing because dropping or replacing it would violate its declared delivery contract; caller cancellation can stop that operation. diff --git a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs index 7eb99edc..4ab27191 100644 --- a/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs +++ b/tests/GameCult.Mesh.Quic.Native.Tests/CultMeshNativeQuicRealtimeTransportTests.cs @@ -13,11 +13,14 @@ namespace GameCult.Mesh.Quic.Native.Tests; public sealed class CultMeshNativeQuicRealtimeTransportTests { [Test] - public async Task NativeConnectorReceivesFromExternalManagedProvider() + public async Task NativeConnectorReceivesFromExternalProvider() { + // The provider on the other end is no longer necessarily the C# + // managed one (Cut 5 adds a TypeScript provider on this same lane); + // the env var name stays, since it names the endpoint, not the peer. var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT"); if (string.IsNullOrWhiteSpace(endpoint)) - Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise cross-process native/managed parity."); + Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise cross-process native/external-provider parity."); var connector = new CultMeshNativeQuicRealtimeTransportConnector(); using var client = await connector.ConnectAsync( @@ -32,6 +35,36 @@ public sealed class CultMeshNativeQuicRealtimeTransportTests frame.Payload.Should().NotBeEmpty(); } + /// + /// Cut C's headline cross-process proof: the harness lets the external + /// TypeScript provider finish broadcasting and disconnect its trigger peer + /// before this test dials, so nothing is retransmitted live. If a + /// frame arrives at all here, it can only be the provider's retained seed; + /// this test also checks the retained frame's own payload, so a + /// regression that seeds a stale generation (not just no seed) is caught + /// too. + /// + [Test] + public async Task NativeConnectorReceivesTheRetainedFrameWhenJoiningLate() + { + var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise the late-join retention lane."); + var expectedPayload = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD"); + if (string.IsNullOrWhiteSpace(expectedPayload)) + Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_EXPECTED_PAYLOAD to the retained frame's expected payload text."); + + var connector = new CultMeshNativeQuicRealtimeTransportConnector(); + using var client = await connector.ConnectAsync( + new CultMeshTransportCandidate(endpoint!), + new CultMeshSessionTarget("aetheria", "aetheria.local")); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var frame = await client.ReceiveAsync(timeout.Token); + + frame.Delivery.Should().Be(CultMeshRealtimeDelivery.LatestOnly); + System.Text.Encoding.UTF8.GetString(frame.Payload.ToArray()).Should().Be(expectedPayload); + } + [Test] public async Task NativeConnectorAuthenticatesAndReceivesLatestOnlyFrame() { diff --git a/tests/GameCult.Mesh.Quic.Tests/CultMeshQuicRealtimeTransportTests.cs b/tests/GameCult.Mesh.Quic.Tests/CultMeshQuicRealtimeTransportTests.cs index ef739411..5a1d958e 100644 --- a/tests/GameCult.Mesh.Quic.Tests/CultMeshQuicRealtimeTransportTests.cs +++ b/tests/GameCult.Mesh.Quic.Tests/CultMeshQuicRealtimeTransportTests.cs @@ -138,6 +138,94 @@ public sealed class CultMeshQuicRealtimeTransportTests "a late older generation must not twitch the consumer backwards"); } + [Test] + public async Task ASessionAttachingAfterTwoLatestOnlyBroadcastsReceivesExactlyTheNewerFrame() + { + if (!QuicListener.IsSupported) Assert.Ignore("MsQuic is unavailable on this test host."); + using var certificate = CreateCertificate(); + await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions + { + ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0), + ServerCertificate = certificate + }); + + await server.BroadcastAsync(Frame(1, CultMeshRealtimeDelivery.LatestOnly)); + await server.BroadcastAsync(Frame(2, CultMeshRealtimeDelivery.LatestOnly)); + + // Attach only after both broadcasts above have already completed: + // whatever this session receives came from the retained seed on + // attach, not from `BroadcastAsync`'s own fan-out. + var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon"); + var target = new CultMeshSessionTarget("aetheria", runtimeId.Value); + using var sessions = CreateSessions(server, out _); + var session = await sessions.ConnectRealtimeAsync(target); + + var seeded = await session.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + seeded.Sequence.Should().Be(2, "a late joiner must be seeded with the newest retained generation"); + } + + [Test] + public async Task RetentionKeepsTheNewerGenerationWhenAnOlderOneBroadcastsAfterward() + { + if (!QuicListener.IsSupported) Assert.Ignore("MsQuic is unavailable on this test host."); + using var certificate = CreateCertificate(); + await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions + { + ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0), + ServerCertificate = certificate + }); + + await server.BroadcastAsync(Frame(5, CultMeshRealtimeDelivery.LatestOnly, producerEpoch: 9)); + // An older generation broadcast afterward must not overwrite the + // retained frame: a plain last-write-wins assignment would regress this. + await server.BroadcastAsync(Frame(3, CultMeshRealtimeDelivery.LatestOnly, producerEpoch: 7)); + + var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon"); + var target = new CultMeshSessionTarget("aetheria", runtimeId.Value); + using var sessions = CreateSessions(server, out _); + var session = await sessions.ConnectRealtimeAsync(target); + + var seeded = await session.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + seeded.ProducerEpoch.Should().Be(9); + seeded.Sequence.Should().Be(5, "an older generation must not replace the retained newer one"); + } + + [Test] + public async Task AReliableOrderedFrameIsNeverRetainedAndIsNotDeliveredToALateJoiner() + { + if (!QuicListener.IsSupported) Assert.Ignore("MsQuic is unavailable on this test host."); + using var certificate = CreateCertificate(); + await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions + { + ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0), + ServerCertificate = certificate + }); + var runtimeId = CultMeshRuntimeId.Parse("service:aetheria.daemon"); + var target = new CultMeshSessionTarget("aetheria", runtimeId.Value); + + using var firstSessions = CreateSessions(server, out _); + var firstSession = await firstSessions.ConnectRealtimeAsync(target); + await WaitUntilAsync(() => server.ConnectionCount == 1); + await server.BroadcastAsync(Frame(9, CultMeshRealtimeDelivery.ReliableOrdered)); + var atFirst = await firstSession.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5)); + atFirst.Sequence.Should().Be(9); + + // A real latest-only frame follows, so the assertion below proves the + // reliable-ordered frame specifically was never retained, rather than + // merely proving the server had nothing at all to seed. The first + // session stays connected throughout: only the second session's own + // seed is under test here. + await server.BroadcastAsync(Frame(1, CultMeshRealtimeDelivery.LatestOnly)); + using var secondSessions = CreateSessions(server, out _); + var secondSession = await secondSessions.ConnectRealtimeAsync(target); + var seeded = await secondSession.ReceiveAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + seeded.Delivery.Should().Be(CultMeshRealtimeDelivery.LatestOnly); + seeded.Sequence.Should().Be(1, "only the latest-only frame may be seeded, never the reliable-ordered one"); + } + [Test] public async Task UnreliableDeliveryFailsClosedUntilANativeDatagramConnectorExists() { @@ -159,6 +247,56 @@ public sealed class CultMeshQuicRealtimeTransportTests session.State.Status.Should().Be(CultMeshSessionStatus.Offline); } + /// + /// Cut 5's cross-process lane: an external provider (normally the + /// TypeScript `cultmesh-quic-peer.ts serve` peer started by + /// `cultnet-interop.test.ts`) is dialed by the plain managed connector, + /// using only the endpoint's own `cert-sha256` pin — no + /// — + /// exactly as an ordinary consumer would. Prints the received frame's + /// re-encoding as hex so the harness can compare it against the same + /// frame's TypeScript-side encodeRealtimeFrame output (golden + /// bytes across processes, closing "TypeScript encodings decode in C#" + /// by process, not by shared file). + /// + [Test] + public async Task ManagedConnectorReceivesFromExternalProvider() + { + var endpoint = Environment.GetEnvironmentVariable("CULTMESH_NATIVE_EXTERNAL_ENDPOINT"); + if (string.IsNullOrWhiteSpace(endpoint)) + Assert.Ignore("Set CULTMESH_NATIVE_EXTERNAL_ENDPOINT to exercise cross-process managed/external-provider parity."); + + var connector = new CultMeshQuicRealtimeTransportConnector(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + var transport = await connector.ConnectAsync( + new CultMeshTransportCandidate(endpoint!), + new CultMeshSessionTarget("interop", "interop.ts-provider"), + timeout.Token); + try + { + var frame = await transport.ReceiveAsync(timeout.Token); + + transport.TransportId.Should().Be("msquic-realtime"); + frame.SchemaId.Should().NotBeNullOrWhiteSpace(); + frame.BodyId.Should().NotBeNullOrWhiteSpace(); + frame.Payload.Should().NotBeEmpty(); + + // Which frame this actually is is not pinned to the provider's + // first broadcast: latest-only coalesces on both the provider's + // outbox and this connector's own receive-side inbox, so a + // single ReceiveAsync() can land on whichever sequence was + // latest by the time it was called. The harness matches by + // sequence rather than assuming the first frame sent. + var hex = Convert.ToHexString(CultMeshRealtimeWireProtocol.EncodeFrame(frame)); + TestContext.Out.WriteLine($"CULTMESH_GOLDEN_SEQUENCE={frame.Sequence}"); + TestContext.Out.WriteLine($"CULTMESH_GOLDEN_HEX={hex}"); + } + finally + { + transport.Dispose(); + } + } + private static CultMeshSessionManager CreateSessions( CultMeshQuicRealtimeServer server, out IdentityObservation validatedIdentity) diff --git a/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj b/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj index 2a782a72..e55facc5 100644 --- a/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj +++ b/tests/GameCult.Networking.InteropPeer/GameCult.Networking.InteropPeer.csproj @@ -4,10 +4,12 @@ net10.0 enable GameCult.Networking.InteropPeer + $(NoWarn);CA1416 + diff --git a/tests/GameCult.Networking.InteropPeer/Program.cs b/tests/GameCult.Networking.InteropPeer/Program.cs index 3e1de70b..f07908a9 100644 --- a/tests/GameCult.Networking.InteropPeer/Program.cs +++ b/tests/GameCult.Networking.InteropPeer/Program.cs @@ -5,11 +5,15 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Sockets; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using GameCult.Caching; +using GameCult.Mesh; +using GameCult.Mesh.Quic; using GameCult.Networking; using MessagePack; @@ -21,7 +25,7 @@ static async Task ProgramMainAsync(string[] args) { if (args.Length == 0) { - throw new InvalidOperationException("Expected mode: serve | probe | dial | rudp-serve-once | rudp-dial-once | rudp-serve-message-once | rudp-dial-message-once"); + throw new InvalidOperationException("Expected mode: serve | probe | dial | rudp-serve-once | rudp-dial-once | rudp-serve-message-once | rudp-dial-message-once | quic-realtime-serve"); } var mode = args[0]; @@ -49,6 +53,9 @@ static async Task ProgramMainAsync(string[] args) case "rudp-dial-message-once": RudpDialMessageOnce(options); return 0; + case "quic-realtime-serve": + await QuicRealtimeServeAsync(ParseQuicRealtimeServeConfig(options)); + return 0; default: throw new InvalidOperationException($"Unknown mode {mode}"); } @@ -1213,6 +1220,104 @@ static async Task WaitForeverAsync(CancellationToken cancellationToken) } } +static async Task QuicRealtimeServeAsync(QuicRealtimeServeConfig config) +{ + using var certificate = CreateQuicInteropCertificate(); + await using var server = await CultMeshQuicRealtimeServer.ListenAsync(new CultMeshQuicRealtimeServerOptions + { + ListenEndPoint = new IPEndPoint(IPAddress.Loopback, config.Port), + ServerCertificate = certificate + }); + var pin = Convert.ToHexString(SHA256.HashData(certificate.RawData)); + var endpoint = $"cultmesh-state+quic://127.0.0.1:{server.LocalEndPoint.Port}?cert-sha256={pin}"; + WriteJsonLine(new { status = "ready", endpoint }); + + // The harness dials only after reading the line above, so broadcasting + // immediately would send to nobody: MsQuic's own send path silently no-ops + // over an empty peer set. Wait for at least one connection first, bounded, + // so a peer that never shows up still exits instead of hanging forever. + await WaitForFirstConnectionAsync(server, TimeSpan.FromSeconds(30)); + + var delivery = config.Delivery switch + { + "reliable-ordered" => CultMeshRealtimeDelivery.ReliableOrdered, + "latest-only" => CultMeshRealtimeDelivery.LatestOnly, + _ => throw new InvalidOperationException($"Unsupported --delivery '{config.Delivery}'.") + }; + + var stdinClosed = WatchStdinCloseAsync(); + for (var sequence = 1; sequence <= config.Frames && !stdinClosed.IsCompleted; sequence++) + { + var frame = new CultMeshRealtimeFrame + { + ChannelId = "interop.quic-realtime", + SchemaId = "gamecult.interop.quic_realtime_frame.v0", + BodyId = "interop:quic-realtime:frame", + ProducerEpoch = 1, + Sequence = sequence, + Delivery = delivery, + Payload = Encoding.UTF8.GetBytes($"frame-{sequence}") + }; + await server.BroadcastAsync(frame); + WriteLog("quic-realtime-serve", new { sent = sequence }); + if (config.IntervalMs > 0) await Task.Delay(config.IntervalMs); + } +} + +static async Task WaitForFirstConnectionAsync(CultMeshQuicRealtimeServer server, TimeSpan timeout) +{ + var deadline = DateTime.UtcNow + timeout; + while (server.ConnectionCount == 0) + { + if (DateTime.UtcNow >= deadline) + throw new TimeoutException($"quic-realtime-serve: no client connected within {timeout}."); + await Task.Delay(20); + } +} + +static Task WatchStdinCloseAsync() +{ + return Task.Run(async () => + { + while (await Console.In.ReadLineAsync() != null) + { + } + }); +} + +static QuicRealtimeServeConfig ParseQuicRealtimeServeConfig(Dictionary options) +{ + return new QuicRealtimeServeConfig( + ParseOptionalIntArg(options, "port", 0), + ParseOptionalIntArg(options, "frames", 5), + options.TryGetValue("delivery", out var delivery) ? delivery : "latest-only", + ParseOptionalIntArg(options, "interval-ms", 50)); +} + +static X509Certificate2 CreateQuicInteropCertificate() +{ + using var key = RSA.Create(2048); + var request = new CertificateRequest( + "CN=localhost", + key, + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false)); + request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension( + new OidCollection { new("1.3.6.1.5.5.7.3.1") }, + false)); + var names = new SubjectAlternativeNameBuilder(); + names.AddDnsName("localhost"); + names.AddIpAddress(IPAddress.Loopback); + request.CertificateExtensions.Add(names.Build()); + request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); + using var generated = request.CreateSelfSigned( + DateTimeOffset.UtcNow.AddMinutes(-1), + DateTimeOffset.UtcNow.AddHours(1)); + return X509CertificateLoader.LoadPkcs12(generated.Export(X509ContentType.Pfx), null); +} + static Dictionary ParseArgs(string[] args) { var parsed = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -1447,6 +1552,8 @@ sealed record DialConfig( int? TargetRudpPort, string SchemaPath); +sealed record QuicRealtimeServeConfig(int Port, int Frames, string Delivery, int IntervalMs); + static class InteropPeerShared { public const string InteropDocumentType = "cultnet.interop-note";