Merge the TypeScript QUIC realtime line (Cuts 3-6 and retention)

What lands:
- Cut 3: the native MsQuic bridge, with the Windows release leg and F5
  closed on both platforms, and the closerace fixture fix.
- Cut 4: the koffi Node binding, consumer, session manager and trust.
- Cut 5: the TS provider. It reaches the C# native connector, which is
  the Unity overlay's path, on Windows.
- Fix batches 1-4: use-after-free, handler isolation, the
  handshake-timer eviction, the koffi allocation leak, one C#-shaped
  inbox, encode-once, advertisedHost, publish-only
  (acceptClientFrames: false), and maxConnections.
- Retention: the provider seeds late joiners with the newest
  latest-only frame per (channel, body), in TS and C#.
- Cut 6, narrowed: native binaries committed under
  packages/cultmesh-ts/native, built by Actions. A second from-scratch
  build reproduced them byte for byte.

Gates: four Opus Soul passes on the runtime and one Sonnet pass on the
packaging. The last runtime pass found that the connection death under
load was the old handshake eviction, which fix 3a had removed, and it
confirmed StreamPixels' load (60 Hz latest-only for 10 minutes) with
flat memory.

Also carried, per the operator's ruling: CultMath erf/erfinv (Cut 12.0
and its fix batch) from the branch base.

Map conflicts were resolved in favour of main, which owns the maps. The
only branch-side lines main lacked were a Cut 3 "still outstanding"
block that main's closure supersedes.

Open follow-ups, recorded on the map: IPv6-literal endpoints cannot be
dialled; consumer-side native queues are unbounded while Unity is not
polling; a client can see connect() resolve past maxConnections before
the refusal arrives; subtraction (about -130, mostly comments) is
deferred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-23 23:51:19 +02:00
commit d110d9d6ff
45 changed files with 6959 additions and 47 deletions

16
.gitattributes vendored
View file

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

202
.github/workflows/quic-native.yml vendored Normal file
View file

@ -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 <<EOF
# Draft written by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}.
# The operator fills the remaining fields from this run's job logs
# (build-win32-x64 and build-linux-x64 each print their toolchain
# version) before committing packages/cultmesh-ts/native/MANIFEST.txt.
msquic.version=2.5.9
msquic.windows.source=
msquic.linux.source=
bridge.commit=${GITHUB_SHA}
build.workflow_run=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}
build.release=${GITHUB_REF_NAME}
build.win32-x64.host=
build.linux-x64.host=
date=$(date -u +%Y-%m-%dT%H:%M:%SZ)
EOF
# Named after the upload-artifact names above, not after the parked
# scoped per-platform npm package names from the registry-publication
# seam (11.9): these are release archives, not npm packages, and
# 11.7's negative grep for that parked naming must stay clean.
tar -C win32-x64 -czf quic-native-win32-x64.tar.gz .
tar -C linux-x64 -czf quic-native-linux-x64.tar.gz .
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
cd release
gh release create "$GITHUB_REF_NAME" --verify-tag \
--title "$GITHUB_REF_NAME" \
--notes-file MANIFEST.txt \
quic-native-win32-x64.tar.gz \
quic-native-linux-x64.tar.gz \
SHA256SUMS \
MANIFEST.txt

View file

@ -81,7 +81,21 @@ if(CULTMESH_QUIC_BUILD_TESTS)
endif()
set_target_properties(cultmesh_quic_native_tests PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
if(NOT WIN32)
if(WIN32)
# gamecult_mesh_quic_native.dll imports msquic.dll, and the scenario
# runner loads it from its own directory (or PATH) at process start, not
# from MSQUIC_ROOT. build-quic-native.ps1's packaged output gets a copy
# from its own recipe; a test build has no packaging step, so without this
# the loader falls through to whatever msquic.dll happens to already be on
# PATH — silently running the scenarios against an unpinned, wrong-version
# MsQuic instead of failing to start. CMake already knows MSQUIC_ROOT, so
# it places the pinned DLL itself instead of leaving the reader to guess
# why a scenario passed against the wrong binary.
add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
"${MSQUIC_ROOT}/bin/x64/msquic.dll"
"$<TARGET_FILE_DIR:cultmesh_quic_native_tests>/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"
"$<TARGET_FILE_DIR:cultmesh_quic_native_tests>/libmsquic.so.2")
endif()
endif()

View file

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

View file

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

355
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Binary file not shown.

View file

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

View file

@ -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<typeof parseQuicRealtimeEndpoint> {
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<CultMeshQuicRealtimeProvider> {
return CultMeshQuicRealtimeProvider.listen(options);
}
}
function cultMeshSchemaFromDefinition(

View file

@ -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/<platform>` straight from a
// build), otherwise `path.join(__dirname, "..", "native", "<platform>-<arch>")`
// (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<number>;
/**
* 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<typeof buildLibrary>;
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<bigint, NativeEventListener>();
private readonly connectionListeners = new Map<
bigint,
{ listener: NativeEventListener; onFault?: NativeFaultHandler }
>();
private closed = false;
private pumpLoop: Promise<void>;
/**
* `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<CultMeshQuicNativeRuntime> {
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<void> {
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<void> {
while (!this.closed) {
let first: Awaited<ReturnType<CultMeshQuicNativeRuntime["nextEvent"]>>;
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<ReturnType<CultMeshQuicNativeRuntime["nextEvent"]>>;
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;
}
}
}
}

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

Binary file not shown.

View file

@ -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<string, string> {
const args = new Map<string, string>();
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<string, string>, name: string): string {
const value = args.get(name);
if (!value) throw new Error(`Missing required argument --${name}`);
return value;
}
function optionalIntArg(args: Map<string, string>, 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<string, unknown>): void {
process.stderr.write(`${JSON.stringify({ event, ...payload })}\n`);
}
function watchStdinCloseAsync(): Promise<void> {
return new Promise((resolve) => {
process.stdin.resume();
process.stdin.once("end", resolve);
});
}
async function serveAsync(args: Map<string, string>): Promise<void> {
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<void> {
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;
});

File diff suppressed because it is too large Load diff

View file

@ -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/<platform>` 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<T>(collect: (resolve: (value: T) => void) => void, timeoutMs = 5_000): Promise<T> {
return await new Promise<T>((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<bigint>((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<void>((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<Uint8Array>((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<void>((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<bigint>((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<void>((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<bigint>((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<bigint>((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<void>((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<Uint8Array>((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<void>((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.",
);
},
);

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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> = {}): 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<never>(() => {});
},
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<void>((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);
});

View file

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

View file

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

View file

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"module": "Node16",
"moduleResolution": "Node16",
"strict": true,
"declaration": true,
"declarationMap": true,

View file

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

View file

@ -73,6 +73,7 @@ export {
isNullOrWhiteSpaceCSharp,
isProtectedEndpoint,
isUnsignedCertificate,
routeCertificateView,
trimCSharp,
verifyAuthorityRoute,
verifyP256,

View file

@ -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<void> | undefined;
let csharpInteropPeerBuild: Promise<void> | undefined;
let kotlinInteropPeerBuild: Promise<void> | undefined;
let cultMeshTsInteropPeerBuild: Promise<void> | 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<void> {
await csharpInteropPeerBuild;
}
async function buildCultMeshTsQuicPeer(): Promise<void> {
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<void> {
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<null, Readable, Readable>;
child: ChildProcessByStdio<Writable | null, Readable, Readable>;
ready: Promise<unknown>;
stderr: string[];
}
async function spawnServeProcess(name: string, command: ServeCommand): Promise<RunningServeProcess> {
// 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<Writable | null, Readable, Readable>;
const stderr: string[] = [];
let stdoutBuffer = "";

View file

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

View file

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

View file

@ -158,6 +158,16 @@ public sealed class CultMeshQuicRealtimeServer : IAsyncDisposable
private readonly CancellationTokenSource _shutdown = new();
private readonly ConcurrentDictionary<CultMeshQuicRealtimeTransport, byte> _clients = new();
private readonly CultMeshRealtimeInbox _received = new();
/// <summary>Guards <see cref="_retained"/> 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 <see cref="BroadcastAsync"/> already carrying the
/// newest retained generation, and can never be seeded with a frame older than one it already
/// received via broadcast.</summary>
private readonly object _retainedGate = new();
/// <summary>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.</summary>
private readonly Dictionary<string, CultMeshRealtimeFrame> _retained = new(StringComparer.Ordinal);
private readonly TaskCompletionSource<Exception> _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<CultMeshGeneration>
internal readonly record struct CultMeshGeneration(long ProducerEpoch, long Sequence) : IComparable<CultMeshGeneration>
{
public int CompareTo(CultMeshGeneration other)
{

View file

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

View file

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

View file

@ -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();
}
/// <summary>
/// Cut C's headline cross-process proof: the harness lets the external
/// TypeScript provider finish broadcasting and disconnect its trigger peer
/// <i>before</i> 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.
/// </summary>
[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()
{

View file

@ -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);
}
/// <summary>
/// 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
/// <see cref="CultMeshQuicRealtimeConnectorOptions.ValidateProviderCertificate"/> —
/// 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 <c>encodeRealtimeFrame</c> output (golden
/// bytes across processes, closing "TypeScript encodings decode in C#"
/// by process, not by shared file).
/// </summary>
[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)

View file

@ -4,10 +4,12 @@
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<RootNamespace>GameCult.Networking.InteropPeer</RootNamespace>
<NoWarn>$(NoWarn);CA1416</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\GameCult.Networking\GameCult.Networking.csproj" />
<ProjectReference Include="..\..\src\GameCult.Mesh.Quic\GameCult.Mesh.Quic.csproj" />
<PackageReference Include="MessagePack" Version="3.1.7" />
</ItemGroup>
</Project>

View file

@ -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<int> 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<int> 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<string, string> 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<string, string> ParseArgs(string[] args)
{
var parsed = new Dictionary<string, string>(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";