diff --git a/.gitattributes b/.gitattributes index e75b066c..dfe07704 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,31 +1,2 @@ # Auto detect text files and perform LF normalization * text=auto - -# Shell scripts keep LF in the working tree as well as in the index. Left to -# `text=auto`, a Windows clone writes them with carriage returns, and the -# documented Linux path then dies inside the container in under a second: the -# shebang reads as the interpreter `bash\r`, which does not exist. Nothing about -# that failure names line endings, and the same clone builds and tests fine -# everywhere else, so it reads as a broken image rather than a broken checkout. -# -# A clone checked out before this rule keeps its carriage returns, because the -# blob is unchanged and git has no reason to rewrite the file. The one-line -# repair is in native/GameCult.Mesh.Quic.Native/README.md, beside the container -# path it unblocks. -*.sh text eol=lf - -# The committed CultMesh QUIC native binaries (cut 6, packaging) ride the -# deploy `git archive` as raw bytes. `* text=auto` already autodetects them as -# binary by content, but that autodetection is a heuristic; say so explicitly -# so no future text-processing rule (a diff driver, an export filter) can -# apply text conversion and poison the committed digests in SHA256SUMS. -packages/cultmesh-ts/native/**/*.dll binary -packages/cultmesh-ts/native/**/*.so binary -packages/cultmesh-ts/native/**/*.so.* binary - -# The digest list and manifest beside them keep LF on every checkout. Left to -# `text=auto`, a Windows clone writes them with carriage returns, and the -# documented `sha256sum -c SHA256SUMS` then fails on every line (each filename -# ends in `\r`) while CI, checking out on Linux, stays green. -packages/cultmesh-ts/native/SHA256SUMS text eol=lf -packages/cultmesh-ts/native/MANIFEST.txt text eol=lf diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 520a4836..ce361fc8 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -95,46 +95,3 @@ jobs: dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj --filter FullyQualifiedName~Document_OneHundredThousandDistinctLeaseCyclesLeaveNoResourcesOrCallbacks --logger "console;verbosity=minimal" - - # The TypeScript side of the CultMesh authority rules. This job is the - # only one that runs on both Windows and Linux for main, codex/** and - # pull requests: cultnet-interop.yml is windows-latest and main-only, so - # until these steps existed the shared verifier's tests never ran on - # Linux at all, and the browser client's never ran in CI on any OS. - # packages/cultnet-ts verifies both vector files; cultmesh-browser proves - # the browser client reads the shared rules rather than its own. - - name: Verify the CultMesh authority rules with the TypeScript verifier - run: npm run test --workspace packages/cultnet-ts - - - name: Verify the browser client against the shared TypeScript verifier - run: npm run test --workspace packages/cultmesh-browser - - # The realtime frame codec. Its byte parity rests on two vector files, - # contracts/cultmesh/realtime-frame-vectors.json written by the C# - # reference and decoded by this step, and realtime-frame-vectors.ts-written.json - # written by this codec and decoded by the C# step below. Neither half ran - # in CI on any OS until these two steps existed, so a codec that agreed - # only with itself would have gone green. - - name: Verify the realtime frame codec with the TypeScript codec - run: npm run test --workspace packages/cultmesh-ts - - # Both directions of the CultMesh authority vectors: the C#-written - # file (contracts/cultmesh/authority-route-vectors.json) and the - # TypeScript-signed file (authority-route-vectors.ts-signed.json) are - # verified by the C# reference here; the TypeScript side verifies both - # in the step above. CULTMESH_WRITE_VECTORS is never set in CI: the - # committed files are the contract. - - name: Verify the CultMesh authority vectors with the C# reference - run: >- - dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj - --filter FullyQualifiedName~AuthorityProof - --logger "console;verbosity=minimal" - - # The other half of the frame parity: the C# reference decodes and - # re-encodes the vector file the TypeScript codec wrote. A fixture - # authored on one side alone would only agree with itself. - - name: Verify the realtime frame vectors with the C# reference - run: >- - dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj - --filter FullyQualifiedName~RealtimeWireProtocol - --logger "console;verbosity=minimal" diff --git a/.github/workflows/cultnet-interop.yml b/.github/workflows/cultnet-interop.yml index 5f8936bf..6876a76a 100644 --- a/.github/workflows/cultnet-interop.yml +++ b/.github/workflows/cultnet-interop.yml @@ -65,32 +65,6 @@ jobs: "KOTLIN_JAVA_HOME=$env:JAVA_HOME" | Out-File -FilePath $env:GITHUB_ENV -Append "$kotlinHome\bin" | Out-File -FilePath $env:GITHUB_PATH -Append - - name: Verify CultNet schema contracts stayed consolidated - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $stray = git ls-files '*.schema.json' | Where-Object { $_ -notmatch '^contracts/cultnet/' } - # -I skips binary files: unity/org.gamecult.cultlib/Runtime/Plugins/GameCult.Networking.dll - # still embeds the retired namespace until it is rebuilt (REC-032 D4, deferred). - $oldNamespace = git grep -I -lE 'GameCult/cultnet-ts/(integration/)?contracts' -- ':!.voidbot' - if ($LASTEXITCODE -eq 2) { - throw "git grep failed" - } - if ($stray) { - Write-Host "Schema files found outside contracts/cultnet/:" - $stray | ForEach-Object { Write-Host " $_" } - } - if ($oldNamespace) { - Write-Host "Files still referencing the retired GameCult/cultnet-ts contracts namespace:" - $oldNamespace | ForEach-Object { Write-Host " $_" } - } - if ($stray -or $oldNamespace) { - throw "CultNet schema contracts drifted out of contracts/cultnet/ (REC-032). See CultLib/contracts/cultnet/ and rewrite any `$id back to https://github.com/GameCult/CultLib/contracts/cultnet/." - } - # git grep exits 1 when nothing matches, and the runner's pwsh wrapper - # ends the step with that $LASTEXITCODE; a clean tree must pass. - exit 0 - - name: Install Node dependencies run: npm install --no-package-lock diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 60cc6a01..6061ca7a 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -42,12 +42,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - # fetch-depth: 0 so the semver check below can see every previously - # published tag, not just the one that triggered this run. - name: Check out CultLib uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Set up Node uses: actions/setup-node@v4 @@ -68,19 +64,6 @@ jobs: exit 1 fi - # docs/semver-policy.md: a breaking change under too small a bump, or a - # version that skips or reverses the previous release, fails here - # instead of shipping with a number that lies. - - name: Verify semver policy - if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') - run: | - tagged="${GITHUB_REF_NAME#cultcache-ts-v}" - node scripts/check-changelog-semver.mjs \ - --package "@gamecult/cultcache-ts" \ - --changelog packages/cultcache-ts/CHANGELOG.md \ - --version "$tagged" \ - --tag-prefix cultcache-ts - - name: Build and test working-directory: packages/cultcache-ts run: npm test @@ -122,13 +105,9 @@ jobs: echo "run=false" >> "$GITHUB_OUTPUT" fi - # fetch-depth: 0 so the semver check below can see every previously - # published tag, not just the one that triggered this run. - name: Check out CultLib if: steps.selected.outputs.run == 'true' uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Set up Python if: steps.selected.outputs.run == 'true' @@ -136,12 +115,6 @@ jobs: with: python-version: "3.12" - - name: Set up Node - if: steps.selected.outputs.run == 'true' - uses: actions/setup-node@v4 - with: - node-version: 24.x - - name: Verify the tag matches the manifest version if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') run: | @@ -152,19 +125,6 @@ jobs: exit 1 fi - # docs/semver-policy.md: a breaking change under too small a bump, or a - # version that skips or reverses the previous release, fails here - # instead of shipping with a number that lies. - - name: Verify semver policy - if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') - run: | - tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}" - node scripts/check-changelog-semver.mjs \ - --package "${{ matrix.package }}" \ - --changelog packages/${{ matrix.package }}/CHANGELOG.md \ - --version "$tagged" \ - --tag-prefix "${{ matrix.package }}" - # Lower layers install from source so a release of one package is tested # against the sibling revision it was cut with, not whatever PyPI has. - name: Install and test diff --git a/.github/workflows/quic-native.yml b/.github/workflows/quic-native.yml deleted file mode 100644 index 9b16cbeb..00000000 --- a/.github/workflows/quic-native.yml +++ /dev/null @@ -1,202 +0,0 @@ -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 < 0. - `TryReadSchemaVersion` goes back to internal, behind a registry method that - takes the descriptor. `mutate-dotnet.ps1` normalises path separators. -- **R-M. Soul's named deletions all land.** P-2 (`Eq`) lands too. - -**The C# half of the fix batch landed** (Sonnet), `7903853`..`3d32c67`. - -What changed: -- **R-A:** v1 is served by both servers, and D6 reconcile routing is in place. -- **R-C:** `CultNetCodePointComparer` gives code-point order. -- **R-D:** floats render as their exact decimal expansion, computed with - `BigInteger`. -- **R-E:** cites go through the alias matcher, and the wire id is one - `WireSchemaId`. -- **R-F:** the door is unconditional inside `Select`. -- **R-G:** `Matched` is the total, computed once through `EvaluateAll` and - `SelectAll`. -- **R-H:** the cursor digest is length-prefixed. -- **R-J:** numeric `any_of` compares the canonical form. -- **R-M:** `WithBindingSchemaAlias` and the duplicated paging loop are deleted. -- **R-I:** tests kill both S20 mutants. One real bug was fixed in the - process: `ToEdge` serialised a plain-typed payload as a document. -- **R-K:** every join is in `finally`. `HasConsumers` now waits for the - condition. -- **R-L:** the schema carries `minItems`. Python refuses every vN>0 (only - one site exists). The harness normalises path separators. -- **Vectors:** `cs-written.json` is regenerated and gains the float-tie, - cites-by-alias, astral-order and projection cases. - -Tests: Networking 201/202, where the one failure is the stale Rust vector -until the Rust half lands. Caching 192. Mesh 253/254. Python 54, 78 and 34. In -20 full runs there was no crash, but one run had an unexplained failure. - -**Not done, sent to a second Hands:** mutation entries for the fix-batch -rules (there are only regression tests), S9, and a diagnosis of the 1-in-20 -failure over 30 runs. - -**The Rust half of the fix batch landed** (Sonnet), `8b114ed`..`a088435`: -- 36/36 entries killed. -- Three real bugs were found while it ran, among them a v0 fast path that - had inherited the door's "empty matches all". -- The `contracts.rs` hand codecs are gone (−362/+35). -- The Rust Hands corrected `f32` 3e20 to `300000006012263202816`. - -**Shared parity fixture** (Sonnet), `3317ddd` and `defb9a1`. Both runtimes now -build their vectors from -`contracts/cultnet/interop/selection-vectors.fixture.json`, which uses real -SHA-256 ids. A test bug is fixed: both readers had compared the page size with -`matched`. Of 15 vectors in each direction, the following agree byte for byte: -exact ids, float tie, astral order, header and document projection, -`cites`/`cited`, and the refusals. **Two real parity defects:** -- **Two incompatible alias rules.** C#'s `CultNetSchemaAliasMatching.Matches` - (`CultNetDatabase.cs:74-87`) strips `.vN` and compares against the - descriptor's `SchemaName`. Rust's `schema_alias::matches` - (`selection.rs:452-459`) strips `.vN` and compares against the *hash*. No - alias resolves in both. -- **C# does not refuse an unresolved `cites.target`** (`CultNetSelection.cs:361-391`), - which R-E requires. Rust does refuse it. - -**Self's ruling, 2026-09-22: the C# reference's alias rule is the rule.** -CultLib keeps wire parity against the C# reference. An alias is a schema -*name* with a `.vN` suffix, resolved against the descriptor's name. A -hash-shaped alias is not a wire form. Rust ports the name rule, which means -its `RowSet` must expose each schema's name, and it deletes the hash-stripping -matcher. C# refuses an unresolved `cites.target` at the door. Each vector that -fails today becomes a vector both runtimes must pass. - -**The Rust alias port landed** (Sonnet). Rust's `schema_alias::matches` is -now `(candidate, schema_id, schema_name)`, and it matches the C# reference row -for row: exact id, bare name, `.vN` on the name, case-sensitive, the last -`.v` marker wins, and `.v` with no digits is taken literally. The C# table was -produced by running the reference source. The hash-stripping overload is -deleted. `SchemaVersion` and `CompatibleSchemaIds` are not ported, because no -Rust call site needs them; that is a stated reduction. Rust entries are 40/40 -killed. The whole suite had never run under `-Repo` before, and its commands -now carry `--manifest-path`. - -| Reader | Vectors from | Pass | -|---|---|---| -| Rust | `cs-written` | 15/15 | -| C# | `rs-written` | 14/16 | - -The two C# failures both come from the pending C# door refusal of an -unmatched `cites.target`, which sits with the matrix Hands. - -**The early Soul pass (commits 0–1, pinned at `17d10e0`) reported late, on -2026-09-22.** It was interrupted three times by API errors. Much of what it -found is already covered by R-A to R-M. These findings are **new, and still -open against the current branch:** - -- **F2, high: typed refusals never reach the wire.** `CultNetErrorMessage` - (`CultNetSchemaMessages.cs:~498-512`) has no `code` and no `details`. Nothing - maps the selection or cursor exceptions to an error. This means - `selection_invalid`, `cursor_stale`, `cursor_invalid` and - `reference_outside_target` exist only in-process. -- **F6, high: the cursor can be forged.** The digest is an unkeyed SHA-256 - that anyone can recompute, so any caller can mint a cursor. A record key - containing the U+241F delimiter makes a server's own cursor fail to parse. -- **F7, high: authorization sees two different ids.** On the snapshot path, - the authorizer receives the binding's wire id. On the live path it receives - the descriptor id (`CultNetDatabaseSubscriptionServer.cs:~309` against - `:~451`). A row delivered by the snapshot can therefore be refused on its - first live change, or leak through a denylist. No test pins the live id. -- **F8, medium: `LastWriteSequence` is not the order a snapshot reflects.** - - Replica apply paths (`CultNetDatabase.cs:~1416`, `:~1459`) never set it. - - Nothing rebuilds it after a restart. - - Sequences are per shard, but `asOf` is a single `ulong`. -- **F11, medium: v0 lowering changed what v0 answers.** A snapshot with - `recordKeys: []` used to answer empty, and now answers every row. v0 rows - now come back in schema-and-key order instead of the requested key order. -- **F12, medium: the committed schemas do not describe the bytes C# writes.** - - Headers `$ref` a record schema that requires `payload`. - - MessagePack-CSharp writes both `headers` and `documents`, one of them - nil, so every C# page fails the `oneOf`. - - `fieldPredicate` does not forbid `values` and `number` together. -- **F14, low, now reachable because v1 is served: field predicates on a - Removed change throw.** `TryGetIndexValue(null)`, at - `CultNetDatabaseServer.cs:~375`. -- **F15, low: `cites` skips the out-of-target check** for edges that do not - point at the requested key. `EnsureWithinDeclaredTarget` also returns early - when `TargetType` is null. -- **Surviving mutants:** - - S1-Loose (reachable becomes all descriptors); - - S2-AllOf; - - S17-FirstLeaf; - - Door-NoValidate, since fixed by R-F; - - S5-DigestNoFields; - - Auth-LiveSchemaId, which is F7; - - QL-OutOfTargetCites, which is F15. - - S4 has no entry. A clone at `C:\ss1` was left behind: the Soul pass could - not remove it. - -**Self's rulings for fix batch 3, 2026-09-22.** Batch 3 goes to Hands once the -C# matrix Hands and the Rust alias port have landed, so that no two C# Hands -edit `src/` at the same time. - -- **R-N, F2: refusals are wire messages.** `cultnet.error.v0` gains `code` and - `details`. That is an additive field, so the version is unchanged, and v0 - peers ignore it. Every selection and cursor refusal, and - `reference_outside_target`, maps to its code in both servers and in Rust. - Tests decode the bytes a peer actually receives. -- **R-O, F6: cursors are keyed.** The digest becomes an HMAC under a key the - answering server holds. The key is random per process, so a cursor does not - survive a restart and answers `cursor_invalid`. That is acceptable, because - cursors are short-lived and §2 already calls them "minted by the answering - server". The cursor body uses length-prefixed fields, not a delimiter. Rust - does the same, since Huginn mints cursors. -- **R-P, F7: one id reaches the authorizer, and it is the wire id**, on the - snapshot, live and reconcile paths alike. A test pins the id each path - passes. -- **R-Q, F8:** every path that commits a row, whether local or a replica - apply, sets the last-write sequence. It is rebuilt from the mutation log at - startup. A multi-shard selection's `asOf` is defined, or refused: a - selection that spans shards is answered only when every row comes from one - shard's log. Otherwise it is refused with `cursor_stale`/`selection_invalid` - as appropriate, and the choice is documented in §2. A single shard is the - case today. -- **R-R, F11: v0 lowering preserves v0's pre-cut answers exactly.** v0 is a - frozen contract for the TS, Python and Kotlin peers. **This corrects the - earlier ruling in the commit 2 fix batch**, which lowered an empty v0 list - to "no filter". Where the pre-cut v0 server answered empty for - `recordKeys: []`, it still answers empty. Where pre-cut v0 kept the - requested key order, it still does. The pre-cut behaviour comes from the - code at `b3d9cf7`, and tests prove it by running the same requests against - both. -- **R-S, F12: the schemas describe the bytes.** Add a header schema, make the - page shape match what MessagePack-CSharp writes (nil keys included), and - make `fieldPredicate` exclusive. A test decodes real C# bytes against the - schemas. -- **R-T, F14 and F15:** removed changes are evaluated without reading a - document. A removal matches through the selection's keys and schemas - alone, and field predicates count as unknown. `cites` checks every edge - against its declared target. -- **R-U: every surviving mutant from the early pass becomes an entry, and it - must die.** That covers S1-Loose, S2-AllOf, S17-FirstLeaf, - S5-DigestNoFields, Auth-LiveSchemaId, QL-OutOfTargetCites and an S4 entry. - -**Fix batch 3 is landing in pieces, 2026-09-22.** - -- **The harness is retired** (operator ruling). The branch's `mutate-dotnet.ps1`, - the four `*.entries.ps1` and the Rust entries file are deleted. Tests pin - behaviour, and Soul runs Stryker.NET and cargo-mutants on the diff. -- **C#, first pass** (`8d7bee9..f366791`): R-R (v0 lowering answers exactly - what pre-cut v0 answered, key order included) and the cites-refusal vectors. - C# now reads 16 of 16 Rust vectors. It stopped there, so R-N, R-O, R-P, R-Q, - R-S, R-T and R-U were left. - - **R-R meets R-F:** the raw v0 wire answers an explicit empty list with an - empty page, while Mesh's `EnsureV0Compatible` refuses that list before - sending. Both layers keep their own ruling, which is right. -- **Rust** (`5cf31d1..c606eb3`): R-O (keyed cursor), R-T, R-R, and the entries - file deleted. - - **The cursor API for Huginn:** `Cursor::parse` stays public and key-free - and still exposes `as_of`. Only digest verification needs the process key, - so no second helper was needed. - - Rust reads 17 of 17 C# vectors. - - **R-N is pending in Rust by design.** An earlier guess at the wire shape - was backed out; Rust matches C#'s field names byte for byte once they land. -- **C# pass A is in Hands:** R-N, R-P, R-T. **Pass B follows:** R-O, R-Q, R-S, - R-U. -- **C# pass A landed** (`66b5e60`, `e8a3dec`): R-N, R-P and R-T. - - **R-N's wire shape:** `CultNetErrorMessage` gains `code` and `details`. - `CultNetErrorDetails` carries `field`, `value`, `asOf` and `current`. The - codes are `selection_invalid`, `cursor_stale`, `cursor_invalid` and - `reference_outside_target`. The schema gains both fields additively. - - Two catch sites that previously let the exception escape now answer the - peer. - - **R-P:** the live fast path authorized on the descriptor id and now uses - the wire id, so all three paths agree. - - **R-T:** a removal skips field predicates instead of dereferencing a null - document. `cites` no longer early-returns on a null target, and **the root - cause was in CultCache**: a many-reference declared without an explicit - attribute left its target type null, even though the element type was - statically known, so the out-of-target check silently skipped. - - Networking 223 pass and 2 skip; Mesh 255. - - **The Caching claim was wrong.** There is one failure, not three, and it - is identical at the branch head, at `b3d9cf7` and on `main`. It expects a - write to a read-only directory to fail, which cannot happen in a container - running as root. **Environmental.** -- **Rust R-N landed** (`eff62e5`): 213 tests pass. Parity is pinned by - **captured C# bytes** for all four codes, taken from a probe run on the - dotnet image. - - **An encoding trap the vectors would not have caught:** MessagePack C# - always writes all five declared keys in declaration order, nil included, - while Rust's generic path sorts keys alphabetically. Rust now builds that - map by hand. **A key added or reordered in either error type must be - mirrored there.** - - **A defect in C#, sent to pass B:** `ForReferenceOutsideTarget` prefixes a - message that already carries its own prefix, so a peer reads - `reference_outside_target: reference_outside_target: …`. - - The parity-vector machinery carries no concept of an error message, so - R-N's parity lives in its own byte-level test. That is the right home. -- **C# pass B landed** (`a4cf54f`..`52082ec`): R-O, R-Q, R-S and R-U, plus the - doubled `reference_outside_target` prefix. - - Networking 238 pass and 2 skip; Mesh 255. - - **Hand probes: six of seven killed.** S1-Loose, S2-AllOf, S17, S5-DigestNoFields, - Auth-LiveSchemaId and S4 all die. - - **QL-OutOfTargetCites is a genuine gap.** Since pass A fixed - `CultCache.ResolveReferenceTarget`, the fixture's many-reference gets an - inferred target type, so the guard never fires for it. `TargetType` is now - null only for a scalar reference typed as the bare `ICultRecordRef`, and - no fixture reaches that shape. **Open for Soul:** is that shape - registerable at all? If it is, pin it. If it is not, the null branch is - dead and goes. - - **Two fixes found while writing the tests:** - - the cursor's length prefix counted UTF-16 chars, so an astral key did - not round-trip; - - `MiniJsonSchemaValidator` had no `not`, so the exclusivity test found - nothing. - - **`cultnet.raw-document-record.schema.json` was missing `schemaName`, - `schemaVersion` and `schemaContentHash` entirely.** Any real C# page would - have failed it on `additionalProperties: false` before R-S's own bugs even - mattered. - - **Self's correction: cursors need no cross-runtime byte parity.** The - brief for pass B said Rust and C# must encode a cursor identically, on the - premise that one runtime parses another's cursor. **That premise is - wrong.** A cursor is opaque, minted by the answering server and echoed - back by the caller, and the digests cannot verify across processes because - each server holds its own key. Both encodings are length-prefixed - five-field bodies with an HMAC-SHA256 digest, and they differ in base64 - flavour and separators. That is correct and stays. Rust's own doc said so - first. - - **Settled by Soul:** three Caching failures, identical at `b3d9cf7` and - `52082ec`. All three expect a write to a read-only directory to fail, and - the container is root. **Environmental.** Pass B's count was right. - -**Soul's whole-cut pass, 2026-09-22** (Opus, at `52082ec`). **Cut 1 may not -merge.** - -- **Held:** Networking 238 and 2 skips, Mesh 255, cultnet-rs 213. R-Q holds - (both replica applies, the startup rebuild, and a shard-spanning refusal). - R-O holds against a stranger and across a restart, and the astral key - round-trips. R-H's digest covers every selection term. R-R holds and is - honest. R-A holds. R-F's door is inside `EvaluateAll`, which every path - reaches. The canonical-number door is anchored `\A..\z`. -- **S-1, high: the hop indexes rows by record key alone.** `byKey` - (`CultNetSelectionEvaluator.cs:176-178`) and Rust's `HashMap<&str, &R>` - (`selection.rs:1457`) both keep the last row at a key. CultCache keys are - unique **per schema**, and the Mesh tests already put two schemas at one - key. With rows in one order the selection refuses valid data as - `reference_outside_target`; in the other order it succeeds. Under `cited` - the index is a set of bare keys, so a row of one schema counts as cited - because something cites another schema at the same key. -- **S-2, high: R-T's `cites` half did not land in C#.** C# filters edges by - the citation's key (`:355`) **before** `EnsureWithinDeclaredTarget` (`:359`); - Rust checks first. A many-reference declared to one leaf but holding an edge - into another is refused by Rust and accepted by C#. -- **S-3, high: a selection with both hops loses its `cites` edges in Rust.** - `selection.rs:1541` computes one direction for every edge; C# anchors each - edge by its own hop. No vector covers both hops. -- **S-4, high: the error message fails its own schema.** Nothing decodes real - `CultNetErrorMessage` bytes against `cultnet.error.schema.json`. - `routingHint` is undeclared under `additionalProperties: false`, and nil - `details.asOf`, `details.current`, `code` and `details` all fail their - declared types. The header schema shows the nullable pattern was understood - and simply not applied here. -- **S-5, medium: Stryker leaves 85 survivors** over the cut's two core files: - 396 mutants, 310 killed, 85 survived, **75 with no coverage at all**, score - 66%. The old "every entry killed" was an artifact of a hand-picked list. -- **S-6, medium: the cursor is malleable in its own position.** The digest - covers the selection but not the body's `asOf`, `ordinal`, `schemaId` or - `recordKey`. A caller holding one valid cursor can rewrite its position and - reuse the digest. -- **S-7, medium: R-I's claim is false.** The vectors compare ids, `matched`, - `hasNext` and edges. They never compare page bytes, `asOf`, `next`, or a - refusal's code, and `rs-written.json` carries no projection vector at all. -- **S-8, low: the out-of-target null branch is dead.** D11 refuses a bare - `ICultRecordRef` in both scalar and many form, so every surviving shape has - a non-null target. -- **S-9, low: `asOf` is the maximum across all shards**, so a single-shard - page advertises another shard's number, and an unrelated write invalidates - a live cursor. -- **S-10, low: `MiniJsonSchemaValidator` silently ignores** `minimum`, - `maximum`, `maxLength`, `maxItems`, `anyOf`, `allOf`, `if/then/else`, - `uniqueItems` and `format`. The committed schemas use `minimum` 34 times, - including `limit`'s bound. Its `pattern` also runs through .NET `Regex`, - whose `$` accepts a trailing newline. -- **S-11, low:** the v1 subscribe refuses a hop before running the door and - always names field `cites`. -- **S-12, low:** the fixture's prose still calls the alias vectors a - cross-runtime defect, which the alias port fixed. Rust's port also drops - `SchemaVersion` and `CompatibleSchemaIds`, so R-E is two rules agreeing on a - subset. -- **Where the evidence lives.** The pass ran against `52082ec`. Its nine hand - probes — eight C# and the Rust both-hops probe that proved S-3 on a matched - pair — are on branch `soul-probe` in the scratch clone `C:\ss5` - (`e0fd672..107ff5f`), **unpushed**. Nothing reached origin and the pinned - checkout at `F:\Projects\CultLib` was never touched. Notes are at - `scratchpad/soul-sel-final-notes.md`. Probes are scratch by doctrine and are - meant to be thrown away; this pointer exists because S-1 through S-3 were - proven there and fix batch 4's tests have to reproduce them independently. If - `C:\ss5` is gone, the probes are gone with it, and that is acceptable — the - findings are recorded here, which is the durable copy. -- **Soul's own corrections, same pass.** It first reported a wedged - `cargo mutants` job on Yggdrasil and asked for it to be killed. Checking the - box rather than its log, the work directory was already gone: what it had - lost was its detached ssh's output. Self had by then killed the process, - which was Soul's own run, so nothing else was affected. **The shape is worth - keeping: a log is not the machine, and Self acted before confirming the - process was actually stalled.** -- **The Stryker triage is owed, after four attempts, none of them Stryker's - fault.** The `dotnet/sdk:10.0` image has no `python3`; the JSON reporter threw - on `CreateDirectory`; two runs with a different output path ran about fifty - minutes without finishing, against three minutes for the mutation phase that - did complete. The completed run stands, and the command is in Soul's notes: - `dotnet-stryker --project GameCult.Networking.csproj --mutate "**/CultNetSelection*.cs" --reporter json --reporter progress --concurrency 4`. - **Read the HTML report Stryker writes beside the JSON**, rather than parsing - JSON in the container. -- **The triage landed on the fifth attempt, 2026-09-22, and it sharpens S-5 - rather than softening it.** Most of the 85 are message-string mutations and - are not worth chasing; roughly a dozen are real, and they sit on the rules - this cut exists to enforce: - - `CultNetSelection.cs:439`, `Any()` → `All()` survived. §2's "a comparison - on an alias any reachable schema declares non-numeric is refused" degrades - to "refused only if every reachable schema declares it non-numeric". No - fixture has an alias numeric on one reachable leaf and a string on - another — the exact case the rule exists for. - - `CultNetSelection.cs:387`, `||` → `&&` survived. A `cites.target` with a - schemaId and a blank recordKey, or the reverse, stops being refused. - - `member.IndexAlias ?? member.MemberName` → `member.MemberName` survived at - all four sites (`CultNetSelectionEvaluator.cs:352,384,404` and - `CultNetSelection.cs:445`). §2's role-naming rule for the hop is untested - everywhere it is implemented. - - `CultNetSelectionEvaluator.cs:203,204`, `ThenBy` ↔ `ThenByDescending` - survived on both paths: the `(ordinal, schemaId, recordKey)` tiebreak is - pinned by nothing. - - `CultNetSelectionEvaluator.cs:290-297`, five order flips in `EdgesFor`, all - survived. **R-B's "page-row order, then `(from, role, to)` in code-point - order" has no test pinning a single component**, though R-B exists because - that ordering was wrong once already. - - `CultNetSelectionEvaluator.cs:590-605` has no coverage at all: - `ComputeDigest`'s `cites` and `cited` branches, plus the number-presence - flag at `:584-585`. R-H's collision-freedom rests on a reading, not a test. - - `:673,678` survived: `Cursor.Parse`'s length-prefix bounds checks, on - attacker-supplied base64. `:307,322` survived: cursor positioning under - `descending`, and `ComparePosition`'s schema-vs-key tiebreak. `:710,715,729` - survived: the code-point comparator's surrogate path, despite R-C having a - vector for it. - - **Equivalent, not to be chased:** `Selection.cs:103` `<=`, `:118` `>=`, - `:121` `dot <= 0` are guarded upstream and unreachable for canonical input; - `:103,107` turn `sign * x` into `sign / x` with `x` in {-1,1}; `:109` - Max→Min is a no-op because `PadRight` to a shorter length does nothing; - `:80` is a `RegexOptions` flag; `Evaluator:272` is equivalent because - `full.Edges` is empty without a hop. -- **Soul withdrew a finding of its own in the same pass.** It had reported that - C#'s cursor digest does not sort its lists while Rust's does, offered as a - low-severity intra-runtime difference. `CultNetSelectionEvaluator.cs:637` - sorts by code point, same as Rust. The remark belongs in no fix list. -- **cargo-mutants cannot run on `cultnet-rs` at all.** `schema_discovery.rs` - uses `include_str!("../../../contracts/…")`, which escapes the package, so - the tool's copied tree fails its baseline build. **There is no mutation - evidence for the Rust half**, and the package's own source shape is why. - -**Self's rulings for fix batch 4, 2026-09-22:** - -- **R-V (S-1). A row's identity is `(schemaId, recordKey)`, never the key - alone.** Both runtimes index rows and incoming edges by the pair. `RecordRef` - already carries both. Tests: two schemas at one key, in both row orders, - give the same answer; `cited` does not match a row of another schema at the - same key. -- **R-W (S-2 and S-3). The hop behaves identically in both runtimes.** The - target check runs **before** any key filtering, and each edge is anchored by - its own hop, so a selection carrying both `cites` and `cited` returns both - sets. C# adopts Rust's ordering for the first; Rust adopts C#'s per-edge - anchoring for the second. Vectors cover both. -- **R-X (S-4 and S-10). A message that fails its own schema is a defect.** - `cultnet.error.schema.json` declares `routingHint` and every nullable field - the way the header schema does. A test decodes real bytes for every code. - `MiniJsonSchemaValidator` **refuses a schema containing a keyword it does - not implement**, rather than ignoring it, and implements `minimum`, - `maximum`, `maxLength` and `maxItems`. Its `pattern` anchors so a trailing - newline cannot pass. -- **R-Y (S-6).** The cursor's HMAC covers the body as well as the selection, - so the position cannot be rewritten. Test: a rewritten ordinal or key is - refused. -- **R-Z (S-7).** The vectors compare **page bytes**, plus `asOf`, `next` and a - refusal's code, and carry a header-projection and a document-projection - vector on both sides. Where a field is legitimately per-server, such as a - cursor, the vector states that and compares the rest. -- **R-AA (S-5).** Triage all 85 survivors by name. The 75 uncovered mutants - mean whole regions have no test; write behavioural tests for them. A - survivor that is genuinely equivalent gets a one-line reason in the report. - **The triage half is done** (see the list under S-5, 2026-09-22). What is - owed is a behavioural test for each named live survivor: the alias - disagreement across reachable schemas, the half-blank `cites.target`, the - four index-alias role sites, the row tiebreak, `EdgesFor`'s ordering, - `ComputeDigest`'s hop branches, `Cursor.Parse`'s bounds, descending cursor - positioning, and the surrogate comparator. The equivalent mutants are - recorded and are not to be chased. -- **R-AB. Make `cultnet-rs` self-contained enough to mutate.** Replace the - escaping `include_str!` with a build script that stages the contracts into - `OUT_DIR`, or another route that keeps every source path inside the package. - Prove it by running cargo-mutants on the crate. -- **S-8:** delete the dead branch. -- **S-9:** `asOf` is the watermark of the shards the selection actually - covers. -- **S-11:** run the door first, and name the field that is actually set. -- **S-12:** correct the fixture's prose, and state Rust's alias reduction - where R-E is described. - -**Ledger correction.** Commits 0 and 1 came in at about twice the §14 -estimate: - -| Package | Actual (net) | §14 estimate | -|---|---|---| -| Caching | +297 | about +150 | -| Networking | +851 | about +400 | -| contracts | 163 | about 200 | - -The two new Networking files alone are 714 lines, against about 360. The hop's -incoming index, the cursor's digest and mint, and the per-field door messages -all cost more than the prose assumed. The binding-alias fix was not in the -ledger at all. The estimate was wrong; the scope did not grow. The deletions -in commits 2 to 4 (Mesh collapse, `_projectRecord`, the Rust -`snapshot_query.rs`) are still to be counted against the total. - -Status: cut map for two cuts. **Cut 1: the selection vocabulary in the C# -reference runtime and the Rust runtime** (sections 1-17), with the -follow-on runtime cuts and the Huginn consumer cut named and not mapped. -**Cut 2: watching a selection** (section 18), mapped to the same standard, -landing after Cut 1 and depending on it. Cut 1 goes to Hands without -waiting on anything in Cut 2. - -**Rulings this map is written under (operator, 2026-09-17):** - -- The vocabulary lands in CultLib at the CultNet layer, not in Huginn. It - fills a hole: the query side of the substrate was never dropped by - decision, only never asked for. `contracts/cultnet/cultnet-distributed-database.md:10-11` - names RethinkDB changefeeds with "point, table/schema, and **filtered - watches**" as prior art to keep; schema and key allowlists were built, - filtered selection never was. -- The first cut covers the C# reference (`src\GameCult.Networking`) and - Rust (`packages\cultnet-rs`, what Huginn consumes). TypeScript, Python and - Kotlin follow when they have a caller. -- Deletes first: the two untyped stand-ins are replaced, not sat beside. -- The CultMesh subtraction audit is a required part of the map, with real - line counts, and an honest miss beats a padded ledger. -- Nothing on the wire is serde-untagged (rule of this cut; evidence below). - -**What survives unchanged from the Huginn-targeted specification** -(`scratchpad/cut10b-read-side-spec.md`, D1-D4): the five capabilities, the -chosen representation and the rejected ones with their reasons, the order and -snapshot cursor, the role-typed hop, the one negation, and the bound. What -changes: owner, repo, the value model of a field predicate (strings, because -the cache's indexes are strings), the parity obligation, and the subtraction. - -## Pins - -| Repo | Branch | HEAD | State | -|---|---|---|---| -| CultLib | `main` | `51fb449` | dirty in `native/GameCult.Mesh.Quic.Native/*` and `scripts/mutate-cultmesh.mjs` from the live QUIC campaign; **not touched**. Every `file:line` below is against `51fb449`. | -| Huginn | `eureka/memory-organ` | `25a841d` | the consumer; its cut is named in section 12 and mapped in the Epiphany campaign map. | -| Epiphany leaf | `5cda0886` | — | untouched. | - -Baselines: `src/GameCult.Networking` 14,848 lines; `src/GameCult.Mesh` -21,720 lines; `tests/GameCult.Networking.Tests/NetworkingTests.cs` 141 -`[Test]`s; `packages/cultnet-rs` 111 `#[test]`s; `cultnet-rs` depends on -`serde`, `rmp-serde 1.3`, `rmpv`, `serde_json`, `cultcache-rs` and no -`schemars` (`packages/cultnet-rs/Cargo.toml:14-31`). - -## 1. Body facts, each by source read - -**The reference's read side today.** - -- `CultNetDatabase` (`src/GameCult.Networking/CultNetDatabase.cs`): change - kinds `:15-45`; `Watch` `:1052`, `WatchAllChanges` `:1063`, - `WatchRecord` `:1072`; `GetAll` `:776`; per-shard mutation log with - sequence (`:465-466`, `:528-590`, `:631` a snapshot carries - `ShardLogSequence`). `CultNetSchemaAliasMatching` `:47-97` is the one - schema-alias matcher, `internal static`. -- **Three separate selector engines evaluate the same two allowlists:** - `CultNetDocumentRegistry.CreateRawSnapshotResponse` `:263-349` (snapshot, - over `cache.AllEntries` by reflection at `:325-342`) with its own - `MatchesRequestedSchema` `:505-524`; `CultNetDatabaseServer.CreateChangeMessage` - `:347-384` with `Matches`/`MatchesSchema`/`InferSchemaName` `:401-468` - (the LiteNetLib path, the one production construction site, - `CultNetLocal.cs:154`); `CultNetDatabaseSubscriptionServer.CreateChangeCore` - `:442-485` with `MatchesRequestedSchema` `:493-500` (the RUDP path). Two - of them carry an identical reflection copy of `CreateRawRecord` - (`CultNetDatabaseServer.cs:386-399`, `CultNetDatabaseSubscriptionServer.cs:502-515`). -- **The two stand-ins.** `CultNetDatabaseSubscribeMessage` - (`CultNetSchemaMessages.cs:788-826`): `schemaIds` `:805`, `recordKeys` - `:809`, `includeSnapshot` `:813`, then `consumerRuntimeId`, `bodyIds`, - `supportedBodyTransports` (body-plane demand, not selection). The same - pair on `CultNetSnapshotRequestMessage` `:737-741`, - `CultNetShardCatalogRequestMessage` `:949-953` (shard routing, stays), and - `CultNetSchemaCatalogRequestMessage` `:1591` (catalog, stays). - `_projectRecord` on the subscription server (`:27`, `:44`, `:50`, - `:239-240`, `:314`): **no production caller constructs the subscription - server at all** — `grep "new CultNetDatabaseSubscriptionServer"` over - `src/` is empty; it is built only in tests (`NetworkingTests.cs:1596-1605` - passes `projectRecord`). Projection is authority that exists in the - reference and reaches no wire and no caller. -- **The cache already owns addressing.** `CultIndexAttribute` - (`src/GameCult.Caching/CultDocumentContracts.cs:34`, `Alias` `:41`) names - a member as an index; the cache's index values are **strings by - construction** (`CultCache.cs:1084` `Getter = document => getValue(document)?.ToString()`; - `GetByIndex(string alias, string value)` `:1503-1509`). - `CultReferenceAttribute` (`:45`, `TargetType` `:53`, `Many` `:55`) names a - member as a reference; the descriptor records `IsReference`, `IsMany`, - `TargetSchemaName` (`CultCache.cs:1069-1080`). So a field predicate's key - is an index alias and a hop's role is a reference member, both declared - by the document's owner. -- **The declarations are public; the values are not.** `ToCatalogEntry()` - (`CultCache.cs:151-176`) is public and carries every member's `Slot`, - `MemberName`, `TypeName`, `IsReference`, `IsMany`, `TargetSchemaName`, - `IsName`, `IndexAlias`. But `CultDocumentDescriptor.Members` (`:149`) and - `IndexAccessors` (`:148`) are **`internal`**, and `GameCult.Caching` - declares no `InternalsVisibleTo` for `GameCult.Networking` (the only - `InternalsVisibleTo` in `src/` outside the vendored Unity tree is - `GameCult.Caching.MessagePack` → `GameCult.Caching.Tests`). That is why - `CreateRawSnapshotResponse` reaches for reflection at - `CultNetDocumentRegistry.cs:325-342`. **An evaluator outside the cache - assembly cannot read a declared index's value today**, by any means the - cache offers. The earlier claim in this map that no cache change is - needed for addressing was wrong: addressing is declared publicly and - *readable* only inside the cache. Section 6 carries the read surface as - an add; sections 7 and 14 carry its cost. -- **Schemas are hand-written JSON** in `contracts/cultnet/*.schema.json`, - listed by file name in `CultNetSchemaRegistry.cs:360-385`; TypeScript - compiles them with Ajv and refuses unknown fields - (`cultnet.database-subscribe.schema.json` has `additionalProperties: false`); - Python lists required fields per version by hand - (`packages/cultnet-py/src/cultnet_py/schema_catalog.py:283`, `:407`); Rust - types are a serde enum tagged on `schemaVersion` - (`packages/cultnet-rs/src/contracts.rs:252`) with hand validation - (`require_optional_string_vec`, `:548-594`). C# encodes with - MessagePack-CSharp string keys (`[Key("name")]`), i.e. named maps; Rust - encodes with `rmp_serde::to_vec_named`. -- **Runtime coverage of the subscribe message today.** C# server ×2, C# - client (`CultNetDatabaseSubscriptionClient.cs:138-151`); TypeScript - contract only (`packages/cultnet-ts/src/contracts.ts:237-247`, no server - fanout, interop tests send it at `test/interop/cultnet-interop.test.ts:719,798`); - Python server ×2 (`cultnet_py/interop_peer.py:390-401` with - `DatabaseSubscription.matches` `:110-125`, and - `cultmesh_py/server.py:315-331` with `_subscription_matches_document` - `:449-470`, `_schema_matches_request` `:699-716`, and a third copy in - `cultmesh_py/node.py:1322-1345`); **Rust: none** — `cultnet-rs` has - `SnapshotRequest` with the two allowlists (`contracts.rs:314-321`) and no - `DatabaseSubscribe` variant; Kotlin: none (a snapshot-request builder, - `CultMesh.kt:2111`, and a `require(schemaVersion == "…snapshot_request.v0")` - at `:2195`). -- **How each runtime treats a message it does not know**, which is the - wire-parity fact section 9 rests on: C# throws - (`CultNetSchemaMessageSerialization.cs:85` `_ => throw`); TypeScript - `parseCultNetMessage` (`contracts.ts:728-744`) looks the version up in its - Ajv validators and throws when absent; Kotlin `require`s the exact version - (`CultMesh.kt:2195-2196`); Rust's tagged enum fails to deserialize on an - unknown tag; **Python returns `[]`** (`interop_peer.py:360-387` falls off - the chain; `cultmesh_py/server.py:341` delegates to `handle_message`) — a - silent drop, no reply, and for a *known* version with unknown fields - Python reads only the fields it expects (`message.get(...)`), so it would - answer a selector it did not understand with the wrong rows. -- **Every committed write has a shard-log sequence** — assumed from - `CultNetDatabase.cs:465` (`_nextLogSequences`) and the invariant "raw - snapshot/put/delete messages must pass through `CultNetDatabase`" - (`cultnet-distributed-database.md:115-116`); **Hands confirms** that a - local `PutAsync` outside any shard also takes a sequence, because the - order in section 3 rests on it. If it does not, the row's ordinal is its - `storedAt` and the cursor refuses on any advance (section 3 says what - that costs). - -**The Aetheria catalog as a hostile example, verified at -`F:\Projects\Aetheria\Assets\Scripts\ServerShared`:** - -- **Inheritance.** `ItemData.cs:272` `abstract class ItemData` (`Name` - `:275`, `Mass` `:284`), `:313` `abstract class CraftedItemData : ItemData` - (which re-declares `Name` at `:327`, hiding the base's), and only the - leaves carry `[CultDocument]`: `SimpleCommodityData` `:300`, - `CompoundCommodityData` `:330`, `GearData` `:449`, and more. The cache's - `DiscoverMembers` (`CultCache.cs:838-875`) walks `current.BaseType`, so a - leaf's descriptor carries every inherited member with its attributes - (`GetCustomAttribute` reads the member's own attribute wherever it is - declared). Inherited addressing therefore already resolves per leaf. - **Corrected on a second read:** duplicate *slots* are refused - (`:931-935`), and a **hiding member is already refused too** — `:937-943` - groups keyed members by `Member.Name` and rejects any name carried at two - different slots (`HiddenMemberMessage`), while a hiding member re-using - one slot is caught by the duplicate-slot rule. So `CraftedItemData`'s - `Name` at `ItemData.cs:327` hiding `:275` is **not registrable in - CultCache as it stands**, however the aliases fall; that is a finding for - the Aetheria campaign, not a hole here. What is *not* refused by name is - two members with **different names** carrying the **same index alias** - (base `[CultIndex("mass")] Mass`, leaf `[CultIndex("mass")] Weight`). - That does not collide silently either: the alias map is built by - `ToDictionary` at `:462-468`, which throws `ArgumentException("An item - with the same key has already been added")` — a real refusal, from the - wrong owner, naming neither schema nor member nor alias. D10 is therefore - a **renaming of an existing refusal**, not a new rule, and is smaller - than it first looked. -- **A reference to an abstract target.** `FactionProduct.cs:24` - `CultRecordRef Design`. `PersistedMember.FromMember` - (`CultCache.cs:1069-1071`) takes the target type and reads - `CultDocumentAttribute` from it — an abstract base has none, so - **`TargetSchemaName` is `null` today for exactly this reference**. The - registry can compute the leaf set by assignability (it already does for - index lookups, `:1506`, and store routing, `:1828`) but records nothing. -- **A dictionary reference with a value.** `ItemData.cs:333-334` - `[CultReference(typeof(PersonalityAttribute), many: true)] Dictionary, float> DemandProfile`. - The descriptor records `IsMany` (`:1080`) and nothing else: **no code in - the cache enumerates a many-reference's targets**, and the only accessor - is `Getter = document => getValue(document)?.ToString()` (`:1082`), which - on a dictionary yields the CLR type name. The edge and its payload are - declared and unreadable. -- **Numbers.** `PersistedMember.MemberType` (`:1053`) knows the member is a - `float`, and the catalog persists it as `TypeName` (`:38`, `:192`, - compared by `CompareSchemaShapes` `:605-629`), so **a member's numeric - type is already declared and persisted**; what is missing is a typed - accessor beside the string one. Adding an accessor is a runtime change - with no catalog-shape or hash change, because `TypeName` is already in - the canonical shape. - -**The cache's index maps are unique indexes, and the evaluator must not use -them.** `Index(stored)` (`CultCache.cs:1849-1862`) writes -`MapOf(_indexes, (type, alias))[value] = key` — **one key per value, last -writer wins**. Two rows of one type sharing an index value do not collide -loudly; the second silently displaces the first in the map. `GetByIndex` -(`:1502-1508`) reads that map and `Single` (`:1838-1848`) throws only -when *several types* assignable to `T` each contribute a key, never for two -rows of one type. So `GetByIndex` answers "the row that most recently -claimed this value", not "the rows with this value". - -The consequence for this cut is a rule, not a caveat: **the evaluator -evaluates every predicate per row, through the accessor, and never through -`_indexes` or `GetByIndex`.** The declared alias is an *addressing* name — -the cache's own lookup map is a different, lossier thing that happens to be -built from the same declaration. A "use the index, it is already there" -optimisation would turn `kind any_of [weapon]` into a one-row answer. S23 -pins it, `WatchByIndex` already has the bug (section 18), and the -unique-index behaviour itself is left exactly as it is: it is the cache's -own contract for a single-record lookup and this cut does not renegotiate -it. - -**A fourth selector engine, in Rust, that this map's first draft missed.** -`packages/cultnet-rs/src/snapshot_query.rs` (339 lines, `pub use` at -`lib.rs:29`) already serves and asks snapshot requests: - -- `serve_read_only_raw_snapshot` `:64-130` is an engine — it walks the - source's records, refuses a duplicate identity and an unregistered - schema, applies a `CultNetReadOnlySnapshotPolicy` allowlist of - `(schema_id, record_key)` pairs, then filters by the request's - `schema_ids` and `record_keys` (`:112-125`). The policy is - **authorization** and stays; `:112-125` is the fifth copy of the same two - allowlists and goes, as the C# three do. -- `CultNetRawSnapshotQuery` `:158-285` is the client side, and it exposes - the sharpest limit of the two-allowlist model: `request()` `:195-217` - turns a set of exact `(schema, key)` pairs into the **cross product** of - their schemas and their keys, and `accept_response` `:219-284` then - *errors* on any record the cross product dragged in - (`"snapshot response contains unexpected record"`). Asking for `(A,x)` - and `(B,y)` asks for four and refuses two. **v1 does not fix this**: - `schemas × keys` is still a cross product (section 13, Q-M). What v1 does - change is that `accept_response`'s check is a *declared expectation*, not - a re-filter of a page the server got right, and section 8's forbidden - writer is worded to keep that distinction honest. - -**The CultMesh audit** (Explore pass over `src/GameCult.Mesh` and -`CultNetDocumentRegistry.cs`, 92 sites classified) is in section 11 with its -numbers. - -## 2. The vocabulary, exactly - -One shape, `cultnet.selection.v1`, carried by two request messages at a new -version and answered by one page message. Field names are the wire -spelling; C# and Rust type names follow each runtime's convention. - -``` -Selection { - schemas: string[]? // kinds: schema ids or aliases, matched by CultNetSchemaAliasMatching; absent = every schema - keys: string[]? // record-key allowlist; absent = every key - fields: FieldPredicate[]? // conjunction; each is any-of over one declared index alias - cites: Citation? // rows whose declared reference `role` (or any reference) names `target` - cited: Incoming? // rows that some row does / does not name in `role` - projection: "header" | "document" // default "header" - descending: bool // default false; the one order, reversed - limit: uint32? // clamped 1..=200 - cursor: string? // opaque; minted by the answering server -} -FieldPredicate { // one operator per predicate, named by `op`; no union, no untagged - index: string // a declared index alias, reachable on some schema the selection can reach, else refused - op: "any_of" | "lt" | "le" | "ge" | "gt" - values: string[]? // present iff op = any_of; non-empty - number: string? // present iff op is a comparison; a canonical decimal (Q-J, below); the alias must be declared numeric on every reachable schema that declares it -} -Citation { target: RecordRef, role: string? } // target validated as a reference the row owner recognises -Incoming { role: string, exists: bool } -RecordRef { schemaId: string, recordKey: string } - -SelectionPage { // cultnet.snapshot_response_raw.v1 carries this - matched: uint32 - asOf: uint64 // the snapshot the page is exact for - next: string? // absent on the last page - headers: RawDocumentHeader[]? | documents: RawDocumentRecord[]? // exactly one present, by projection - edges: Edge[]? // present iff the selection has `cites` or `cited`: the edges the hop traversed - shardId?, shardEpoch?, shardLogSequence? // as v0 carries them -} -Edge { - from: RecordRef // the citing row - role: string - to: RecordRef // the cited row, its schema resolved to the leaf actually stored - payloadEncoding: "messagepack"?, payload: bytes? // the value attached to the edge (a dictionary's value), typed by the member's declared value type; present under `document`, absent under `header` -} -RawDocumentHeader = RawDocumentRecord minus payload, payloadEncoding -``` - -Messages: `cultnet.snapshot_request.v1 { messageId, selection, shardId?, shardEpoch? }`; -`cultnet.database_subscribe.v1 { messageId, subscriptionId, selection, includeSnapshot, consumerRuntimeId?, bodyIds?, supportedBodyTransports? }`; -`cultnet.snapshot_response_raw.v1` as above. `database_change_raw.v0` and -`database_unsubscribe.v0` are unchanged: a change is a change. - -**Rules, each one owner:** - -- **Order** is `(ordinal, schemaId, recordKey)` ascending, or the reverse - under `descending`. `ordinal` is the sequence of the commit that last - wrote the row (the shard-log sequence in the reference; the receipt - sequence in Huginn). The row owner supplies it; the vocabulary never reads - a clock. -- **Snapshot.** A page is exact as of `asOf`. A cursor carries `asOf`, the - last `(ordinal, schemaId, recordKey)`, and a digest of the selection with - `cursor` and `limit` cleared. A server that can present rows as of the - cursor's `asOf` answers the next page over that snapshot; **a server that - cannot refuses** `cultnet.error.v0 { code: "cursor_stale", details: { asOf, current } }`. - The reference refuses when the shard log has advanced (rows mutate, and a - replay is not this cut's); Huginn never refuses (append-only, one - writer). One contract, two honest servers. A cursor that does not decode, - or whose digest is not this selection's, is `cursor_invalid`. -- **Fields** are predicates over one declared index each, conjoined across - predicates: `any_of` over string values, or one of four comparisons - (`lt`, `le`, `ge`, `gt`) against one number over a declared numeric - member. A row of a schema that does not declare the index never matches - it. An `index` no reachable schema declares, an empty `values`, a - comparison on an alias any reachable schema declares non-numeric, an - empty `keys` or `schemas` list, and a `role` no schema declares are - refused typed at the door (`selection_invalid { field, value }`), never - answered as an empty page. `any_of` values are strings because the - cache's indexes are strings (`CultCache.cs:1084`). Comparison numbers are - **canonical decimal strings** under the operator's Q-J ruling. The rule is - specified in full in the **Numbers** item below. No range operator (two - predicates on one alias make a range), no `ne`, and no nesting. -- **Numbers (Q-J; Self's specification, 2026-09-22, which the ruling asked - for and this map had not yet written).** - - *Canonical form.* A number on the wire matches - `^-?(0|[1-9][0-9]*)(\.[0-9]*[1-9])?$`, and `-0` is excluded. That fixes - one spelling per value. It forbids leading zeros, trailing fractional - zeros, a bare trailing point, an explicit `+`, exponent notation, - negative zero, whitespace, and culture separators. - - *Non-canonical spellings are refused.* Any other spelling is refused at - the door with `selection_invalid { field: "number", value }`. It is never - normalised. The reasons: the cursor digest and the parity vectors need - one byte form per value, and a lenient parser differs between runtimes - at exactly these edges. The ruling says "a non-canonical spelling of an - equal value must not change an answer". That holds because such a - spelling never reaches the evaluator. It gets a typed refusal, not a - different page. - - *Row side.* The cache renders a numeric member as its exact canonical - decimal: - - An integer type renders as its exact decimal. There is no 2^53 limit. - - `decimal` renders with its trailing zeros stripped. - - `float` and `double` render in the runtime's established shortest - round-trip form (`"R"` in .NET, the standard shortest form in Rust), - rewritten from exponent notation to positional digits. Parity pins - this. - - A NaN or infinite member matches no comparison. - - *Comparison.* Both operands are canonical, so they are compared - numerically by sign, then by the integer part's length, then by the - integer digits, then by the fraction digits padded on the right. It is - one small pure function per runtime, with no numeric parse and no - precision limit. - - *Mutants that must die:* - - a lexicographic comparison ("10" < "9"); - - a comparison that ignores sign; - - a comparison that ignores the integer part's length; - - a door that accepts `+1`, `1.0`, `01`, `1e3` or `-0`; - - a row rendering through float64 (a `long` member of 2^53+1 must - compare greater than `"9007199254740992"`). - - Refusing a non-canonical number at the door, rather than normalising it, - was Self's reading of the ruling. **The operator accepted it on - 2026-09-22.** -- **Inheritance.** A schema is a leaf: only a type with `[CultDocument]` - has a schema name, and `schemas` names leaves. A predicate on an alias - applies to every reachable leaf whose descriptor carries a member with - that alias, inherited or own — the cache already discovers inherited - members per leaf, so "gear or commodities with `mass` over 10" is - `schemas: [geardata, simplecommoditydata], fields: [{index: mass, op: gt, number: 10}]` - with `mass` declared once on the abstract base. Two leaves that each - declare a member with the same alias independently are two members; the - predicate reads each leaf's own. One leaf that resolves the same alias to - two members (a base member and a hiding re-declaration both carrying the - alias) is refused at registration (D10). A numeric comparison on an alias - that is numeric on one reachable leaf and a string on another is refused - at the door, because the row owner cannot compare a string. -- **The hop** reads edges the row owner declares: in the reference, members - with `CultReferenceAttribute` (role = the member's index alias or name); - a one-reference yields one edge, a many-reference yields one edge per - element, and a dictionary keyed by references yields one edge per key - **with the value as the edge's payload**. In Rust, `Row::references()` - yields `(role, target, payload)`. **A reference's target is a set of - leaves**: the declared target type's own schema if it is a leaf, else - every registered leaf assignable to it (`CraftedItemData` → - `compoundcommoditydata`, `consumableitemdata`, …). The hop follows an - edge into any row whose schema is in that set. **A stored edge naming a - row whose schema is outside the set refuses the selection** - (`reference_outside_target { from, role, to }`), not skips it: an edge the - declaration forbids is corrupt data and a read does not paper over it. - The citer's status, fields and authorization are not consulted by the - hop; one hop, no second. -- **Edges in the answer.** A selection with `cites` or `cited` returns, beside - the rows, the edges the hop traversed, each with its payload under - `document` projection and without it under `header`. A weighted reference - and a bill of materials are the same shape: follow the edge, return the - payload, filter the rows. **Filtering on the payload as part of the - traversal is not expressible** (section 13): that is a join condition. -- **Projection.** `header` is the record without its payload; `document` is - the record. The consumer decides what a header *means* for typed rows - (Huginn's typed summary is its header; the substrate carries whichever - type the page was instantiated with). -- **Not on the wire:** the admission window (`admittedAfter/Before`) of - Huginn's Cut 9 — `descending` plus the cursor answer "the latest"; a date - window is a range, and ranges are out (R-3). - -**The bound, carried forward in force, with one widening the operator -ruled.** Closed key set (declared aliases), a closed operator set (any-of -and four comparisons, one per predicate, no nesting), no path language, no -expression terms, no OR, one negation, one hop with its edges returned, no -depth field, no sort field, no text, no predicate on an edge's payload in -the traversal. Section 13 lists what is deliberately impossible. The -capability set is now: **C1** typed predicates over declared members, -string any-of and numeric comparison; **C2** projection; **C3** order and -cursor; **C4** one hop, both directions, returning typed edge payloads; -**C5** existence and count without bodies. - -**Nothing on the wire is serde-untagged — rule, with evidence.** Probe -`scratchpad/untagged-probe/` (result in `untagged-probe-result.txt`), run -2026-09-17 against `rmp-serde 1.3.1` / `rmp 0.8.15` / `serde 1.0.229` (the -versions Huginn's lockfile resolves; `cultnet-rs` pins `rmp-serde 1.3`): -every `#[serde(untagged)]` enum with struct variants **fails to round-trip -under positional MessagePack** (`data did not match any variant of untagged -enum`) and passes only under named maps; externally tagged shapes pass under -both. The reference encodes named maps and so does Rust today, so an -untagged shape would work by accident of an undocumented encoding choice. -`grep untagged packages/cultnet-rs/src packages/cultcache-rs/src` is empty -at `51fb449`; this cut keeps it empty and pins it (section 10). - -## 3. Decisions, with the reasons - -**D1. Strings for field values, not typed enums.** In the Huginn-targeted -design a predicate was `Option>`, closed at compile -time. The substrate cannot know a consumer's enums, and the cache's own index -model is string-valued; so the wire carries strings and the row owner -refuses a value outside its enum at the door. The loss is compile-time -closure; what is kept is runtime closure with a typed refusal, which is the -same guarantee to a caller. - -**D2. Declared aliases, not a field-path language.** `index` and `role` are -names the document's owner declared with `CultIndexAttribute` and -`CultReferenceAttribute`; an undeclared name is refused. A dot-path over the -payload would be the string language the bound excludes, and the cache -already has the declaration mechanism. - -**D3. A new message version, not new fields on v0.** Section 1's fact: -Python reads only the fields it knows and would answer a v0 selector it -did not understand with the wrong rows. A v1 version string makes every -runtime not in this cut refuse or drop by its own dispatch rather than -mis-answer. The cost is that v0 stays answerable by the reference until the -follow-ups land (R-1, a scheduled deletion with a named trigger). - -**D4. One evaluator in the reference, three engines deleted.** `Select` -over `(descriptor, key, document)` rows from the cache replaces -`CreateRawSnapshotResponse`'s loop, `CultNetDatabaseServer.CreateChangeMessage`'s -`Matches`, and `CultNetDatabaseSubscriptionServer.CreateChangeCore`. -`CultNetSchemaAliasMatching` (`CultNetDatabase.cs:47-97`) becomes the one -alias matcher and goes `public`; the four Mesh copies and two Networking -copies go. - -**D5. Projection becomes a wire value; `_projectRecord` dies.** The -delegate ran after authorization and reached no caller. `projection` on the -selection is the same authority as a value; `_authorizeRequest` and -`_authorizeRecord` stay, because authorization is not selection. - -**D6. Subscriptions evaluate the selection on the snapshot and on every -change, and hop-bearing selections reconcile.** `cites`/`cited` are -set-dependent: a change to row B can change whether row A matches. The -subscription server already owns a diff-against-delivered reconcile -(`Reconcile` `:251-292`, `DeliveredBySourceRecordKey`); a hop-bearing -subscription runs it on every change instead of the per-change fast path. -That is the mechanism by which "watch a question" falls out, and this cut -does **not** add a local `CultNetDatabase.Watch(Selection)`; section 11 -records what that later cut would collapse; it is Cut 2, section 18 (R-5). - -**D7. The Rust runtime carries the evaluator, not only the types.** Huginn -evaluates selections over its own rows; if `select` lived only in C#, Rust -consumers would each write one. `packages/cultnet-rs/src/selection.rs` -holds the types, the cursor, and `select` over a `Row` trait, with no -subscription client in this cut (Huginn does not subscribe). - -**D8. Numbers are addressable through the type the cache already declares; -the cache gains accessors, not attributes.** A comparison needs a numeric -value the string getter cannot give. `CultCache.cs:1053` holds the CLR -`MemberType` and the catalog already persists `TypeName` in the canonical -shape, so numeric-ness is declared; what is added is runtime: on each -member, `ValueOf(document) -> object?` and `NumberOf(document) -> string?` (the exact canonical decimal, Q-J; was `double?` before 2026-09-22) -(non-null exactly for the closed set of CLR numeric types), and the public -member entry exposes `IsNumeric` derived from `TypeName`. No new attribute, -no catalog-shape change, no schema hash moves, no migration. Rust mirrors -it as `Row::number(index)` and `RowSet::is_numeric(schema, index)`; the -consumer decides from its own types. - -**Verified by source read, because everything downstream rests on it:** -`CultDocumentMemberDescriptor.TypeName` is set at `CultCache.cs:474` from -`CultSchemaTypeNames.FromType(member.MemberType)`, which renders a CLR full -name (`System.Single`, `System.Nullable`, -`System.Collections.Generic.Dictionary, -System.Single>`); `ToCatalogEntry()` `:168` copies it into -`CultSchemaMemberCatalogEntry.TypeName` `:42`; `CompareSchemaShapes` -compares it inside `CanonicalSchemaMember` `:605-629`. Numeric-ness is -declared and persisted today, and the claim holds. - -**Two corrections to the estimate.** First, `IsNumeric` has two possible -authorities — the CLR `MemberType` in hand and the `TypeName` string read -back from a stored catalog. They must agree, or a schema validates one way -locally and another way against a persisted catalog. **The CLR set is the -authority** (a closed set: the eight integer types, `float`, `double`, -`decimal`, and `Nullable` over any of them); the string set is generated -from it by `CultSchemaTypeNames.FromType`, never hand-written. S22 pins -that. - -Second, and larger: the accessor is not the only thing missing. Per section -1, `IndexAccessors` and `Members` are `internal` and `GameCult.Networking` -is not a friend assembly, so the evaluator cannot read *any* declared -value — string or number — without the reflection the map is deleting. The -cache add is therefore a **public read surface over declared members**, of -which the numeric accessor is one method: -`TryGetIndexValue(object document, string alias, out string? value)`, -`TryGetIndexNumber(object document, string alias, out string canonicalDecimal)` -(**revised 2026-09-22 under Q-J**: `NumberOf` returns the member's exact -canonical decimal string, never `double`; see §2 "Numbers"; commit 0 shipped -`double` and is corrected in the Q-J fix), `ReferencesOf` (D11), and a public member view carrying `IndexAlias`, -`IsReference`, `IsMany`, `IsNumeric` and the target **`Type`** (D9 needs -the type, not the name). Section 14 carries the revised cost. - -**D9. A reference's target set is resolved by the registry at evaluation, -not persisted.** Persisting `TargetSchemaNames` would change the canonical -member shape and every stored catalog's hash for such schemas. Resolving at -evaluation by assignability over registered leaves (`:1506`, `:1828` do -this today) costs nothing and is correct as of the registry in hand. The -catalog's `TargetSchemaName` stays what it is (`null` for an abstract -target) and is not read by the hop. - -The resolution needs a **`Type`**, and `CultDocumentMemberDescriptor` -carries only `TargetSchemaName`, a string that is `null` in exactly the -case that matters. So the public member view exposes the target `Type` -(held already on `PersistedMember.MemberType` / the attribute's -`TargetType`, `CultCache.cs:1069-1070`), and the leaf set is -`AllDescriptors` (public, `:254-255`) filtered by -`target.IsAssignableFrom(d.DocumentType)` — the same predicate -`GetByIndex` uses at `:1506` and store routing at `:1828`. It must be -computed **against the registry at evaluation, not at descriptor build**: -`BuildDescriptor` runs per type as it is registered, and a leaf registered -later would be missing from a set frozen early. A cached set keyed by -target type is legitimate only if it is invalidated by `Refresh()` -(`:257-265`) and by `RegisterDescriptor` (`:381-410`); simplest is not to -cache it. - -**D10. The duplicate-alias refusal is renamed, not invented.** The Aetheria -hiding case turned out to be refused already (section 1: `HiddenMemberMessage` -at `CultCache.cs:937-943`), and two differently-named members sharing one -alias already throw — from `ToDictionary` at `:462-468`, as an -`ArgumentException` that names neither the schema, nor the members, nor the -alias. D10 is to replace that with a rejection in `DiscoverMembers`' -`rejections` list, in the shape of its neighbours: one grouping over -`IndexAlias` beside the two at `:931-943`, one message beside -`DuplicateSlotMessage` / `HiddenMemberMessage`. The behaviour barely -changes; what changes is that the refusal can be read. Nothing in the -selection depends on it — a colliding catalog never registers either way — -so it is in this cut only because the cut is already in this file and the -message is four lines. - -**D11. Many-references are enumerated by the cache, with payloads.** On each -reference member, `ReferencesOf(document) -> IEnumerable<(CultRecordKey target, object? payload)>`: -a `CultRecordRef` yields one; an `IEnumerable>` yields -each with no payload; an `IDictionary, V>` yields each key -with its value. Anything else declared `many` is refused at registration -(`unsupported_reference_shape`), so a declaration the cache cannot walk is -never silently walked as nothing. - -The enumeration reads `ICultRecordRef.Key` (`CultDocumentContracts.cs:92-95`), -which every `CultRecordRef` implements (`:97-99`), so it needs no -generic dispatch. Two shape rules fall out of `ResolveReferenceTarget` -(`CultCache.cs:1100-1113`) and belong in the same refusal: a member -declared `many` whose element type is not a `CultRecordRef<>` and whose -attribute names no `TargetType` has **no resolvable target at all** -(`IsReference` is true only because the attribute exists, and -`TargetSchemaName` is `null` for a reason that is not inheritance); and a -`many` member that is neither `IEnumerable>` nor -`IDictionary, V>` cannot be walked. Both are -`unsupported_reference_shape` at registration. A `many: false` member whose -type is not `CultRecordRef` is the same fault and takes the same -refusal. - -**Agreement with our own abandoned prior art** (`ThreadEpiphanyGraphQuery`, -Epiphany `5c62a650`, deleted with its host `5f6f2441`): direction and edge -kind as data, schema derived — agreed; differs as before (predicate not -subgraph read; roles are declared names not free strings; one negation -added; no depth field). - -**Rejected, unchanged in reason:** Qdrant's `Filter` (open dot-path keys, -polymorphic values, OR/threshold/recursion advertised then refused, the -untagged hazard; Cut 11 lowers `FieldPredicate` to `must`/`match.any` in ~30 -lines on the daemon side); ReQL term trees; Firestore `CompositeFilter`; -Mongo/ES magic-key documents; every string-parsed evaluator surveyed. - -## 4. Deletes first - -| Path:lines (at `51fb449`) | Lines | What | -|---|---:|---| -| `src/GameCult.Networking/CultNetDatabaseServer.cs:347-468` | 122 | `CreateChangeMessage`, `CreateRawDocumentRecord`, `Matches`, `MatchesSchema`, `InferSchemaName` — the LiteNetLib selector engine; the handler calls the shared evaluator and the shared raw-record projection | -| `src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs:424-515` | 92 | `CreateAuthorizedChange`, `CreateChange`, `CreateChangeCore`, `ResolveWireSchemaId`, `MatchesRequestedSchema`, `CreateRawRecord` — the RUDP selector engine | -| `CultNetDatabaseSubscriptionServer.cs:27, :44, :50, :239-240, :314` | 6 | `_projectRecord`, its parameter and its two call sites | -| `CultNetDatabaseSubscriptionServer.cs:294-322` | 29 | `CreateProjectedSnapshot` body: becomes evaluate + authorize + project (~12 lines stay) | -| `src/GameCult.Networking/CultNetDocumentRegistry.cs:263-349` | 87 | `CreateRawSnapshotResponse`: the loop and its reflection over `cache.AllEntries` (`:325-342`); the raw-record construction (`:297-311`) survives as `ToRawRecord` | -| `CultNetDocumentRegistry.cs:505-524` | 20 | `MatchesRequestedSchema`, a copy of `CultNetSchemaAliasMatching.Matches(candidate, descriptor)` | -| `CultNetDocumentRegistry.cs:247-258` | 12 | `CreateSnapshotRequest` (v0 builder); v1 takes a `Selection` | -| **Networking total** | **368** | | -| `src/GameCult.Mesh/CultMeshSnapshots.cs:638-661, 750-773, 889-911` | 71 | three verbatim clones of the selector-carrying options; one `with` copy over a record type remains | -| `src/GameCult.Mesh/CultMesh.cs:2721-2748, 1941-1969` | 57 | the fourth and fifth copies (`ToSnapshotRequestOptions`, `WithPublicationBindingOptions`) | -| `CultMeshSnapshots.cs:973-983` | 11 | `CleanSnapshotFilter`: normalisation is the selection's own validation | -| `CultMeshSnapshots.cs:1000-1009, 1033-1047, 1078-1088` | 36 | the client-side re-filter of a snapshot by schema and `InferSchemaName`; the server's v1 answer is exact, decode stays | -| `CultMesh.cs:2750-2800` | 51 | `ReadDocumentFromSnapshotResponse`'s four-tier fallback (which returns a record with the wrong key when only the schema matches) and `TryDecodeSnapshotDocument`; replaced by one exact read (~10 stay) | -| `CultMesh.cs:3474-3497` | 24 | `IsSameCultDocumentSchema`: `CultNetSchemaAliasMatching`, now public | -| `CultMeshSnapshots.cs:625-636` and the rule at `:198`, `CultMesh.cs:2716` | ~15 | the "no schema filter when keys are given" rule, three times; one place | -| `CultMeshClient.cs:574-604, 830-860, 932-962` selector construction | ~25 net | `{recordKeys, schemaIds}` literals become `Selection` literals; the retry loops stay | -| `CultMeshBodyDemand.cs:177-221` | ~15 net | `CultMeshHotBodySubscription` builds two allowlists; builds one `Selection` | -| `CultMesh.cs:2701-2719, 2838-2858, 2875-2915` | ~20 net | selector construction for peer snapshots and body subscriptions | -| `CultMeshSnapshots.cs:139-201, 434-516, 611-623` overlay logic | ~40 net | overlaying caller `schemaIds`/`recordKeys` onto defaults, four times | -| **Mesh total** | **≈ 365 gross, ≈ 331 net** | see section 11 for what does not go | -| `packages/cultnet-rs/src/snapshot_query.rs:100-130` | ~31 | the fifth selector engine: the `requested_schemas` / `requested_keys` sets and the two `is_some_and` filters inside `serve_read_only_raw_snapshot` become one `select` over a lowered v0 selection. The duplicate-identity and unregistered-schema refusals (`:90-110`) and the whole `CultNetReadOnlySnapshotPolicy` (`:132-157`, authorization) stay | -| `src/GameCult.Caching/CultCache.cs` | **0** | the cache deletes nothing. The string `Getter`/`GetterNullable` pair (`:1081-1082`) stays: it is what `any_of` compares and what the index maps are built from (`:1858`). The numeric accessor is beside it, not instead of it | -| `tests/GameCult.Networking.Tests/NetworkingTests.cs`: `CultNetDatabaseServer_Creates_Filtered_SnapshotResponse` `:3124`, `…_ForCompatibleSchemaAlias` `:3158`, `CultNetDatabaseServer_Creates_Filtered_SubscriptionChange` `:5547`, `…_ForSchemaAlias` `:5601`, `DatabaseSubscription_FiltersLiveChangesByWireSchemaBinding` `:2256`, `…ByRequestedSchemaAlias` `:2310`, and the `projectRecord` lambda in `…AuthorizesRequestAndFiltersSnapshotAndLiveRecords` `:1601-1605` | ~250 | rewritten over v1 selections against the one evaluator (section 10); the rules they pin survive | - -**Not deleted in this cut, by ruling (R-1), and scheduled for deletion on FU-v0's trigger:** the v0 message classes and -their three schema files, the v0 arms in `CultNetSchemaMessageSerialization`, -and the v0 handling in the two servers — kept as a **lowering**: a v0 -request lowers to `Selection { schemas: schemaIds, keys: recordKeys, projection: document }` -and is answered through the same evaluator, so v0 has no engine of its own. -They die with the last runtime follow-up. - -## 5. Keeps and moves - -- `CultNetSchemaAliasMatching` (`CultNetDatabase.cs:47-97`), `internal` → - `public`, the one alias matcher for every runtime-side match. -- `_authorizeRequest`, `_authorizeRecord`, `Reconcile`, `Watch`, - `ApplyProjectedChange`, `PublishDemand`, `DemandChanged` and the body-plane - fields of the subscribe message: authorization, reconciliation and demand - are not selection. -- `CultNetDocumentRegistry`'s raw-record construction (`:297-311`) as - `ToRawRecord(descriptor, binding, key, document, options)`; the payload - schema resolution (`:452-503`) untouched. -- The v0 messages, schemas and arms as a lowering (above). -- `CultMeshSnapshotRequestOptions` as a record `{ Selection, ShardId, ShardEpoch, …transport knobs }`. -- Every (B)-class site of the audit (section 11): transport racing, - authority, freshness, body routing, provider-session validation. -- The interop lanes and CI witness (`.github/workflows/cultnet-interop.yml`) - green throughout, because v0 still answers. - -## 6. Adds - -| Add | Owner | Live consumer | Protected invariant | What it replaces | -|---|---|---|---|---| -| `src/GameCult.Caching/CultCache.cs`: a **public read surface over declared members** — `CultDocumentDescriptor.DeclaredMembers` (a public view: `MemberName`, `Slot`, `IndexAlias`, `IsName`, `IsReference`, `IsMany`, `IsNumeric`, `TargetType`), `TryGetIndexValue(document, alias, out string?)`, `TryGetIndexNumber(document, alias, out string canonicalDecimal)` (Q-J, revised 2026-09-22), `ReferencesOf(document, member)` (D11), `ResolveTargetLeaves(targetType)` over `AllDescriptors` (D9) | `GameCult.Caching` | the evaluator; `CultNetDocumentRegistry`; later any runtime-side reader | **the cache owns addressing and its values**; a reader outside the assembly reads declarations and values through one surface instead of reflecting over documents | the reflection loop at `CultNetDocumentRegistry.cs:325-342`, and the absence that forced it | -| `CultCache.cs` registration refusals: one alias-collision rejection beside `:931-943` (D10), one `unsupported_reference_shape` rejection for a `many`/reference member the cache cannot walk (D11) | `GameCult.Caching` | every registering catalog | a declaration the cache cannot read is refused when it is declared, not answered as nothing when it is read | an `ArgumentException` from `ToDictionary` `:462-468` that names nothing; a silent unwalkable reference | -| `contracts/cultnet/cultnet.selection.schema.json` (`$id`, referenced by `$ref`), `cultnet.snapshot-request.v1.schema.json`, `cultnet.database-subscribe.v1.schema.json`, `cultnet.snapshot-response-raw.v1.schema.json`; registry entries in `CultNetSchemaRegistry.cs` | CultLib contracts | C#, Rust; TS/Py/Kotlin follow-ups | one published shape of a selection, hand-written like its siblings, pinned by vectors in both runtimes | `schemaIds`/`recordKeys`/`includeSnapshot` as the wire's only selection | -| `src/GameCult.Networking/CultNetSelection.cs`: `CultNetSelection`, `CultNetFieldPredicate`, `CultNetCitation`, `CultNetIncoming`, `CultNetSelectionProjection`, `CultNetRecordRef`, `CultNetRawDocumentHeader`, `CultNetSelectionPage`, **`CultNetEdge`** (`From`, `Role`, `To`, `PayloadEncoding?`, `Payload?`), **`CultNetSelectionOperator`** (`AnyOf`, `Lt`, `Le`, `Ge`, `Gt`, serialised as the `op` string, never as a union); `Validate(IReadOnlyList)` — declared aliases and roles, non-empty lists, exactly one of `values`/`number` per `op`, and every reachable leaf declaring a compared alias declares it numeric | `GameCult.Networking` | the three message classes, the evaluator, Mesh | a selection is typed data with one validation | `_projectRecord`, the allowlist pair | -| `src/GameCult.Networking/CultNetSelectionEvaluator.cs`: `Select(cache, descriptors, selection, ordinals, asOf)` → ordered, hopped, cursored, projected page; `Matches(selection, descriptor, key, document)` for a single change; the cursor mint/parse with `cursor_stale` / `cursor_invalid` | `GameCult.Networking` | `CultNetDocumentRegistry` (snapshot), both servers (changes), `Reconcile` | one evaluator; order, snapshot and hop have one owner | the three engines (section 4) | -| `CultNetDatabase.LastWriteSequence(schemaId, key)` (a per-key ordinal kept on apply, ~20 lines) | `CultNetDatabase` | the evaluator's order and cursor | a row's ordinal is its last commit's sequence | `storedAt` as the only time | -| v1 message classes beside the v0 ones; the v0→`Selection` lowering (one function) | `GameCult.Networking` | both servers | v0 has no engine of its own | — | -| `packages/cultnet-rs/src/selection.rs`: the types (`Serialize`, `Deserialize`, no `untagged`; `SelectionOperator` an **externally tagged / string** enum, `Edge { from, role, to, payload_encoding, payload }`), `Row` trait (`id`, `ordinal`, `values(index) -> &[String]`, **`number(index) -> Option`**, **`references() -> Vec<(role, RecordRef, Option>)>`**), `RowSet` trait (`declared_indexes`, `declared_roles`, **`is_numeric(schema, index)`**, **`target_leaves(role)`**), `Malformed`, `select`, cursor mint/parse, `LIMIT_MAX` | `cultnet-rs` | Huginn (section 12), later Rust services | the same evaluator semantics as the reference, pinned by vectors both ways | Huginn's own `select.rs` from the earlier design | -| `snapshot_query.rs`: `serve_read_only_raw_snapshot` calls `select` over a lowered v0 selection; the policy check stays where it is | `cultnet-rs` | its existing callers and tests | the fifth engine has no rule of its own | the filter at `:112-125` | -| `contracts.rs`: `SnapshotRequestV1`, `DatabaseSubscribeV1`, `SnapshotResponseRawV1` variants | `cultnet-rs` | Huginn's daemon | the wire spelling equals the reference's | — | -| `contracts/cultnet/selection-vectors.cs-written.json` and `selection-vectors.rs-written.json`: a fixture row set (`cultnet.interop-selection-row` document type; declared indexes `kind`, `severity`, `tags[]` and **`mass` (numeric, declared on the abstract middle type, not on the leaves)**; references `parent`, `related[]` and **`components` (a dictionary keyed by reference with a float payload)** and **`design` (declared target = the abstract middle, resolving to two leaves)**; **two leaf schemas under one abstract middle**, so inheritance, the abstract target, the dictionary edge and the comparison are all exercised by the fixture rather than argued about in prose), **twenty-eight** selections, and each selection's expected page as ordered ids + `matched` + `next` presence + **the expected `edges` with their payload bytes**. The fixture must also carry, because tests depend on it and a loosening that cannot fail is the fixture's defect: a row whose `mass` **equals** a compared number exactly (S16), **three** rows sharing one index value (S23), one edge pointing outside its declared target (S18), two dictionary entries with **different** payload bytes (S19), and a nullable numeric member and a `decimal` one (S22). **Expected to grow** (R-4): it is chosen for coverage now, not pinned as final, and adding rows or selections to it is a normal change | tests, both runtimes | `NetworkingTests`, `cultnet-rs/tests/selection.rs` | **vectors written by the reference decode and evaluate identically in Rust, and vectors written by Rust decode and evaluate identically in the reference** | a within-runtime round trip, which pins nothing about parity | -| `contracts/cultnet/interop/cultnet.interop-selection-row.schema.json` | contracts | the vectors | — | — | -| `docs/cultnet-selection-cut.md` (this file), a paragraph in `contracts/cultnet/cultnet-distributed-database.md` under "Implemented", and the runtime table row in `docs/runtime-parity-scope.md` | docs | — | describe the live system | — | - -No dependency in either runtime (`schemars` is **not** added to -`cultnet-rs`: the schema is hand-written JSON like every CultNet schema, and -Huginn's derived schema for its own request embeds `Selection` through a -`#[schemars(schema_with)]` field attribute in Huginn. The attribute returns a -`$ref` to this cut's published selection schema `$id`. It does not go through -the substrate. (Corrected 2026-09-22: a hand-written `impl JsonSchema` for a -foreign type fails to compile with `E0117`, the orphan rule. The read-side -Imagination probed it.) — -see section 12). No new package, binary, transport, or store format. -**`packages/cultcache-rs` is untouched**: the C# cache change buys the -evaluator a read surface it does not have, and in Rust the row owner -already supplies exactly that through `Row`/`RowSet`. The asymmetry is -real and intended — the reference reads documents the cache owns, Rust -reads rows the consumer owns — and it is the reason the parity obligation -is on the *vectors* and not on the two implementations' shapes. - -## 7. Per-file changes (code that exists, anchored; new code by name and rule) - -**`src/GameCult.Networking/CultNetSchemaMessages.cs`**: after `:826`, the -three v1 classes; `CultNetSchemaVersions` (`:77`, `:93` and the -`SnapshotResponseRaw` constant) gains `SnapshotRequestV1`, -`DatabaseSubscribeV1`, `SnapshotResponseRawV1`. v0 classes untouched. - -**`CultNetSchemaMessageSerialization.cs:9-85`**: three arms before the -`_ => throw`. - -**`CultNetSchemaRegistry.cs:360-385`**: four `SchemaResourceSpec` entries. - -**`CultNetDatabase.cs`**: `:47` `internal static class` → `public static -class`; a `Dictionary<(string, string), long>` maintained where a mutation -is appended to the shard log (Hands anchors on the append site; section 1's -assumption is confirmed there) and `LastWriteSequence`; `:1052-1075` untouched. - -**`CultNetDocumentRegistry.cs`**: `:247-258` → `CreateSnapshotRequest(messageId, CultNetSelection)`; -`:263-349` → `CreateRawSnapshotResponse(cache, messageId, selection, ordinals, asOf, options)` -calling the evaluator and `ToRawRecord`; `:505-524` deleted. - -**`CultNetDatabaseServer.cs`**: `:276-316` handles v0 (lowered) and v1; -`:332-345` `CreateSubscription` calls `evaluator.Matches(selection, change)` -then `ToRawRecord`; `:347-468` deleted. - -**`CultNetDatabaseSubscriptionServer.cs`**: `:27, :44, :50` `_projectRecord` -deleted; `:112-167` handles v0 (lowered) and v1, and answers a -`snapshot_response_raw.v1` page with `matched`/`asOf`/`next`; `:218-249` -`Watch`: a selection with `cites`/`cited` runs `Reconcile` on every change, -otherwise `Matches` on the changed row; `:294-322` evaluate + authorize + -project; `:424-515` deleted. Module doc `:14-18` rewritten: projection is a -value on the request. - -**`src/GameCult.Caching/CultCache.cs`** (the only cache file this cut -touches): - -- `:112-150` `CultDocumentDescriptor`'s constructor and fields: `Members` - `:149` and `IndexAccessors` `:148` stay `internal`; a **public** - `DeclaredMembers` view and the three value accessors are added beside - them. Do not widen `Members` itself — `CultDocumentMemberDescriptor` is a - mutable internal DTO whose shape `ToCatalogEntry` `:151-176` depends on, - and publishing a mutable descriptor list makes the catalog's shape a - public contract by accident. The public view is a readonly struct or - sealed record built from it. -- `:445-490` `BuildDescriptor`: the alias `ToDictionary` at `:462-468` is - the site of the D10 refusal's *symptom*; the refusal itself belongs in - `DiscoverMembers` so it lands in `rejections` with its neighbours. -- `:838-946` `DiscoverMembers`: two new groupings beside the two at - `:931-943` — one over `IndexAlias` (D10), one over reference shape - (D11) — and two message helpers beside `DuplicateSlotMessage` / - `HiddenMemberMessage`. Nothing in the existing walk changes. -- `:1050-1114` `PersistedMember`: `Getter`/`GetterNullable` `:1081-1082` - untouched; `NumberOf` (non-null exactly for the closed CLR numeric set, - `Nullable` unwrapped) and `ReferencesOf` added beside them; - `MemberType` `:1052` and the attribute's `TargetType` surface through the - public view as the target `Type`. -- `:254-255` `AllDescriptors` is the input to `ResolveTargetLeaves`; the - set is computed per call, not cached, because `Refresh()` `:257-265` and - `RegisterDescriptor` `:381-410` can both add a leaf after any given - descriptor was built. -- `CultSchemaTypeNames.FromType` (`CultSchemaTypeNames.cs:9-31`) is - `internal` and generates the `TypeName` strings; the `IsNumeric` string - set is generated from the CLR set through it, never written by hand - (S22). - -**`packages/cultnet-rs/src/snapshot_query.rs`**: `:100-130` the two -allowlist sets and their filters become one `select` over the lowered -selection; `:64-99` and `:132-157` untouched; `:158-285` -`CultNetRawSnapshotQuery` untouched in this cut and named in Q-M. - -**`CultNetSelection.cs`**, **`CultNetSelectionEvaluator.cs`** (new): as -section 6. Rules that must die under their own mutation are in section 10. - -**`src/GameCult.Mesh/CultMeshSnapshots.cs`**: `:16-62` the options record -carries `Selection`; `:139-201`, `:434-516`, `:611-636` overlay through one -`WithSelection`; `:638-661`, `:750-773`, `:889-911` deleted; `:973-983`, -`:1000-1009`, `:1033-1047`, `:1078-1088` deleted. -**`CultMesh.cs`**: `:1941-1969`, `:2721-2748`, `:2750-2800` (one exact read -remains), `:3474-3497` deleted; `:2701-2719`, `:2838-2858`, `:2875-2915` -build a `Selection`. -**`CultMeshClient.cs:574-604, 830-860, 932-962`**, **`CultMeshBodyDemand.cs:177-221`**: -`Selection` literals. - -**`packages/cultnet-rs/src/selection.rs`** (new), **`contracts.rs:314-331`** -region gains the three v1 variants, **`lib.rs`** re-exports. **`tests/selection.rs`** -(new): the vectors both directions, the evaluator, the cursor, the -refusals, the untagged grep. - -**`tests/GameCult.Networking.Tests/NetworkingTests.cs`**: the seven tests in -section 4 rewritten; new tests in section 10; the vector writer under -`CULTNET_WRITE_VECTORS=1` following `scripts/write-cultmesh-realtime-frame-vectors.mjs` -and the C#-written pattern the QUIC campaign established (`docs/typescript-quic-realtime-cut.md:15-21`). - -## 8. Authority map - -- **Owner.** CultNet's database organ, `src/GameCult.Networking`, as the - reference: `CultNetSelection` owns the shape and its validation; - `CultNetSelectionEvaluator` owns matching, the hop, the order, the - snapshot and the cursor; `CultNetSchemaAliasMatching` owns schema - identity; `CultNetDatabase` owns a row's ordinal. **The cache owns - addressing and every value of it**: which names are indexes, which - members are references, whether a member is numeric, what leaves a - reference may target, what a row's value at an alias is, and what edges - and payloads a row carries — through `CultIndexAttribute` and - `CultReferenceAttribute`, the persisted `TypeName`, and the public read - surface of section 6. The vocabulary reads those declarations and adds - none: no new attribute, no persisted field, no schema-hash movement, and - no opinion in the evaluator about what a CLR type means. **The evaluator - never reflects over a document**; if it needs something the cache does - not expose, the cache gains a method, not the evaluator a `GetProperty`. `packages/cultnet-rs` - is the Rust spelling of the same owner, at wire parity, with the row - owner supplying what the cache supplies in C# (`Row`, `RowSet`). -- **Inputs.** The cache's rows and descriptors, the shard log's sequences, - the selection, the peer's authorization answers. Not inputs: a clock, - a payload path, a transport. -- **Outputs.** Pages (`headers` or `documents`, `matched`, `asOf`, `next`), - per-change match decisions, typed refusals (`selection_invalid`, - `cursor_stale`, `cursor_invalid`) as `cultnet.error.v0` with a code, the - form the reference already uses for routing errors. -- **Derived state.** The page, the order, the incoming-edge index per - evaluation, the cursor. **Demotions:** `schemaIds`/`recordKeys` are no - longer a selection language; they are a lowering of v0 into one. - `_projectRecord` is no longer an authority; projection is a request - value. `CreateRawSnapshotResponse`, `CreateChangeMessage` and - `CreateChangeCore` are no longer engines; they call one. - `IsSameCultDocumentSchema`, the Mesh `InferSchemaName` and the registry's - `MatchesRequestedSchema` are no longer owners of schema identity. -- **Forbidden writers.** Neither server may match a record by a rule of its - own; **`serve_read_only_raw_snapshot` may not filter by schema or key** - (its policy is authorization and stays; its allowlist filter goes); Mesh - may not re-filter a page the server answered — a caller *may* verify a - page against expectations it declared before the request - (`CultNetRawSnapshotQuery::accept_response`, which errors rather than - quietly dropping, and so cannot hide a server that answered wrongly), but - no caller may narrow a page and call the narrowed result the answer; - nothing may mint a - cursor a caller could construct; nothing may answer a page over a - different `asOf` than its cursor's; the evaluator may not read a payload - by path, only declared indexes and references, **and may not reflect over - a document at all**; **the evaluator may not read the cache's `_indexes` - map or call `GetByIndex`** — those answer a unique-index lookup, not a - predicate (section 1, S23); no rule may consult an edge's payload while - traversing (section 13); nothing outside `GameCult.Caching` may decide - whether a member is numeric or what leaves a target resolves to; no type - on the wire is `untagged`; the Rust `select` may not diverge from the - reference on any vector. -- **Shared paths.** The evaluator under snapshot, change, and reconcile; - `CultNetSchemaAliasMatching` under every schema match in both assemblies; - `ToRawRecord` under snapshot and change; the v0 lowering under both - servers. -- **Deletion line.** Commit 0: the cache's public read surface and its two - registration refusals, with S17-S22 green and `GameCult.Networking` - untouched — the cache change is independently true and lands first, so - that if the rest of the cut is cut, the substrate is not left with half a - surface. Commit 1: the shape, the evaluator and the v1 - messages beside v0, with the three engines rewired and deleted and the - seven tests rewritten — green. Commit 2: `_projectRecord` and the - Mesh collapse. Commit 3: Rust. Commit 4: vectors both ways and entries. - Nothing lands with two engines alive. -- **Where the line to CultMesh falls.** Mesh is a client that builds - selections and reads pages; it evaluates nothing. Where Mesh watches a - cache locally by name or index (audit sites #2, #4-9, #16-21), it keeps - its own loops until `CultNetDatabase.Watch(Selection)` exists, which is Cut 2 (section 18). - -## 9. Parity: what this cut covers, and what the other runtimes do - -**In this cut:** the C# reference (both servers, the client, the registry) -and Rust (`cultnet-rs`: types, evaluator, cursor; no subscription client, -because no Rust caller subscribes). Parity is pinned by vectors in both -directions (section 10), not by a round trip inside one runtime. - -**Runtimes not in this cut, stated answer per runtime, by source read:** - -| Runtime | On v0 (unchanged) | On a v1 request it receives | On a v1 page it receives | -|---|---|---|---| -| TypeScript | unaffected; the reference still answers v0 | **refuses**: `parseCultNetMessage` finds no validator for the version and throws (`contracts.ts:728-744`) | refuses the same way | -| Kotlin | unaffected (client only) | **refuses**: `require(schemaVersion == …v0)` throws (`CultMesh.kt:2195`) | n/a as a server; a Kotlin client sends v0 and gets v0 | -| Python | unaffected | **silently drops, no reply**: `handle_server_message` returns `[]` for an unknown version (`interop_peer.py:387`), so a caller times out rather than reading a refusal; **it cannot answer with wrong rows**, because v1 is a version it does not dispatch, which is why D3 chose a version over new fields | as above | -| Rust | in this cut | in this cut | in this cut | - -The one runtime that mis-answers today is none: nothing sends v1 to a -runtime that does not speak it until its follow-up lands, and a stray v1 is -refused (TS, Kotlin, C#) or dropped (Python). **Python's drop is fixed in -this cut, not in its follow-up** (R-2): after this cut the table's Python -row reads "refuses with `unsupported_schema_version`", and every runtime on -the wire answers an unknown version with something a caller can read. The -build budget gains one Python test run for it. - -**Follow-ups, recorded with triggers:** - -- **FU-TS.** `cultnet-ts`: the three v1 contracts, Ajv registration, a - `Selection` type; `cultmesh-ts` builders. Trigger: the first TypeScript - caller that needs a filtered snapshot or subscription (the browser Eve - runtime, when it reads pipeline state). Until then TS refuses v1 loudly. -- **FU-Py.** Its first line is **not** a follow-up: by R-2, `cultnet-py` - and `cultmesh-py` reply `cultnet.error.v0 - { code: "unsupported_schema_version" }` for an unknown version **in this - cut** (five lines at `interop_peer.py:360-387` and the delegating path at - `cultmesh_py/server.py:341`, with one test per site that a v1 message - produces a refusal and not an empty list). The rest of FU-Py is the v1 - contracts, and the **three** copies of the selector match - (`interop_peer.py:110-125`, `cultmesh_py/server.py:449-470`, - `cultmesh_py/node.py:1322-1345`) collapsed into one evaluator. Trigger: - the first Python peer asked a v1 question, or the interop lane gaining a - v1 case. -- **FU-Kt.** `cultmesh-kotlin`: v1 snapshot request builder and page - reader. Trigger: an Android/JVM client reading filtered state. -- **FU-v0.** Retire v0's classes, schemas and lowering once FU-TS, FU-Py, - FU-Kt have landed and the interop lanes send v1. Trigger: the last of the - three. -- **Not a follow-up any more:** `CultNetDatabase.Watch(Selection)` is - **Cut 2**, mapped in section 18 with its own deletes, authority map, - verification and numbers. It lands after this cut. The audit's ≈ 240 - local-watch lines are counted there, and the real figure turned out - larger than the audit's estimate (≈ −378 in Mesh, section 18.3), because - the audit did not count the nine `Collection*` overloads as selection. - -`docs/runtime-parity-scope.md`'s table gains, per runtime, "typed selection -v1: C# and Rust claimed; TS, Python, Kotlin not claimed (refuse / refuse / -drop)". The claim is written where the parity claims live, not here alone. - -## 10. Verification - -**Builds.** `dotnet build` of `GameCult.Caching`, `GameCult.Networking`, -`GameCult.Mesh` and their test projects — the cache is now in the build -budget, and it is the assembly the rest of CultLib depends on, so -`dotnet test tests/GameCult.Caching.Tests` is the gate on commit 0 and runs -on every later commit; `cargo test -p cultnet-rs`; the TypeScript workspace -untouched but `npm run test --workspace packages/cultnet-ts` run once to -prove the interop lane still passes on v0. Host = target = workstation for -C# and Rust; no native code. Nothing cleaned. - -**Tests, each named for the rule it pins, with the mutation that kills it — -a loosening where one exists.** - -| # | Test (C# unless marked) | Pins | Revert kills | Loosening kills | -|---|---|---|---|---| -| S1 | `Selection_RefusesUndeclaredIndexRoleAndEmptyLists` | D2, door refusals typed | validation dropped (empty page) | `index` checked against *any* schema's aliases rather than the reachable ones (a predicate on an alias only an excluded schema declares matches nothing silently) | -| S2 | `Evaluator_ConjoinsAnyOfPredicatesOverDeclaredIndexes` | fields | an arm dropped | any-of treated as all-of | -| S3 | `Evaluator_OrdersByLastWriteSequenceThenIdentityAndReverses` | order, `descending` | sort by `storedAt` | `descending` ignored | -| S4 | `Evaluator_PagesExactlyOnceAndTheLastPageSaysSo` | cursor walk | `next` always present | position `>=` (last item repeats) | -| S5 | `Evaluator_RefusesAStaleCursorAfterTheShardLogAdvances` | `cursor_stale` typed | advance ignored (a page over a moved set) | digest check dropped (a cursor accepted under another selection) | -| S6 | `Evaluator_HopsOneEdgeByDeclaredReferenceAndRole` | hop, roles from `CultReferenceAttribute`, `Many` | role ignored | a second hop followed | -| S7 | `Evaluator_NegatedIncomingEdgeIsTheOnlyNegation` | `cited { exists: false }` | `exists` ignored | citer's status consulted (an unauthorized citer's edge dropped) | -| S8 | `Evaluator_HeaderProjectionCarriesNoPayload` | projection | payload carried | `payloadEncoding` carried without payload | -| S9 | `Subscription_EvaluatesSelectionOnSnapshotAndEveryChange_AndReconcilesHopBearingOnes` (rewrite of `:1587`) | D6 | a change matched by v0 allowlists | a hop-bearing subscription taking the per-change fast path (a change to the citer not reconciled) | -| S10 | `V0Request_LowersToASelectionAndHasNoEngineOfItsOwn` (rewrites of `:3124`, `:3158`, `:5547`, `:5601`, `:2256`, `:2310`) | D3/D4 | v0 matched by its own loop | alias matching inlined again in one server | -| S11 | `SchemaAliasMatching_IsTheOneMatcherInBothAssemblies` (grep-shaped, negative) | D4 | — | — | -| S12 | `SelectionVectors_WrittenByTheReference_DecodeAndEvaluateIdenticallyInRust` (Rust reads `selection-vectors.cs-written.json`) and `SelectionVectors_WrittenByRust_DecodeAndEvaluateIdenticallyInTheReference` (C# reads `selection-vectors.rs-written.json`) | **parity** | any semantic drift on either side | a vector whose expected ids are compared as a set (order lost) — the vectors carry ordered ids and `next` presence | -| S13 (Rust) | `selection_is_never_untagged` (grep over `packages/cultnet-rs/src` for `untagged`, empty) and `selection_round_trips_named_and_positional` (both encodings) | the encoding rule | an `untagged` shape added | — | -| S14 (Rust) | `select_over_a_toy_row_set_matches_orders_hops_pages_and_refuses` | D7, no CultCache knowledge in the evaluator | as S1-S8 | as S1-S8 | -| S15 | `Mesh_ReadsASnapshotPageWithoutReFilteringIt` (Mesh tests) | the client trusts the page | the four-tier fallback restored | schema re-filter restored | -| S16 | `Evaluator_ComparesNumbersAtTheBoundaryForEachOfTheFourOperators` | D8's operators; one operator per predicate; two predicates make a range | the comparison ignored (every row matches) | `le` evaluated as `lt` (and `ge` as `gt`) — **kills only if the fixture carries a row whose `mass` equals the compared number exactly**; the fixture must, and if it does not, that is the fixture's defect, not licence to mutate something easier | -| S17 | `Evaluator_AppliesAnInheritedAliasToEveryReachableLeaf` | the inheritance rule; the cache's `BaseType` walk reaching the evaluator | the walk stopped at the leaf (`CultCache.cs:847`) — kills S17 and S12 together | "reachable" narrowed from *every* leaf matching `schemas` to the first descriptor consulted, so a two-leaf selection on an alias declared only on the abstract middle answers with one leaf's rows and no refusal — **requires the fixture to declare `mass` only on the middle and to carry rows of both leaves** | -| S18 | `Evaluator_ResolvesAnAbstractTargetToItsLeavesAndRefusesAnEdgeOutsideThem` | D9; the typed `reference_outside_target` | the target set taken as `{TargetSchemaName}`, which is `null` for an abstract target, so the hop follows nothing | the out-of-target edge **skipped** instead of refused — the option section 3 rejected, and the mutant that proves the rejection is load-bearing; **requires the fixture to carry one edge pointing at a schema outside the declared target** | -| S19 | `Cache_EnumeratesADictionaryReferenceAsEdgesCarryingItsValues` (cache tests) | D11 | `ReferencesOf` yields nothing for a dictionary — today's behaviour, restored | the keys enumerated and the **values dropped** (`payload` null), which is the shape a careless `IEnumerable` implementation would have; **requires two dictionary entries with different payload bytes**, or the assertion passes on shape alone | -| S20 | `Evaluator_ReturnsTraversedEdgesOnlyForHoppingSelections_AndNoPayloadUnderHeader` | the `edges` rule | `edges` never emitted | payload carried under `header`; and, separately, `edges` emitted for a selection with neither `cites` nor `cited` — a widening rather than a loosening, and killed by the same test | -| S21 | `Cache_RefusesADuplicateIndexAliasAndAnUnwalkableReferenceByName` (cache tests) | D10, D11's shape rule | the rejections removed — **kills only if the test asserts the message names the schema, the two members and the alias**, since without them `ToDictionary` still throws an `ArgumentException` and a shallow `Assert.Throws` passes either way. That is the entire content of D10 | the alias grouping keyed by `MemberName` instead of `IndexAlias`, which refuses nothing the existing `HiddenMemberMessage` (`:937-943`) did not already refuse | -| S23 | `Evaluator_MatchesEveryRowSharingAnIndexValue_NotTheCacheIndexWinner` | the evaluator reads values per row, never `_indexes` / `GetByIndex` | the predicate answered from `GetByIndex`, so an `any_of` over a value **three** fixture rows share answers with one | the per-row read kept but short-circuited after the first match when `matched` is all the caller asked for — a plausible optimisation that makes `matched` a boolean in disguise; **requires three rows sharing one index value**, which the fixture must carry | -| S22 | `Cache_DerivesIsNumericFromTheClrSetAndTheTypeNameSetAgrees` (cache tests) | D8's two authorities agreeing | `IsNumeric` true for every member, so a comparison on a string alias validates and compares `ToString()` lexically | the `TypeName`-string set hand-written and missing `System.Nullable` or `System.Decimal`, so a member is numeric against the live descriptor and non-numeric against the same schema read back from a persisted catalog — **requires the fixture to declare a nullable numeric member and a `decimal` one** | - -**On the parity vectors and the four findings.** S12 is not a new test but -a widened one: the fixture of section 6 carries the abstract middle, the -two leaves, the inherited numeric alias, the dictionary reference with -float payloads and the abstract-target reference, and the vector set -includes at least one selection per finding — a comparison on the -inherited alias, a hop into the abstract target, a hop over the dictionary -returning payloads, and the same hop under `header`. Both directions: -**vectors written by the reference are evaluated in Rust, and vectors -written by Rust are evaluated by the reference**. A round trip inside one -runtime pins nothing and is not counted. The Rust side's fixture rows -implement `Row` over the same declared values, so the vectors test the -*semantics* both ways while each runtime keeps its own source of rows. - -**Negative greps:** `rg -n "GetProperty|GetField|GetCustomAttribute" src/GameCult.Networking` -empty (the evaluator reflects over nothing); -`rg -n "untagged" packages/cultnet-rs/src packages/cultcache-rs/src` -empty; `rg -n "InferSchemaName" src/GameCult.Mesh` empty and -`src/GameCult.Networking` only in `CultNetSchemaAliasMatching` and the -registry's payload-schema resolution; `rg -n "MatchesRequestedSchema|MatchesSchema\(" src` -empty; `rg -n "_projectRecord|projectRecord" src` empty; -`rg -n "SchemaIds = .*RecordKeys = " src/GameCult.Mesh` empty (no -allowlist pair assembled by hand); `rg -n "CloneSnapshot(Facade)?RequestOptions" src` at most one. - -**Mutations.** `tools`-style entries in `scripts/mutate-cultmesh.mjs`'s -format with three new targets, `caching` (killer `dotnet test -tests/GameCult.Caching.Tests`, carrying S19, S21 and S22), `networking` -(killer `dotnet test tests/GameCult.Networking.Tests`) and `rust` (killer -`cargo test -p cultnet-rs`), -one entry per rule above with revert and loosening; the runner's -byte-rewrite/verified-restore discipline applies unchanged. The runner is -the QUIC campaign's file and is dirty in the tree; **this campaign adds its -entries in its own file** (`scripts/mutate-cultnet-selection.mjs` or an -entries module the runner imports) and does not edit the QUIC campaign's -targets — Self decides the exact split at landing. - -## 11. The CultMesh subtraction audit, with real numbers - -Method: an Explore pass classified 92 sites in `src/GameCult.Mesh` and -`CultNetDocumentRegistry.cs` as (A) mechanical selection/projection over -schema ids, keys or fields, or (B) a domain rule (authority, freshness, -transport, session). (A) totalled **1,158 lines**, (B) **429**. **(A) is not -a reduction figure**; it is what *could* be expressed as a selection. What -collapses is what has a replacement in this cut. - -| Category | Sites (audit #) | Lines now | After | Net | -|---|---|---:|---:|---:| -| Selector-carrying options cloned five times | #11, #3, #74, #75, #77 | 128 | ~30 (`with` on a record) | **−98** | -| "No schema filter when keys are given" rule, three times | #65, #73, #10 | ~16 | ~4 | −12 | -| `CleanSnapshotFilter` | #79 | 11 | 0 | −11 | -| Client-side re-filter of a snapshot by schema, plus `InferSchemaName` | #80 (part), #81, #82 | ~51 | ~10 (decode only) | −41 | -| Four-tier read fallback + decode gate | #12, #13 | 50 | ~10 | −40 | -| `IsSameCultDocumentSchema` | #22 | 24 | 0 (public matcher) | −24 | -| Selector literals in client bindings | #32, #34, #35 | 93 | ~68 | −25 | -| Hot-body subscription allowlists | #25 | 45 | ~30 | −15 | -| Peer snapshot / body subscribe selector construction | #10, #14, #15 | 81 | ~61 | −20 | -| Snapshot facade overlay logic | #64, #66-70, #72 | ~110 | ~70 | −40 | -| **Collapses in this cut** | | | | **≈ −326** | -| Local cache/database watches by name and index and their predicates | #2, #4-9, #16-21 | ≈ 240 | ≈ 240 | 0 in this cut; **≈ −378 in Cut 2** (section 18.3 — larger than this audit estimated, because the nine `Collection*` overloads at `CultMesh.cs:2278-2480` were not classified as selection and are) | -| Handle catalogs by type/schema, surface catalog, operation payload | #41-49 | 233 | 233 | 0 — these index *handles*, not records | -| Verse discovery selection and reshaping | #36, #38 (part), #39, #40, #83, #85 | ~110 | ~110 | 0 — over Verse observations, not documents | -| Single-file catalog membership | #58-62 | 81 | 81 | 0 — file-format membership | -| Provider session wire decode gate | #50 | 12 | 12 | 0 | -| (B) sites | 24 sites | 429 | 429 | 0 by definition | - -**Plain statement.** `src/GameCult.Mesh` shrinks by about **326 lines of -21,720 (1.5%)** in this cut, and a further **≈ 378 in Cut 2** — the two -together are about 3.2% of the assembly, and Cut 2 is where the audit's -expectation of a real reduction is met. About 590 of the -audit's (A) lines stay because they select handles, Verse observations or -file-catalog entries, not database records. The operator's expectation of a -net reduction in CultMesh holds, modestly; it does not hold for CultLib as a -whole in this cut, because the Rust runtime is new capability (section 14). -If the expectation was a large reduction, this audit says it is not there -without the watch cut, and it is not there at all for the catalogs and -discovery. - -## 12. Huginn: the consumer cut, and what moved - -**Moves to CultLib (this map):** `Selection`, `Citation`, `Incoming`, -`Projection`, `Page`, the cursor, `select`, the `Row`/`RowSet` traits, the -untagged rule and its probe, the cap, the order rule and the snapshot -contract. Huginn's earlier `select.rs` is never written. - -**Stays with the organ (a smaller, later cut in the Epiphany campaign map, -against a `cultnet-rs` dependency Huginn already has):** - -- `impl Row for PipelineDocumentView`: `ordinal = admission.sequence` - (Q-B A, still recommended; under Q-B B Huginn refuses `cursor_stale` on - advance exactly as the reference does, which is now a legitimate second - option), `values(index)` for the declared aliases `campaign`, `repo`, - `cut`, `kinds`→(schema), `in_force`, `faculty`, `severity`, `confidence`, - `origin`, `authority`, `claim_outcome`, `outcome`; `references()` from - `docs::citations` with the fifteen roles; `RowSet::declared_*` as the - organ's closed lists, so an unknown alias or a value outside an enum - refuses typed. -- `docs::citations`, `CitationRole`, `ResolutionOutcomeKind`, the summary - as the organ's header, the `as_of` restriction of the image to receipts, - the semantic refusal, the ref validation of `cites.target` through the - leaf, and the deletion of `open_items`/`history` with the relation table - unchanged (`history(Subject)` = `kinds: [resolution], cites { target, role: subject }`; - the open lists = five selections). -- **Deleted from the organ's wire (R-3, ruled, not proposed):** - `PipelineQuery`; **`admitted_after` and `admitted_before`**, which existed - only to approximate an order and are replaced by `descending` plus the - cursor — they are a delete in the Huginn cut's own deletes-first table, - with their query-builder call sites, their schema fields and the tests - that pin them, not a deprecation; and `QUERY_LIMIT_MAX`. Huginn's `Query { instance, selection }` carries - the substrate's type; its published request schema embeds `Selection` - through a `#[schemars(schema_with)]` attribute that returns a `$ref` to the - published selection schema. A hand-written impl fails with `E0117` - (corrected 2026-09-22), and the substrate publishes JSON, not `schemars`. It - is pinned by the - existing byte-for-byte test. -- Cut 11's lowering to Qdrant and the after-index application of the hop, - as before. - -The Huginn cut is ordered **after** this cut's commit 3 (Rust) and before -Cut 11 and Cut 13, as 10b was. - -## 13. Deliberately not possible, and why - -OR across predicates (ask twice); negation beyond `cited { exists: false }`; -a predicate on the far end of the hop; a second hop; ranges, dates, text, -paths, sort fields, counts beyond `matched`; a page not tied to `asOf`; -selection across shards or minds; a cursor the caller constructs. Each for -the reason the earlier specification gave, unchanged. - -Named individually, because each has an obvious-looking one-line -implementation and a caller who will ask for it: - -| Not expressible | The obvious ask | Why it is the door | -|---|---|---| -| **A predicate on an edge's payload during traversal** — "components whose quantity is over 4", "demand profile entries weighted above 0.5" | one `if` inside the hop loop, over a payload the evaluator is already holding | **It is a join condition.** The moment a predicate can read a value that belongs to neither the row nor the selection, the evaluator is combining two sources under a condition, and everything that follows is a query planner: which side to drive from, whether to build the incoming-edge index before or after filtering, how `matched` counts a row whose only surviving edge was filtered, what the cursor means when the page is a join result, and whether the payload's type is comparable at all when its declared type differs per leaf. None of that is answerable in a bounded vocabulary. **The caller follows the edge, receives the payload in `edges`, and filters the result** — one extra step for the caller, an entire category of machinery this organ never grows | -| **A set of exact `(schema, key)` pairs** | `keys` already exists; let an entry name its schema | `schemas` × `keys` is a cross product and stays one. `CultNetRawSnapshotQuery` (section 1) shows the cost honestly: it over-requests and verifies. The fix is either a second list shape or a pair type on the wire, and both are the beginning of a term language. A caller who needs exact pairs asks once per schema, or accepts the cross product and verifies against what it declared. Recorded as Q-M, recommendation A: state the limit | -| **A predicate that depends on how many edges a row has** — "hulls with more than three components" | `cited` already counts to one | `exists: true/false` is a boolean, and a threshold is a range over a derived aggregate. Aggregation is a second evaluator with its own ordering and paging semantics | -| **`edges` without rows, or rows the hop excluded** | a `projection: "edges"` | the page's rows are the answer and the edges explain it; an edge-only page is a different question (a graph read) and would need its own order, cursor and bound | - -## 14. Subtraction ledger (estimate) - -| | Removed | Added | -|---|---:|---:| -| `src/GameCult.Caching` | 0 | **~150**: the public member view ~40, `TryGetIndexValue` / `TryGetIndexNumber` + the CLR numeric set ~35, `ReferencesOf` over the three shapes ~40, `ResolveTargetLeaves` ~10, two rejections with their messages ~20 | -| `src/GameCult.Networking` | 368 | ~400 (`CultNetSelection` + `CultNetEdge` + the operator 170, evaluator + cursor + the incoming-edge index 190, ordinals 20, v1 classes and lowering ~40 minus shared) | -| `src/GameCult.Mesh` | ≈ 365 gross | ≈ 40 (one `WithSelection`, one exact read) | -| `packages/cultnet-rs/src` | ~31 (`snapshot_query.rs`'s filter) | ~430 (`selection.rs` 390 with edges and numbers, `contracts.rs` 40) | -| `packages/cultnet-py`, `cultmesh-py` | 0 | ~5 (the `unsupported_schema_version` reply, ruled into this cut) | -| `contracts/cultnet` | 0 | 4 schema files (~200 lines JSON), 1 interop row schema, 2 vector files (generated) | -| tests, C# | ~250 rewritten | ~600 (S1-S12, S15-S18, S20 in Networking; S19, S21, S22 in Caching; the vector writer) | -| tests, Rust | 0 | ~340 (S12-S14 with the edge and number vectors) | -| entries | 0 | ~38 entries across three targets | -| docs | — | this map; two paragraphs | -| **net source outside tests** | | **≈ +32 in C# Networking, +150 in C# Caching, +399 in Rust; ≈ +585 across the repo** | - -**The estimate moved, and in the wrong direction.** The committed version of -this ledger said ≈ +370 and ≈ −18 in C#. Both were wrong, for one reason: -the map assumed the cache needed no change, and the cache needs about 150 -lines of new public surface before the evaluator can read a single declared -value from outside the assembly (section 1). The honest figure is ≈ +585. -What the repo buys for it is unchanged in kind and larger in size: one -evaluator where **five** stood (three in C#, one in Mesh's re-filter, one -in Rust), a read surface where the substrate's own consumers were -reflecting over documents, edges and their payloads where a declared -reference was unreadable, numeric comparison over a type the catalog has -persisted all along, and a Rust runtime with the capability at all. Nothing -here is a reason to shrink the cut; it is a reason not to claim the cut -shrinks the repo. - -The repo grows by about 370 source lines for: a typed selection with a -derived-by-vector schema where two allowlists were the only language; -projection as a value where it was a delegate no caller reached; an exact -snapshot cursor where paging did not exist; one evaluator where three -engines and six schema matchers stood; a Rust runtime that did not have the -capability at all. CultMesh proper shrinks ≈ 326. The liability retired is -not in the line count: five copies of one options record, four spellings -of schema identity, a client that re-filtered what the server answered and -a read that could return the wrong key. - -## 15. Build budget - -C#: **three** assemblies and three test projects (`GameCult.Caching` joins -the budget; it is a dependency of the other two, so touching it rebuilds -them), debug, workstation. Rust: -`cultnet-rs` lib + tests, warm target dir, +0 to +60 paths. Nothing cleaned. -No native, no TypeScript build beyond one interop-lane run. - -## 16. Operator questions, one batch - -**Ruled 2026-09-17 and no longer questions.** Four of the batch are -settled; they are recorded here as rulings and carried in the sections -they govern. - -- **R-1 (was Q-F). v0 retires on a named trigger, not "eventually."** The - reference keeps answering v0 by lowering it into a `Selection`, with no - engine of its own. That lowering is a **scheduled deletion**: it is - deleted when FU-TS, FU-Py and FU-Kt have all landed and the interop lanes - send v1, and that is FU-v0's trigger in section 9. A shim with no - deletion date is a permanent shim; this one has a date written as a - condition, and the condition is checkable by grep over the three - runtimes' contract files. Section 4's "not deleted in this cut" row and - section 5's keeps both point here. -- **R-2 (was the second half of Q-F). Python's silent drop is fixed in this - cut.** Every other runtime refuses a message it does not understand; - Python returns `[]` (`interop_peer.py:387`) and the caller reads a hang - rather than a rejection. The five lines that reply `cultnet.error.v0 - { code: "unsupported_schema_version" }` land here, ahead of FU-Py's own - trigger, because a silence is a worse failure than a missing feature. - Carried in section 9's FU-Py entry and section 14's ledger. -- **R-3 (was Q-G). `descending` is the order; the time windows go.** - `admittedAfter` / `admittedBefore` existed to approximate an order the - vocabulary now states outright, and two filters standing in for an order - are worse than the order. They are deleted from Huginn's wire in the - consumer cut (section 12), not kept as an organ-side alias any-of. "The - latest N" is `descending, limit: N`. -- **R-4 (was Q-I). The fixture is chosen for coverage and expected to - grow.** The vector row type is the dedicated - `cultnet.interop-selection-row` family of section 6, shaped to *exercise* - the four hostile findings rather than describe them: shared fields on an - abstract middle with two leaves under it, a reference whose declared - target is that middle, and a many-reference carrying a payload. It is - **not pinned as final** — the fixture is expected to gain rows and - selections as the vocabulary meets more catalogs, and a change to it is a - normal change, not a contract break. What is pinned is the parity - obligation over whatever the fixture holds: both directions, ordered ids, - `matched`, `next` presence, and the edges with their payloads. -- **R-5 (was Q-H). Watching a selection is its own cut, mapped in section - 18 of this document**, after this one and depending on it. Nothing of it - is smuggled into the selection cut and nothing here is designed around it - speculatively; the seam already drawn — one selector type serving both a - query and a subscription — is all this cut owes it. - -**Still open:** - -- **Q-A. Leaf `Short` titles for question and ruling** (Huginn/Epiphany). - Unchanged: **A, not now** (recommended); B, a leaf follow-up before Cut 13; - C, a text projection (Cut 11's question). Depends: the organ's header row - for those two kinds. -- **Q-B. Huginn's receipt ordinal.** **A, record it** (recommended; exact - order, no `cursor_stale` on an append-only mind); **B, do not**: Huginn - refuses `cursor_stale` on advance like the reference does — now a - legitimate option under one contract, costing a re-walk on a busy mind - and id-order ties within a second. Depends: Huginn's consumer cut only. -- **Q-C. Delete `open_items` and `history`.** **A, delete** (recommended); - B, thin presets in Huginn; C, wire operations (refused). Depends: Cut 13's - tool list. -- **Q-D. The representation.** **A, declared-alias any-of over strings in a - new v1 selection** (recommended; D1, D2); B, Qdrant's `Filter` (rejected, - section 3); C, typed enums on the wire (impossible in the substrate: it - cannot know a consumer's enums; the organ keeps the typed refusal at its - door). Depends: everything in sections 2-10. -- **Q-E.** Withdrawn: the seam question is answered by the placement ruling. -- **Q-J. Number encoding on the wire. RULED B, decimal strings, - 2026-09-17.** The operator: floats are fragile, and strings are the most - reliable way to store a number. **Self's recommendation of A was argued - backwards and the map said so wrongly.** B was framed as "a second value - encoding beside `any_of`'s strings"; it is the opposite. Every value in this - vocabulary is already a string, so decimal strings keep the wire to **one** - value encoding, and it is float64 that would add a second. What B really - costs is a comparison rule, and that is where the danger sits: a decimal - string compared lexicographically answers that ten is less than nine. So - the cut must specify a canonical decimal form and a numeric comparison, - and pin both with mutants — a lexicographic comparison must die, and a - non-canonical spelling of an equal value must not change an answer. Name - the edge cases the canonical form settles: leading and trailing zeros, an - explicit plus, exponent notation, and negative zero. No exactness limit - needs stating, because there is no longer one. - - *Superseded, kept as history:* **A, IEEE float64, with the 2^53 - limit stated in the schema and in this map** (recommended: one encoding, - no parse rule per runtime, and no catalog in hand declares an integer - member wide enough to lose). **B**, decimal strings — exact, but a second - value encoding beside `any_of`'s strings and a per-runtime parse rule, - and two ways to spell a number is how a vocabulary starts growing terms. - **C**, add `integer: int64` beside `number` now, sharing `op` — additive - and untagged-free, and can be added later without breaking A, which is - why adding it now is premature. Depends: the schema files and both - runtimes' predicate types. -- **Q-K. Family names for abstract bases on the wire. PARKED, 2026-09-17, - and Self's reason was wrong.** The operator: this was possible when - CultCache was a single file, so it is odd that it is hard now; not - load bearing either way. That is correct. In-process a family is one - assignability check, because the runtime holds the hierarchy. Over the - wire there is no type system, only schema names, and an abstract type - carries no `[CultDocument]` so it never gets one. **The hardness is not - modelling, it is that nothing publishes the hierarchy where a remote - caller can see it.** The honest cost is therefore a catalog addition, - each leaf's entry naming its base chain, after which a family is the - leaves whose chain contains the name asked for. Small work — but it moves - the catalog's shape, and this cut's whole claim to being cheap is that it - moves neither the catalog's shape nor its hashes. So it is **deferred to a - cut that is already touching the catalog**, not deferred because there is - nothing to name. - - **Trigger named, 2026-09-17: the source-generator campaign needs it first.** - The operator's position is that any project using CultNet wants the types it - touches native, and that where the hierarchy is not being declared in - parallel by hand, a generator should build it. That generator runs against - **published schemas**, with C# authoring and the schema as the contract, - which is what the reference-runtime rule already says. - - The obstacle is the same missing fact. `CultCache.cs:847,887,966` walks the - base types while collecting members and then discards the hierarchy, and - `ToCatalogEntry` (`:152-177`) publishes a schema id, the canonical schema, - the compatible ids and a flat member list carrying slot, name, type, - reference-ness, many-ness, target and alias. **Nothing records what a type - inherits from.** So a generator fed published schemas alone emits one flat - class per leaf with the inherited members copied into each — exactly the - hierarchy the operator wants built for them, and unbuildable from what is - published today. - - So the base chain in the catalog is a **prerequisite of the generator - campaign**, and family names fall out of it for free once it exists. Until - then a family expands at the call site into its leaf schema names, which the - generator can emit as a constant, costing the protocol nothing. The honest - limit of call-site expansion, to be stated wherever it lands: it is - closed-world per compilation, so a leaf declared where the caller does not - compile leaves the list silently short, and an open-world caller must ask the - catalog at run time instead. - - The generator is **its own campaign, mapped after this cut lands** (operator, - 2026-09-17: "definitely later"), because its value is emitting against a - shape that is settled and this cut is what settles it. It is also where the - query sugar comes from: typed per-kind builders make filtering a document by - another kind's field a compile error rather than an empty page. **No source - generator exists in CultLib today** — no Roslyn generator anywhere in the - repository, and `src/GameCult.Caching/emitted/` is empty. - - Superseded reasoning, kept as history: - an abstract type has no `[CultDocument]`, no schema name - and no catalog entry, so there is nothing to name; a caller who wants - "every crafted item" enumerates the leaves from the schema catalog it - already reads. **B**, a `[CultDocumentFamily]` declaration plus a catalog - concept plus a matching rule in every runtime, in a later cut. Note this - is only about the `schemas` list: the *hop* already resolves an abstract - target to its leaves (D9), so the hostile example works under A. -- **Q-L. Refusing an out-of-target reference at write time. RULED A, - 2026-09-17**, with the follow-up upgraded from optional to intended. The - operator: notice on read for now, but on write eventually; today this is - left to consumers to enforce, as Aetheria's Database Tools did and as - CultCache Studio is expected to, and it is easy to insert garbage at the - source level which a cheap check would prevent. - - **Two things make the follow-up worth more than "nice to have."** First, - enforcement living in consumers is duplicated authority by construction: - every tool that writes records re-implements the same check, and a tool - that forgets is indistinguishable from one that does not, until a read - refuses. Second, the check really is cheap — the declaration already names - the target (`TargetSchemaName` on the member catalog entry), and the write - path already has the record in hand. - - **What Self could establish about the consumer story, 2026-09-17:** it - rests on less than it sounds. There is no CultCache Studio repository - under `F:\Projects`, and no project file by that name anywhere; every - reference to it is forward-looking, in Aetheria's migration documents, - where it is described as absorbing that project's Database Tools. Aetheria's - actual data tooling today is the `tools/AetherDb` console project. So the - enforcement being relied on is one tool, in one project, for one catalog, - and there is no second implementation to be inconsistent with yet — which is - the cheapest moment to move the rule to its owner rather than the most - expensive. - - Original options: **A, follow-up (FU-Ref)** (recommended): this cut refuses it at read - (`reference_outside_target`, S18), which is where the corruption is - noticed; refusing at write touches the cache's put path and every - runtime's apply path and is a change of a different size. **B**, in this - cut. Under A, the read-side refusal is the only thing standing between a - corrupt edge and a wrong answer, which is why it refuses instead of - skipping. -- **Q-M. The `(schema, key)` cross product. RULED A, 2026-09-17**, in the - operator's words: once per schema. The cross product stays, the limit is - stated, and a caller needing exact pairs asks once per schema. No third - addressing list and no rule for combining three of them. - Original options: **A, state the limit** - (recommended): `schemas` × `keys` stays a cross product; a caller needing - exact pairs asks once per schema or verifies what it declared, as - `CultNetRawSnapshotQuery` already does. **B**, allow a `refs: - RecordRef[]` list beside `keys` — expressible without a term language and - genuinely useful, at the cost of a third addressing list and a rule for - how three lists combine. **C**, let a `keys` entry carry a schema — a - path language in miniature, refused. Depends: section 13's table and - `snapshot_query.rs`'s client. - -**Q-A, Q-B and Q-C are no longer open.** The operator ruled all three on -2026-09-17 and they are recorded in the Huginn campaign's map, -`F:\Projects\Epiphany\notes\eureka-pipeline-state-cut.md`: receipt ordinals -yes; `open_items` and `history` deleted rather than kept as presets; and the -leaf gains titles on `question` and `ruling`, riding the same store version -bump as the ordinal so there is one migration rather than two. They are named -below only because work here still depends on them. - -What depends on each: S1-S8 on Q-D A; S5 on the shard-log assumption -(section 1) and on Q-B for Huginn; S9 on D6; S16 and the schema files on -Q-J; S18 on Q-L A; the Huginn cut on Q-B and Q-C. Settled by R-1 to R-5: -S10, S12, the fixture, FU-v0's trigger, FU-Py's five lines, Huginn's -window deletion, and the watch cut's existence. - -## 17. What was probed, read, and not settled - -**Probed:** the untagged round trip (section 2). **Read:** every line -anchored above; the CultMesh audit's 92 sites by an Explore pass whose -classification I checked at the sites that carry the ledger (the five -clones, the four matchers, the fallback, the two engines). **Not run:** -`dotnet` or `cargo` on CultLib — the tree is dirty from the QUIC campaign -and no claim here depends on a build; Hands takes the baselines named in -Pins. **Not settled:** whether every committed write has a shard-log -sequence (section 1, Hands confirms at the append site); the exact split of -mutation entries against the QUIC campaign's runner (section 10, Self at -landing); whether MessagePack-CSharp needs a union attribute for the -page's `headers | documents` (it does not if both are optional arrays with -exactly one present, which is the spelling chosen so no union is needed — -Hands confirms the schema says "exactly one" with `oneOf` over `required`). - -**Read on the second pass, and what it changed.** Each of these was read at -the source, and three of them contradicted something this map or its -predecessor's notes asserted: - -| Read | Held | Changed | -|---|---|---| -| `CultCache.cs:474`, `:151-176`, `:598-629`, `CultSchemaTypeNames.cs:9-31` | **Yes.** A member's type name is derived from the CLR type, persisted in the catalog entry and compared inside the canonical shape. Numeric addressing needs no new declaration, no hash movement and no migration — the load-bearing claim of this whole widening | — | -| `CultCache.cs:112-150` | — | **No.** `Members` and `IndexAccessors` are `internal` with no friend assembly, so the evaluator cannot read a declared value from outside `GameCult.Caching` at all. "No cache change is needed for addressing" was false; the cache add is ~150 lines, not ~60, and the ledger moved from ≈ +370 to ≈ +585 | -| `CultCache.cs:931-943`, `:462-468` | — | **No.** A hiding member is already refused (`HiddenMemberMessage`), and a duplicate alias already throws from `ToDictionary`. D10 shrank from "a missing refusal" to "a refusal that names nothing", and its test only means anything if it asserts the message | -| `packages/cultnet-rs/src/snapshot_query.rs` | — | **No.** Rust has a fifth selector engine (`serve_read_only_raw_snapshot:112-125`) that "Rust: none" concealed, and its client (`:195-217`) shows the cross-product limit that v1 does not fix (Q-M) | -| `CultDocumentContracts.cs:92-99`, `CultCache.cs:1100-1113`, `:1495-1509`, `:1818-1838`, `:254-265` | **Yes.** `ICultRecordRef` is the enumerable handle D11 needs; assignability is already how the cache resolves a base type to concrete documents (D9); `AllDescriptors` is public | — | - -**Still not settled, and who settles it:** - -- Whether every committed write takes a shard-log sequence (unchanged; - Hands, at the append site). -- Whether `CultDocumentDescriptor`'s public member view can be a readonly - struct without forcing an allocation per row per predicate in the - evaluator's inner loop. Nothing here depends on the answer being fast, - but a `Select`-per-row over a fresh list would be a silly cost to build - in; Hands measures if a fixture of a few thousand rows is slow, and not - before. -- The exact split of mutation entries against the QUIC campaign's runner - (unchanged; Self at landing). -- Whether `serve_read_only_raw_snapshot`'s existing tests survive the - lowering unchanged; they should, since the behaviour is identical, but - they were not read line by line. -- Q-J, Q-K, Q-L, Q-M: the operator. - -**Not probed, and deliberately:** nothing was built or run against CultLib. -The tree is hosting a live QUIC campaign, and no claim in this map needs a -build to be true — every one of them is a source read, and the three that -were wrong were wrong because the first pass did not read far enough, not -because a build would have caught them. - ---- - -# Cut 2: watching a selection - -Ruled 2026-09-17 (R-5): the operator wants this and wants it soon, so it is -mapped here rather than left as a follow-up trigger. It is **its own cut**, -landing after Cut 1 and depending on it. Nothing below is smuggled into Cut -1, and Cut 1 is not shaped around it: the seam Cut 1 draws — one selector -type serving a query and a subscription alike — is the whole of what Cut 1 -owes this one. Cut 1 can go to Hands without waiting for a ruling on -anything here. - -Same discipline as Cut 1: facts by source read, deletes first, an authority -map, verification with a mutation per rule including a loosening, and a -subtraction estimate with real numbers. - -## 18.1 Body facts - -**The local watch surface today** (`src/GameCult.Networking/CultNetDatabase.cs`): - -- `Watch()` `:1051-1057` filters the change stream by CLR type; - `WatchAllChanges()` `:1063-1067`; `WatchRecord(key)` `:1072-1075`; - `WatchGlobal()` `:1080-1083`; `WatchByName(name)` `:1088-1094`; - `WatchByIndex(alias, value)` `:1100-1107`. -- **`WatchByName` and `WatchByIndex` do not do what they are named.** Both - read the cache's *current single winner* for the lookup and compare it to - the change's document by `ReferenceEquals`. Combined with section 1's - finding that the cache's index maps hold **one key per value**, this - means `WatchByIndex("kind", "cannon")` observes changes to - whichever single `Weapon` most recently claimed `cannon`, not to the - weapons whose kind is `cannon`. It also re-runs the lookup *at change - time*, so a change that clears the value is evaluated against the state - after the clear. The same two lines exist again in CultMesh as - `MatchesName` / `MatchesIndex` (`CultMesh.cs:3312-3333`). -- The rule is set-dependent even without a hop, and nothing tracks - membership: there is no "was in the set, now is not" anywhere in the - local path. `Reconcile` (`CultNetDatabaseSubscriptionServer.cs:251-292`) - is the only membership tracker in the reference and it belongs to a - server, over a delivered set, per peer. - -**The CultMesh collection surface** (`src/GameCult.Mesh/CultMesh.cs`): nine -`Collection*` overloads at `:2278-2480` — three shapes (all / by name / by -index) by three sources (`CultCache` `:2278`, `:2302`, `:2331`; -`CultNetDatabase` `:2362`, `:2386`, `:2413`; `CultMeshNode` `:2442`, -`:2456`, `:2471`, which delegate to the database trio in three lines each). -`CollectionByIndex` over a cache resolves its contents as -`Optional(cache.GetByIndex(alias, value))` — a "collection" of -**at most one row**, whose live feed then filters by `MatchesIndex`. Three -shapes exist because the shape *is* the query language; a selection -replaces the shape axis entirely and leaves the source axis alone. - -**What Cut 1 leaves in place for this cut:** `CultNetSelection` and its -validation, `CultNetSelectionEvaluator.Matches(selection, descriptor, key, -document)` for a single row, the cache's public read surface, and D6's rule -that a hop-bearing selection is set-dependent and must reconcile rather -than decide per change. - -## 18.2 The shape - -``` -CultNetDatabase.Watch(CultNetSelection selection) - -> Observable - -CultNetSelectionChange { - kind: Entered | Left | Updated // membership, not storage - schemaId, recordKey - document: object? // present on Entered/Updated under `document` projection - previous: object? // present on Left/Updated - cause: Self | Edge // this row changed, or a row it cites / is cited by did -} -``` - -**Rules, each one owner:** - -- **Membership, not mutation.** The stream reports a row entering the - selection, leaving it, or changing while inside it. A write to a row that - was outside and stays outside emits nothing. A write that changes a value - the selection filters on emits `Entered` or `Left`, **not** `Updated` — - which is precisely what `WatchByIndex` cannot express today. -- **`cause: Edge`** is how a hop-bearing selection stays honest: when a - change to row B moves row A in or out, the subscriber is told about A, - with the cause marked, and never about B unless B is itself in the set. -- **No order, no cursor, no `asOf`, no `limit`.** A live feed is not a - page. A selection carrying `limit`, `cursor` or `descending` is **refused - at the door** (`selection_invalid { field }`) rather than silently - ignored: "the latest ten, live" is a different question with a different - answer (it needs an eviction rule), and this cut does not answer it. - `projection` is honoured; `schemas`, `keys`, `fields`, `cites` and - `cited` are honoured. -- **The initial set is not a snapshot message.** `Watch` is local and - in-process; it emits `Entered` for every row already matching, at - subscribe time, under the cache's lock, before any live change. There is - no `asOf` because there is no wire and no second reader. -- **A hop-bearing selection recomputes; a plain one decides per row.** - Without `cites`/`cited`, membership is `Matches(selection, row)` and - costs one row's read. With a hop, a change anywhere can move anything, so - the watch re-evaluates the selection over the cache and diffs against the - membership set it holds. That is O(rows) per change and it is stated - plainly rather than hidden (Q-N). - -## 18.3 Deletes first - -| Path:lines | Lines | What | -|---|---:|---| -| `CultNetDatabase.cs:1080-1107` | 28 | `WatchGlobal`, `WatchByName`, `WatchByIndex` — the three that encode a query in a method name, two of them wrongly. `WatchGlobal` lowers to `keys: ["global:{schemaId}"]`, the other two to a `fields` predicate | -| `CultMesh.cs:3312-3333` | 22 | `MatchesName`, `MatchesIndex` — the same `ReferenceEquals`-against-the-winner rule, second copy | -| `CultMesh.cs:2278-2480` | 203 | the nine `Collection*` overloads; **after: three**, one per source (`CultCache`, `CultNetDatabase`, `CultMeshNode`), each taking a `CultNetSelection`, about 75 lines | -| `CultMeshSnapshots.cs` and `CultMesh.cs` local-watch predicates, audit sites #2, #4-9, #16-21 | ~240 | the remaining local watch loops and their inline predicates, about 40 after | -| **Total** | **~493** | **~115 after; net about −378** | - -`Watch()`, `WatchAllChanges()` and `WatchRecord(key)` **stay**, as -Cut 1's v0 lowering stays: a CLR-type filter and a single-key watch are -conveniences that lower to a selection and have no rule of their own. If -they grow a rule again, W6 catches it. - -## 18.4 Adds and authority - -| Add | Owner | Live consumer | Protected invariant | Replaces | -|---|---|---|---|---| -| `CultNetSelectionWatch` in `GameCult.Networking` (~130 lines): the membership set, the per-change decision, the hop recompute, `CultNetSelectionChange` | `CultNetDatabase` | Mesh's three `Collection` overloads; any local consumer that watches a question | membership is tracked in one place, and a row that leaves the set is reported leaving | `WatchByName` / `WatchByIndex` / `MatchesName` / `MatchesIndex`, four spellings of one wrong rule | -| `CultNetDatabase.Watch(CultNetSelection)` (~15 lines) | `CultNetDatabase` | the above | one entry point | three named-query methods | -| Rust: **nothing in this cut** | — | — | — | no Rust caller watches locally; when one does, it gets the same shape over `Row`/`RowSet` | - -- **Owner.** `CultNetSelectionWatch` owns membership and only membership. - `CultNetSelectionEvaluator.Matches` owns whether a row is in the set — - the watch never re-implements a predicate. `CultNetDatabase` owns the - change stream. The cache owns values, as in Cut 1. -- **Inputs.** The change stream, the cache's rows through the public read - surface, the validated selection. Not inputs: a clock, a transport, the - shard log's sequences (there is no order here), a peer's authorization. -- **Outputs.** `Entered` / `Left` / `Updated` with a cause, and one typed - refusal for a selection carrying page-shaped fields. -- **Derived state.** The membership set, and the incoming-edge index when - the selection hops. **Demotions:** `Watch` and `WatchRecord` are no - longer queries, they are lowerings; Mesh's collection *shape* is no - longer a language, it is a `Selection`; `MatchesIndex` is not an owner of - anything. -- **Forbidden writers.** Nothing outside `CultNetSelectionWatch` may decide - membership; no watch path may call `GetByIndex` or `GetByName` to decide - whether a change is interesting (that is the bug being deleted, in both - its copies); Mesh may not filter a feed the watch answered; the watch may - not emit `Updated` for a row that crossed the predicate boundary; the - watch may not accept `limit`, `cursor` or `descending`. -- **Shared paths.** `Matches` under both the query and the watch — a row - that a page would return and a row the watch reports entering are decided - by the same function, or the two truths drift. The subscription server's - `Reconcile` keeps its own per-peer delivered set (it answers a different - question: what this peer has been told) and **does not** become the local - membership tracker. -- **Deletion line.** Commit 1: `CultNetSelectionWatch` and - `Watch(Selection)`, with the three named-query methods deleted and their - tests rewritten. Commit 2: Mesh's nine overloads collapsed to three, both - matchers deleted. Nothing lands with `WatchByIndex` alive beside it. - -## 18.5 Verification - -| # | Test | Pins | Revert kills | Loosening kills | -|---|---|---|---|---| -| W1 | `Watch_ReportsARowLeavingWhenItsFilteredValueChanges` | membership, not mutation — the bug being deleted | `Left` never emitted (today's behaviour restored: the change is simply not delivered) | `Updated` emitted instead of `Left`, so a subscriber holding a list keeps a row that no longer matches | -| W2 | `Watch_ReportsEveryRowSharingAnIndexValue_NotTheCacheIndexWinner` | the deletion of the `ReferenceEquals`-against-the-winner rule | `MatchesIndex` restored | membership decided by the winner *plus* the changed row, which passes a two-row fixture and fails a three-row one — **the fixture must carry three rows sharing the value** | -| W3 | `Watch_EmitsTheInitialSetBeforeAnyLiveChange` | the subscribe-time set | no initial emission | the initial set emitted *after* the subscription goes live, so a write racing the subscribe is delivered twice or not at all — needs a test that writes between subscribe and first emission | -| W4 | `Watch_ReportsAnEdgeCausedEntryWhenTheCiterChanges` | `cause: Edge`, D6 locally | a hop-bearing watch decided per changed row (the citee never moves) | the citee reported with `cause: Self`, which loses the subscriber's ability to tell a row's own change from a set change | -| W5 | `Watch_RefusesAPageShapedSelection` | no order, cursor or limit on a feed | the refusal dropped and the fields ignored | `limit` refused but `descending` ignored — one field's worth of silent ignoring, which is how a "harmless" ignored field becomes a bug report | -| W6 | `Watch_AndSelect_AgreeOnMembershipForTheSameSelection` (property-shaped: for each fixture selection, the set the watch holds equals the ids a page returns) | the shared `Matches` | the watch given its own predicate | the watch's copy agreeing on the plain selections and differing on the hop-bearing ones — kills only if the fixture's selections include hops, which section 6's fixture does | -| W7 | `Mesh_CollectionTakesASelectionAndFiltersNothingItself` (Mesh tests) | the collapse | an overload restored | the three surviving overloads re-filtering the feed after the watch answered | - -**Negative greps:** `rg -n "WatchByIndex|WatchByName|MatchesIndex|MatchesName" src` -empty; `rg -n "GetByIndex|GetByName" src/GameCult.Mesh src/GameCult.Networking` -empty; `rg -n "CollectionByIndex|CollectionByName" src` empty. - -**Mutations.** Entries in this campaign's own file, target `networking` -(killer `dotnet test tests/GameCult.Networking.Tests`) plus the Mesh tests -for W7, one per rule above with its revert and its loosening. - -## 18.6 Subtraction estimate - -| | Removed | Added | -|---|---:|---:| -| `src/GameCult.Networking` | 28 | ~145 | -| `src/GameCult.Mesh` | ~465 | ~115 | -| tests | ~80 rewritten | ~260 (W1-W7) | -| **net source outside tests** | | **about −233** | - -This is the cut where CultMesh actually shrinks: about −350 in -`CultMesh.cs` and the snapshot facade against about +117 in the database -organ. It is also the cut that deletes a wrong answer rather than a -duplicate one — four spellings of "watch the row that currently wins a -unique-index lookup", replaced by one membership tracker over the selection -the caller actually asked about. Cut 1 pays about +585 for the vocabulary; -Cut 2 spends it. - -## 18.7 Operator questions for this cut - -- **Q-N. The hop-bearing watch's recompute.** **A, recompute and diff on - every change** (recommended: obviously correct, O(rows) per change, and - the local caches this runs over hold thousands of rows, not millions). - **B**, maintain an incremental incoming-edge index invalidated per write - — faster, and a second piece of derived state that can disagree with the - cache. Take B only with a measured reason. -- **Q-O. Does `WatchRecord` survive?** **A, yes, as a lowering** - (recommended; it is one key and no rule). **B**, delete it too and make - every caller say `keys: [k]` — cleaner, noisier at its call sites. -- **Q-P. Ordering on a feed, later.** Out of this cut by the rules above. - If "the latest N, live" is wanted, it is a third cut with an eviction - rule, not a field added here. diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md index 1da3ba63..afc94570 100644 --- a/docs/nuget-packaging.md +++ b/docs/nuget-packaging.md @@ -1,8 +1,5 @@ # NuGet Packaging -See `docs/semver-policy.md` for what a version bump on these packages is -required to mean and how a release is checked against that before it ships. - CultLib publishes the managed dependency graph as separate packages. The leaf package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh, CultNet, and CultCache ownership boundaries. @@ -22,5 +19,5 @@ closure through UPM rather than NuGet restore. Install the Unity package from the immutable release tag: ```text -https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.57 +https://github.com/GameCult/CultLib.git?path=/unity/org.gamecult.cultlib#cultlib-unity-v1.0.46 ``` diff --git a/docs/semver-policy.md b/docs/semver-policy.md deleted file mode 100644 index e1af0365..00000000 --- a/docs/semver-policy.md +++ /dev/null @@ -1,213 +0,0 @@ -# Semantic Versioning Policy - -Operator ruling, 2026-09-17: **semantic versioning is policy** for every -package CultLib publishes, strictly. A release that removes or changes public -surface incompatibly is a major bump, full stop — not a judgment call left to -the person cutting the release. - -This document says what "breaking" means for each kind of surface this repo -actually ships, and how a release is checked against it. See -`docs/nuget-packaging.md` for how the managed/Unity packaging mechanics work; -this document is the versioning contract those mechanics must satisfy. - -## The scar - -`org.gamecult.cultlib` 1.0.60 removed `CultInspectorAssetPath` — public API — -and shipped as a patch bump. Its own changelog entry is titled "Breaking"; the -version number said otherwise. A reviewer relying on the number alone would -have missed it. This is recorded as history, not fixed by renumbering: `1.0.60` -stays `1.0.60`. This policy, and the check below, exist so the next one is -caught before it ships instead of written up afterward. - -## Packages in scope - -| Package | Kind | Tag prefix | Published by | -| --- | --- | --- | --- | -| `org.gamecult.cultlib` | Unity UPM | `cultlib-unity` | `scripts/build-unity-package.ps1`, consumed by git tag | -| `org.gamecult.caching.unity` | Unity UPM | `caching-unity` | tagged directly (no build script; see "Coverage gaps") | -| `org.gamecult.cultmath` | Unity UPM | `cultmath-unity` | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag | -| `@gamecult/cultcache-ts` | npm | `cultcache-ts` | `.github/workflows/publish-packages.yml` | -| `cultcache-py` | PyPI | `cultcache-py` | `.github/workflows/publish-packages.yml` | -| `cultnet-py` | PyPI | `cultnet-py` | `.github/workflows/publish-packages.yml` | -| `cultmesh-py` | PyPI | `cultmesh-py` | `.github/workflows/publish-packages.yml` | - -Rust crates are consumed as pinned git revisions (see `publish-packages.yml`'s -own header comment) and are out of scope for tagged-release semver policing; -a `Cargo.toml` version still exists and should follow this policy by -convention, but nothing in this repo checks it mechanically today. - -Tag naming stays `-v` for every package above, matching the -prefixes already in use. - -## What counts as breaking - -Semver's ordinary rule — additive is minor or patch, anything a consumer's -existing code or data can no longer rely on is major — applies per surface as -follows. - -### Managed public API (`org.gamecult.cultlib`, NuGet packages, `cultmath`) - -Breaking: removing or renaming a public type/member, changing a public -member's signature or observable behavior in an incompatible way, tightening -a public contract (new required parameter, narrowed accepted input), -or moving a type across assemblies/namespaces without a compatible forwarder. - -Not breaking: adding a new public member, type, or overload; widening an -accepted type; deprecating (marking obsolete) without removing. - -### Unity package inspector attributes (`org.gamecult.cultlib`, -`org.gamecult.caching.unity`) - -These ship as ordinary public API by the rule above, but get their own -callout because they are easy to mistake for "just editor tooling": an -attribute class, its constructor shape, and the serialized value shape it -reads/writes are all public surface a consumer's `[CultInspectorX(...)]` -usage and serialized scene/prefab data depend on. Removing or renaming an -attribute, or changing what it expects a annotated member's persisted value to -look like (as `CultInspectorAssetPath` to `CultInspectorAssetGuid` did in -1.0.60) is breaking. - -### Persisted wire format / store compatibility (CultCache) - -Governed by `src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md` -and `cultcache-persistence-format.md` — read those for the authoritative -rules. In semver terms: anything those documents classify as **hard -rejection** (type change, reference/value change, one-vs-many change, target -schema change, name/index lookup semantics change, a persisted schema with no -compatible local candidate) is breaking for any package that reads or writes -the format. Anything classified as **soft-migratable drift** (compatible -schema id drift, an ignorable missing slot, a defaultable missing slot) is not -breaking on its own, but changing what counts as soft-migratable — loosening -or tightening the drift rules themselves — is breaking for whichever package -owns that classification logic. - -### CultNet protocol surface - -Breaking: a wire-incompatible change to a CultNet message, frame, or -handshake shape; removing or repurposing a message type or field that another -runtime's implementation depends on; changing routing, authority, or trust -semantics such that a peer built against the previous protocol version -misbehaves rather than cleanly failing. Additive, backward-compatible framing -changes (a new optional field a receiver may ignore) are not breaking. - -### Native bridge exported functions (`GameCult.Mesh.Quic.Native`) - -Breaking: removing or changing the signature/ABI of a `CULTMESH_API` -(`extern "C"`) exported function, or changing an exported function's -behavior such that the existing managed P/Invoke caller (`GameCult.Mesh.Quic.Native`'s -C# side) or Rust caller (`cultmath-core`'s equivalent `#[no_mangle] extern "C"` -surface) no longer gets what it asked for. Swapping the underlying TLS/QUIC -implementation (as 1.0.60 did, Schannel to OpenSSL MsQuic) is not breaking -*by itself* as long as the exported v1 functions keep their signatures and -behavior — 1.0.60's actual breaking change was the inspector attribute -removal, not the bridge swap, and its changelog correctly filed the bridge -swap under "Changed" rather than "Breaking". - -## Pre-1.0 packages - -A package at `0.y.z` (currently `org.gamecult.cultmath`, at `0.2.x`) uses the -standard pre-1.0 convention: `MAJOR` stays `0` during initial development, so -`MINOR` carries what `MAJOR` means once the package reaches `1.0.0`. Concretely: - -- a breaking change bumps `MINOR` (and resets `PATCH` to `0`); -- a non-breaking change bumps `PATCH`. - -Leaving `0.y.z` for `1.0.0` is a deliberate, separate decision (the API is now -considered stable), not something the checker infers on its own. - -## What a release changelog entry must contain - -Every published package keeps a `CHANGELOG.md` beside its manifest -(`unity/org.gamecult.cultlib/CHANGELOG.md` and -`src/GameCult.Unity/Assets/Caching/CHANGELOG.md` are the existing precedent). -Each release adds a `## []` section containing: - -- a category subsection per change (`### Added`, `### Changed`, `### Fixed`, - etc., following Keep a Changelog style); -- **`### Breaking`** — this exact heading, this exact spelling — for any - change that meets the "what counts as breaking" rules above. This heading - is not decorative: the CI check below greps for it by name. -- enough detail that a consumer can tell what to change, in the style of the - 1.0.60 entry (name the removed/changed symbol and its replacement). - -A version with no changelog entry at all fails the check below, the same as -a mislabelled one — an undocumented release is not a smaller sin than a -mislabelled one. - -## Enforcement - -`scripts/check-changelog-semver.mjs` is the mechanical check. Given a -package name, its `CHANGELOG.md` path, the version being released, and a git -tag prefix, it: - -1. resolves the previous published version from the highest existing - `-v*` tag older than the version being released (or treats the - release as a package's first if none exists); -2. requires a `## []` changelog entry to exist at all; -3. classifies the version bump (major/minor/patch) against the previous - version, refusing anything that is not exactly the next version in some - lane — a skip, a reversal, or a repeat are all refused, not just wrong - bump sizes; -4. if the changelog entry has a `### Breaking` section, requires the bump to - be at least major (or, pre-1.0, at least minor); -5. fails loudly, naming the package, the previous and new version, and (when - relevant) the changelog heading that triggered the requirement. - -Run it directly: `node scripts/check-changelog-semver.mjs --package ---changelog --version --tag-prefix `. Its own tests -live in `scripts/check-changelog-semver.test.mjs` (`node --test -scripts/check-changelog-semver.test.mjs`). - -It is wired into: - -- `.github/workflows/publish-packages.yml`, right after each job's existing - "tag matches manifest version" check, for `cultcache-ts`, `cultcache-py`, - `cultnet-py`, and `cultmesh-py`; -- `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`; -- `packages/cultmath/scripts/build-unity-package.ps1`, for - `org.gamecult.cultmath`; -- `scripts/verify-caching-unity-release.ps1`, for `org.gamecult.caching.unity`. - -### Coverage gaps - -`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`, -`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate -the Unity UPM packages at all; those are consumed directly by git tag -(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`, -`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags -cannot be added to that file without also giving it a reason to check out and -build Unity content it currently has no job for. The check is instead wired -into the scripts that already run at Unity-package release time: -`build-unity-package.ps1` and `packages/cultmath/scripts/build-unity-package.ps1` -already verify version consistency between the built assembly and -`package.json`, so the semver check runs alongside that as one more release -gate. `org.gamecult.caching.unity` has no build script at all today — it is -pure C# source tagged directly with no compile step — so -`scripts/verify-caching-unity-release.ps1` exists solely to run this check -before that package is tagged; it is not a build script and does not become -one. - -None of this is enforced by a git hook or branch protection rule: it runs -when someone remembers to run the Unity release scripts, the same way the -existing assembly-version check does. A tag pushed by hand without running -the release script bypasses both checks equally; closing that gap is a CI -trigger on tag push for the Unity packages, not something this change adds. - -Rust crates (consumed as pinned git revisions, not tagged releases in the -usual sense) and the CultNet protocol surface itself (no automated wire -compatibility test currently gates a release) are policy by convention only — -described above, not mechanically checked. Extending the checker to a Rust -crate's `Cargo.toml`/`CHANGELOG.md` or to an automated CultNet interop -compatibility gate is future work, not part of this change. - -`@gamecult/cultcache-ts`, `cultcache-py`, `cultnet-py`, `cultmesh-py`, and -`org.gamecult.cultmath` currently have **no `CHANGELOG.md` file at all**. This -is a real, pre-existing gap, not something introduced by this policy: their -next tagged release will fail the new check for exactly that reason ("no -changelog entry for version X") until each package's `CHANGELOG.md` exists. -That failure is intended — a release with no changelog is not a smaller -problem than a mislabelled one — but it does mean the very next release of -any of these five packages needs a `CHANGELOG.md` created first. Backfilling -changelog content for their past releases is not part of this change: it -would mean inventing release-note prose for history this pass has no -first-hand knowledge of, which is worse than leaving the gap named. diff --git a/docs/typescript-quic-realtime-cut.md b/docs/typescript-quic-realtime-cut.md deleted file mode 100644 index b68a2dae..00000000 --- a/docs/typescript-quic-realtime-cut.md +++ /dev/null @@ -1,2529 +0,0 @@ -# TypeScript QUIC Realtime Plane Cut Map - -Date: 2026-09-16 - -Status: Imagination output for the target in -`docs/typescript-quic-realtime-target.md` (operator-accepted 2026-09-16). No -code in this document has been written. Nothing here is committed by -Imagination; the root agent commits. Q5, Q6 and Q7 were ruled the same day -(section 13, Rulings); Q5 was then re-ruled from A to C: no registry in this -campaign, registry publication parked for a later pass by choice. Cut 6, -section 5.4, the binary-resolution and dependency lines of Cut 4, and Cut 7's -ledger are remapped under C (landed 2026-09-16, same day). Q8 is ruled: -the built binaries are committed to the tree (section 11.2). No operator -question is open. **Cut 1 landed** at `e234a64` (shared verifier, tests, -C#-written vectors), `66286b9` (browser copy deleted, −143 net) and -`062a5c6` (mutation runner; twelve killed, one equivalent), then three -Soul passes and three fix batches; **closed 2026-09-16**, three residuals -landed at `c64a2da`, `6c933d0`, `fe2555d`. - -**Cut 2 landed** at `6cbc73f` (the codec, 178 lines; C#-written and -TypeScript-written frame vectors, each decoded by the other side, the C# -test reading both with the reference alone) and `ae29910` (37 entries as a -third runner target; 72 of 72 across the three targets killed, one -equivalent named). Closed 2026-09-16 after Soul's pass and one fix batch. -Hands' discrepancies, kept: the -65,535-byte identity and 64 MiB payload ceilings recorded as a build rule -plus the digest of the whole encoding rather than half a megabyte of -literal text; the Body has five refusal messages where the spec listed six -(negative length and length-sum mismatch share one); three refusals added -that C#'s type system gives free, among them **a real divergence found by -the test: `TextDecoder` deletes a leading U+FEFF where -`Encoding.UTF8.GetString` keeps it, and U+FEFF is not .NET whitespace, so -such an identity passes `Validate` and reaches the wire**, closed with -`ignoreBOM`; C# sums lengths under `checked` and TS covers the overflow -with its length refusal, unreachable from either encoder. Residual 1's -exact-equality verification could not hold as written: applying the -pre-check leaves eleven stricter-than-C# spellings, all the safe direction, -pinned by name, because exact agreement needs .NET's registered-scheme -table. The no-control-byte test caught a literal U+001F the Write tool put -into its own introducing comment. - -Two items for Self's ruling, both taken: **`npm run test:ts` and `build:ts` -have been broken since `8cb3b72`** (the scoped rename; Hands first -attributed it to `8dd5a45`, which only bumped a version, and Soul -corrected it) because `scripts/run-typescript-workspaces.mjs:9` names the -old unscoped `cultcache-ts`; one-word fix, lands in Cut 2's fix batch. -**The runner is `scripts/mutate-cultmesh.mjs`** since `65f2b66`'s batch -(renamed from `mutate-cultmesh-authority.mjs` once it carried a codec -target); every reference below that names the old path is history. - -**Soul's pass on Cut 2** (Fable; probes `probe-cs/`, `parity.mjs`, -`symmetric.mjs` and others in the session scratchpad) held byte parity in -both directions over its own thirty-nine encode specs and thirty -hand-built byte strings: identical digests, identical refusal messages, -identical U+FFFD counts on invalid UTF-8, the byte-order mark round trip, -all four digest ceilings regenerated from their build rules on both sides, -the five decode refusals in the same order, and every symmetric two-site -mutant dead. **Cut 2 does not close as promised**, on three small findings -in the fix batch: - -- **The mutant Hands called equivalent is not.** A 37-byte frame whose - identity lengths cancel a negative payload length sums to exactly 37 and - decodes to blank identities where C# refuses. The clause is right; nothing - defended it. Medium. -- **The decoded payload aliases the frame when the input is a Node - `Buffer`**, because `Buffer.prototype.slice` is a view; the test used only - a `Uint8Array`. The same trap the repo recorded for `verifyP256`. Medium - for the bridge, nil today. -- **Neither half of the frame parity runs in CI**; a C# codec change leaves - everything green. The Cut 1 scar, repeated. Medium. - -Recorded, not defects: the reference encoder writes delivery values its -own decoder refuses (a cast on the enum), where TypeScript refuses at -encode, the stricter side; an overflow near `int.MaxValue` is reachable -from hostile bytes and refused on both sides with different words; the -runner's sentinel detects only the last mutant by design, and the sidecar -repair names the mutation a dead run was inside. - -**The Cut 2 fix batch landed** (Opus) at `65f2b66` (the aggregate scripts -reach every workspace again; breakage confirmed at `8cb3b72`), `9a4c939` -(the payload copy is `new Uint8Array(bytes.subarray(…))`, because Soul's -prescribed `Uint8Array.prototype.slice.call` returns a `Buffer` from a -`Buffer` through `@@species` and fails Soul's own assertion; three crafted -negative-length frames refused; the false equivalence note gone), -`6ed0ff7` (both halves of the frame parity in CI on both OSes) and -`7a0749b` (the eleven stricter spellings named for what they are: three -backslash, two missing-authority, four host repairs, two `URL` throws; the -same slip in `isProtectedEndpoint`'s own comment fixed). 75 of 75 -mutations killed across three targets. Gap, not a workaround: `npm run -test:ts` still cannot go green on the workstation because one -`cultcache-ts` test spawns `python`, the Store alias stub here; `py -3` -has msgpack; pre-existing since `8cb3b72` and outside every cut. - -**Cut 3 landed** (Opus) at `6a091ef` (the bridge: 440 → 1,168 lines of -C++, a two-role event-queue C ABI v2 with twelve exports, the five v1 -exports kept as a Windows-only Unity shim) and `23dc093` (CMake for both -platforms, `scripts/build-quic-native.sh`, README). Host and target: -win32-x64 built and smoked on the workstation (MSVC 14.44, CMake 4.3.2); -linux-x64 built and smoked in the workstation's Docker `debian:13`, which -is the development loop and **not release-grade**; its release build is -Cut 6's Actions job. Fifteen-check FFI smoke 15 of 15 on both; the v1 gate -3 passed, 1 skipped by design; ten native mutations killed under two green -controls; exports 17 on Windows and exactly 12 on Linux with hidden -visibility; the deb digest and header sizes match the probe ledger; -manifests with SHA-256s beside every binary. Two bugs the old shape never -exercised, found by hanging tests and now mutation-pinned: the v1 -certificate pin must be attached before `ConnectionStart` (the handshake -reaches the certificate event on a worker thread at once on loopback), and -teardown must close connection handles itself because `RegistrationClose` -blocks until every child is closed. Spec discrepancies kept: `ntdll` on the -Windows link list; the deb carries no licence text, so the script copies -the committed MIT text; the deb ships no unversioned `.so` symlink, so the -script creates one; four Linux runtime libraries are load-bearing and now -in the README. Soul in flight. - -**Q9, the one fork Cut 3 hit, ruled A on 2026-09-16 (detail below).** The -pinned Windows MsQuic is the Schannel flavour, and Schannel does not -implement `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`: `ConfigurationLoadCredential` -returns `QUIC_STATUS_NOT_SUPPORTED`, so the provider role cannot open a -listener on Windows under it. The identical bridge DLL passes the full -smoke beside the OpenSSL 2.5.9 `msquic.dll` (SHA-256 `FEE9A664…`, -4,181,856 bytes). Linux is unaffected: Microsoft ships only the OpenSSL -build for Debian. -- **A. Pin OpenSSL on Windows.** One credential type on both platforms, - provider role works, ABI unchanged. Costs: the package URL and digest in - `build-quic-native.ps1`; the committed Windows `msquic.dll` grows from - 537 KB to 4.18 MB, so Q8's tree price becomes about 11.6 MB; the Unity - plugin's `msquic.dll` diverges until the operator's unscheduled rebuild. - The v1 client path works under either flavour. -- **B. Keep Schannel and convert PKCS12 to a certificate context via - `PFXImportCertStore`.** Keeps the pin and the Unity DLL; puts about - forty lines of Windows crypto in the shared provider path, breaking - "runtime-neutral" and the cut's own negative grep. -- **C. Providers Linux-only.** Cheapest; kills the Windows FFI smoke the - cut requires and the Windows provider lanes Cuts 4 and 5 need. -**Recommended: A. Ruled A, 2026-09-16** ("A"). The Windows pin moves to -`Microsoft.Native.Quic.MsQuic.OpenSSL` 2.5.9 in `build-quic-native.ps1` -with its digest; Q8's committed tree price is restated at about 11.6 MB; -the Unity plugin's `msquic.dll` stays Schannel until the operator's -rebuild, recorded in section 15, and the v1 client path is verified under -both flavours. The spec's "one credential type for Schannel and OpenSSL" -sentence in section 8 is withdrawn: the header declares the type and only -the OpenSSL provider implements it. *(Digest correction, Soul: `FEE9A664…` -is the NuGet zip, 19,179,817 bytes; the DLL is `c17c6581…`, 4,181,856 -bytes. Hands' report and this map's first record had the zip's digest on -the DLL.)* - -**Soul's pass on Cut 3** (Fable; `soul3-*` in the session scratchpad, -including a C consumer of the ABI that is not koffi and an ASan build of -the bridge) held the Cut 2 fixes entire, the export counts, the greps, the -clean-container Linux rebuild byte-identical to the manifest, the four -load-bearing Linux runtime libraries, the stress at 16,384 frames with -zero lost or reordered on both targets, both hang mutations dying by -timeout, and both of Q9's load-bearing claims (Schannel refuses the PKCS12 -credential with not-supported; the identical bridge passes 15 of 15 beside -the OpenSSL DLL; the v1 gate passes beside either). **Cut 3 does not -close.** Found, in a fix batch (Opus): - -- **A use-after-free between the host thread and MsQuic's worker** (high): - the id lookups drop the lock and return a raw pointer, the worker frees - the object on shutdown-complete, and the host's next send dereferences - it. One in three release runs crashes on each target under an ordinary - peer-driven stream teardown; three of three under ASan. The fix is an - object kept alive across the call, not a guard. -- **Event 7 is not one per frame send**: the kind-byte send completes as - its own event with nothing to tell it apart, six events for five frames. -- **The initiator of a shutdown never learns its connection is gone**: - MsQuic gives it only shutdown-complete, which the bridge swallows. -- **Return codes invert by platform**: `QUIC_STATUS` is a negative HRESULT - on Windows and a positive errno on POSIX, so a Linux host checking for a - negative code misses every failure; the README promised negative. -- **`runtime_close` wakes a blocked poller into freed memory**; the wake - makes the pattern look supported. Close must quiesce. -- **No host-facing header**: the struct layout and the twelve signatures - live in the `.cpp` and the spec; Soul wrote its C consumer from the spec. -- Smaller: an unbounded per-connection trace string from the v1 - diagnostics; a listener config failure leaving a dead handle in the map; - a stream emplaced after start; a partial listener open leaving an entry; - a null-payload poll consuming the event; the DLL importing the VC - runtime with the README silent. - -The Q9 pin change and the manifest's stale commit line ride in the same -batch. - -**The Cut 3 fix batch landed** (Opus; its commits are signed Opus 5, which -is what ran) at `9a01321` (`include/cultmesh_quic_native.h`, 305 lines: -the event struct with offsets static-asserted, every export, the id and -return-code conventions, the lifetime and threading rules; the koffi smoke -and Soul's C consumer are checked against it), `47aa7b6` (objects kept -alive across the call that names them: one `shared_ptr` per map entry, a -lookup returns a reference the caller holds for the whole MsQuic call, the -worker drops only the map's reference, the handle closes exactly once in -the destructor under the exchange guard, a stream owns its connection so -close order is structural; `runtime_close` marks closing, wakes pollers, -waits for `active_calls == 0`, and refuses any later call), `8c41896` (the -ABI gaps: one event 7 per frame send with `Canceled` distinct; event 4 -uniform at completion for every path, the initiator included, published -after the id stops resolving; return codes mapped negative on every -platform with `cultmesh_quic_last_status` for the raw status, thirteen v2 -exports now; a null payload with capacity refused; a stream in the map -before start; failed listeners leaving nothing) and `c729dff` (the OpenSSL -pin with both digests, `/MT` so the DLL imports no CRT, manifests with a -real commit line). Host and target: win32-x64 on the workstation, 20 of 20 -smoke checks, the C consumer's seq, cancel, stress (16,384 frames, zero -lost or reordered), race 400×20 with zero crashes, closerace 20×512, the v1 -gate 3 passed 1 skipped, 18 exports; linux-x64 in the workstation's Docker -`debian:13`, development loop only, the same set plus ASan clean on every -mode, 13 exports. 22 of 23 mutations killed under two green controls. -**L3 survived**: deleting the quiesce wait cannot be caught, because the -wake happens under the lock and every poller drains before teardown; right -by construction and by reading, unfalsifiable by this harness. Soul rules -whether that stands or a dev-only probe asserts the invariant. Open after -the batch: the Unity plugin's `msquic.dll` is still Schannel until the -package build runs; the Schannel dependency cache is orphaned; the v1 -return-code values changed while the zero-versus-nonzero contract held. -Bridge 302,080 bytes / `f4c5f644…` (win32-x64, static CRT), 108,128 / -`61d5b96e…` (linux-x64). - -**Soul's second pass on the fix batch** (Fable; `soul3b-*` in the session -scratchpad, its C consumer extended with close, re-entry and completion-race -scenarios, ThreadSanitizer and AddressSanitizer on linux-x64 in the -workstation's Docker `debian:13`). Held: the lifetime model, race 400×20 with -zero crashes on both targets and ASan clean on every mode; 16,384 of 16,384 -frames under stress with none lost or reordered; one completion event per -frame with cancellation distinct; the completion event uniform at the end of -every connection, with its mutant killed on both targets; negative return -codes everywhere; the null-payload refusal; the header compiling clean as -both C and C++ under maximum warnings on two compilers with no padding and -no implicit conversion; no C runtime import; the OpenSSL pin re-downloaded -and both digests equal; the version-one gate green beside either flavour of -the library. **The cut does not close at `c729dff`.** Findings: - -- **The quiesce is one line short, and it is a use-after-free.** `~CallScope` - decrements the call count under the lock, releases it, and only then - notifies. The closer's predicate is already true, so if that last poller is - preempted in between, the runtime is destroyed and the notify lands on a - freed condition variable. ThreadSanitizer caught it on the **unmutated** - bridge, so the previous batch's model was right in shape and wrong by one - line. Confirmed, medium, and the blocker. -- **The header oversells the return code.** It is portably negative, not - portably meaningful: the same failure is −12032 on Windows and −1126 on - Linux, and the Windows mask collides two distinct statuses onto −1002. The - sign is the contract; the magnitude is diagnostic. Low, wording. -- **The previous batch's own README claims a rebuild reproduces the committed - Unity plugin.** It does not: that plugin is the older Schannel build at - 40,448 bytes with a C runtime import, against 302,080 bytes without one - today. Low, and exactly the stale-artifact trap. The current build does - reproduce itself. -- **A Windows status sign-extends in diagnostics**, printing sixteen hex - digits. Trivial. - -**L3 is ruled: it becomes killable, it is not accepted as unfalsifiable.** -Soul established the survival was a harness limit rather than a proof of -correctness. Under AddressSanitizer with pollers blocked mid-copy of large -frames, deleting the quiesce wait hit use-after-free four times in -forty-eight attempts, while the release scenario never observed it. So a -dev-only assertion now aborts when the call count is non-zero after the -quiesce block, which fires deterministically with enough blocked pollers and -makes the mutation die by the existing scenario; the sanitizer run stays as -the second check. That assertion does not cover the defect above, where the -count has already reached zero. **Two mutations are honest only on the -sanitizer target:** the pop-then-copy one and the closed-handle one survive -on Windows release and kill three times of three under AddressSanitizer, so -the entries table must say which target each is honest on. A mutation whose -kill depends on the target is not killed until it runs there. - -**The second fix batch landed** (Opus) at `a4753f4` (the notify moves inside -the gate), `dc9e12c` (a Windows status stops sign-extending in diagnostics), -`53d5929` (the header says the return code is portably negative, not portably -meaningful), `19d8d5c` (the readme says the committed Unity plugin is the -older build), `f4186e7` (a committed scenario runner for the runtime -lifetime) and `180917d` (four native entries, each naming the target it is -honest on). - -**The discovery that outweighed the fix: there was no committed native -harness at all.** Every native mutation kill this cut had reported came from -consumers and scenarios that lived in agent scratchpads and no longer exist. -The runner at the previous head carried three package targets and nothing -touching the bridge, and two entries could not even be re-marked because -their definitions were gone. Hands built what the brief had assumed already -existed. - -**Soul's third pass, 2026-09-17** (Fable). Held: the lifetime fix on both -targets, the reverted notify killed three of three by the thread sanitizer -and invisible to the address sanitizer, so its target marking is right; the -assertion killing the deleted wait deterministically on both targets and -absent from both shipped libraries and a fresh release build; two fresh -release builds byte-identical to the committed artifact; the return-code -numbers recomputed from the pinned headers; byte-exact restore on both hosts. -**Cut 3 still does not close**, and the reason is that the harness defending -the fix is weaker than the fix: - -- **The committed Linux path does not run as committed.** The sanitizer - configuration dies before the scenario starts, on the kernel's - address-space randomisation, and the runner swallows the child's standard - error, so a future agent sees a red control and no diagnosis. Soul reached - four of four kills only by disabling randomisation and relaxing the - container's seccomp profile, neither of which any committed file mentions. - The image came from a definition that is not in the repository, and the - documented development recipe cannot run the runner at all, since it - installs the C++ toolchain but not the JavaScript runtime. High for the - claim that the Linux kills are rerunnable. -- **The scenario does not test the rule it exists for.** Deleting the call - guard from the polling entry point, so host calls are not counted at all, - survived nine runs of nine across every configuration. The harness pins the - order of the count and the notify, not that anything is counted. -- **The scenario passes vacuously when nothing parks.** Inverting the timeout - comparison makes every poller return before the close begins, and the - scenario is green on all six configurations. -- **The bounded-wait loosening is pinned by a constant, not by the rule.** At - one millisecond it dies with as few as two calls outstanding; at fifty - milliseconds it survives on both targets. A bounded wait is the wrong - contract at any bound. -- **A skipped entry exits zero** with no closing summary, so a gate reads a - host that killed nothing as green. -- **The header contradicts itself**, forbidding a call that begins after - close starts and separately promising such a poll is refused, when that - refusal path reads freed memory after teardown. - -**Two rules are defended by nothing committed**: popping before copying, and -a handle closed under a live call. Both were previously reported killed on -the sanitizer target only, and neither has a definition left. The third fix -batch is told to give each a real entry or to say plainly that the rule is -undefended, because an honest gap beats a fabricated kill. - -**The third fix batch landed** at `62e53df` (the development seam and a -hold-then-close scenario), `e4e09a0` (four entries, the bounded wait at 50 ms, -an exit status on skips, the child's stderr on a red control, re-execution -with randomisation disabled), `12c8bfc` (the header's self-contradiction) and -`4407098` (the committed container, the Ninja generator, the README recipe -with the seccomp requirement). - -**Soul's fourth pass, 2026-09-17** (Fable, banked mid-pass when that model ran -out, resumed on Opus from the bank; the handoff paid for itself by carrying a -live harness process, a dirty tree and two leads the successor opened with). -**The verdict splits, so it is given twice.** - -*The infrastructure question is now yes.* The committed container built with no -cache, then the literal README run line, gives eight killed, none surviving, -none skipped, exit zero on Linux; Windows gives six killed, two honestly -skipped, and a real exit code. A stranger can rerun both targets today. The -seccomp requirement is genuinely load-bearing and the randomisation -re-execution genuinely runs. The development seam is absent from a release -build: thirteen production symbols, no debug symbols, no assertion strings, -and every executable section byte-identical between pre- and post-change -release builds. The predecessor's harness had already exited, restoring the -tree byte-exactly, so the restore path holds. - -*The rules question is still no.* Three of the four rules in the native table -are pinned. The fourth is not, and the cause is structural rather than a bad -constant: - -- **F1, confirmed, high: the hold sits after the wait whose duration is the - rule**, so every timeout mutant lands in a blind spot by construction. - A hard-coded 100 ms survives Windows and Linux with assertions, and dies - under the thread sanitizer only because its slowdown trips an unrelated - condition. **A hard-coded 2000 ms survives the entire matrix on both - targets.** A host asking for five seconds silently gets two and every - consumer's poll loop spins at two and a half times the rate it asked for, - forever. The committed one-millisecond entry dies only because one is less - than the scenario's fifty-millisecond settle: constant pinning constant. -- **F2, confirmed, high: the pop-before-copy gap was wrong about itself.** - A client opening to a closed loopback port reaches that path in about a - millisecond with no listener, credential or connection, because the failure - path always publishes a non-empty reason; fifteen lines. Moving the pop - before the copy then crashes an ordinary optimised build twice out of two, - while both committed scenarios stay green on that same binary. -- **F3, confirmed, medium: the refusal during a close has no entry** and - survives being neutered, though the header promises it. A call entering - behind the closer increments the in-flight count behind the wait, which can - then never reach zero. -- F4, low: the seam misdescribes itself, claiming the held call touches - nothing afterwards when its scope destructor takes the gate, decrements and - notifies. The safety comes from the assertion, not the hold. -- F5, low: the bounded wait at 600 ms survives, the same shape as F1 and - honestly labelled as a limit by Hands. -- Also: "the close wakes every blocked poll" **is** defended by committed code - but has no entry, so the table understates coverage in one place while - overstating it in another. - -**Two scars from the pass itself.** An export made with `git archive` carried -1,459 inserted carriage returns, poisoning any byte comparison built on it; -raw blobs verified against the worktree hash are the only safe source. And the -51 bytes that differ between pre- and post-change release builds are accounted -for: 48 are four copies of the reproducibility content hash, and the last -three sit in exception metadata outside every executable section and **could -not be fully accounted for**, which Soul said rather than rounding away. The -same source rebuilt at the same path twice differs by zero bytes; built -elsewhere it differs by 69, so the figure is only meaningful at a shared path. - -**One undocumented step remains** in an otherwise clean path: the README -hardcodes the container mount, so a newcomer on another checkout must edit the -line. - -**The fourth fix batch landed** at `25d7de2` (the container mount taken from -the running checkout rather than one machine's path) and `a937a0b` (the -scenarios, the seam correction and the new entries). - -**Hands rejected Self's diagnosis and gave a better one.** Self had said the -blind spot was that the hold sat after the wait whose duration was the rule. -The real cause is that **no committed scenario ever let a timeout finish a -poll**: both ended their waits with a close, so the timeout governed nothing -either could observe. The fix is therefore a scenario rather than a seam -recording. `polltimeout` polls an idle runtime and measures elapsed against -what was asked, **at two values with non-overlapping bands** — 200 ms in -[180,500] and 900 ms in [880,1200] — because one value cannot tell a bridge -honouring the host's timeout from one waiting on a constant that happens to -equal it, and no constant lies in both bands. No new export, no header change, -and it works in the shipped shape. - -All three constants now die by that scenario: 100 ms, 600 ms and 2000 ms. The -600 ms case survives both older scenarios and dies to the new one, which is -the demonstration of what the old ones could not see; Hands kept it as a hand -check rather than carrying a third constant in the table. - -Also landed: Soul's closed-port probe committed as `payloadfit`, reaching the -two-phase poll in about a millisecond, where the pop-before-copy mutant -segfaults an optimised Windows build with no sanitizer, five runs of five; an -entry for the refusal of a call racing a close; an entry for the wake, which -the table had been silently failing to claim; and the seam's self-description -corrected, since a held call does leave its scope, which takes the gate, -decrements and notifies. Release shape unchanged on both targets, with no -debug exports and no assertion strings. - -**Tables now: Linux 18 killed, none surviving, none skipped, exit 0. Windows -16 killed, none surviving, two honestly skipped, exit 2.** Controls green on -both. - -**A declared equivalence, handed to Soul rather than substituted.** Changing -the close's wake-all to a wake-one survives, and Hands argues it is equivalent -rather than uncovered, because the first poller out calls wake-all under the -gate from its own scope destructor. It recorded the equivalence beside the -entry and shipped the queue-conditional wake as the loosening instead. This -campaign's fourth encounter with that defect shape, and the first where the -answer was an argued equivalence rather than a swap. - -Named limits: the 600 ms mutant lives only in the report; `payloadfit` and -`polltimeout` run only in the assertion configuration, so the pop-before-copy -kill rests on an unsanitized crash and an exit code rather than an -address-sanitizer configuration the harness still lacks. **Soul's fifth pass, 2026-09-17** (Opus). Both targets reproduced end to end -from a fresh clone at a different path. **Cut 3 still does not close, and for -the same underlying reason in a third costume: the scenario proves the wait is -not a constant, not that it is the host's.** - -- **F1, confirmed, high: an expression defeats the two-band argument.** No - constant sits in both bands, which is true and is not the rule. Any mapping - that is identity at 200 and 900 passes for free, and the late tolerance is a - flat 300 ms. Three mutants, each run through all five Windows scenarios, all - **survived all five**: clamping the wait to at most a second, adding 150 ms, - and scaling by six fifths. **The clamp is the one that matters**, because its - failure is verbatim the bug this batch claims to fix — a host asking for five - seconds gets one, forever, with nothing reporting it — and "cap the wait so - shutdown gets noticed" is the most ordinary spelling that line will ever be - given. The close is cheap: raise the long probe above any plausible ceiling, - which kills the clamp and the scale, and make the tolerance proportional - rather than flat, which kills the offset. -- **F2, confirmed, medium: a Windows clone cannot run the documented Linux - path at all.** `.gitattributes:2` is `* text=auto` with no rule for shell - scripts, so a default Windows clone writes the build script with carriage - returns and the container dies in under a second complaining about the - interpreter line. Soul hit it from a clean clone before anything else ran. - The mount fix works; this is a second, independent blocker on the same - documented path, and it fires for the likeliest newcomer. One line repairs - it. -- **F3, confirmed, low: the only non-comment source change in the range is - pinned by nothing.** Reverting the hold's new guard survived all five - scenarios, because the timeout scenario never arms the hold and the other two - never let a timeout expire, so nothing is ever in both states at once. The - seam folds away in release, so nothing shipped is at risk; it is the - recurring shape again. - -**The declared equivalence holds, and its stated reason was the weaker half.** -Narrowing the close's wake to one waiter is genuinely equivalent, reproduced. -But the equivalence rests on two premises together: the closing flag is set -permanently under the gate before the notify and appears in every waiter's -predicate, so after a close begins no poller can block at all; and each exiting -call's own destructor takes the gate, decrements and wakes everyone, cascading. -Soul then narrowed **both** wakes, which also survived and **is not equivalent -by any argument**, since a notify consumed by the closer stalls the chain. It -passes on wait-queue ordering alone. The premises belong beside the entry so -the claim is checkable rather than assertable. - -**Held:** Linux 18 killed and Windows 16 with two honest skips, both -reproduced; 600 ms dying to the new scenario and surviving the older two; -the closed-port probe reaching the two-phase poll and both pop-before-copy -entries dying on both targets; the release shape clean on both, with the new -local folding away. **The red-control path proved itself unasked** — a run -from a deep temporary path failed MSBuild's file tracker, and the harness -reported a red control with the child's output rather than banking eighteen -kills it had not earned. - -**Two record corrections.** The source digest quoted in the previous report is -checkout-dependent, because line-ending conversion changes it; Soul's clone -restored to a different one. It is not a portable identity. And the Windows -harness needs a short checkout path or the control fails on the file tracker, -which belongs where a newcomer meets it. **Fifth fix batch in Hands.** - -**The fifth fix batch landed** (Opus), `ca6c0f5..244154e`. F2: `*.sh text -eol=lf` in `.gitattributes` (`ca6c0f5`); the container line no longer picks a -shell's continuation, the Dockerfile's recipe mounts the running checkout, and -the win32-x64 short-path requirement is stated where a newcomer meets it -(`c177533`). F1: the long probe is 5000 ms and the late tolerance is -`60 + timeout/8` rather than a flat 300, which kills the clamp and the scale on -both checks and the offset on the short one; the clamp is its own entry. Stated -limit, in source beside the tolerance: **a scaling smaller than an eighth -survives** (`c682619`). F3: `holdtimeout` puts a call in both states; the -revert and the reordered-conditions loosening both die there at about 800 ms -and on nothing else (`5ab4c62`). The single-waiter equivalence now states both -premises beside the entry, and narrowing both wakes is recorded as **not yet -reached**, with why a scenario cannot force it while the first premise holds -(`b985f07`). The restore digest is printed as a restore check, not an identity -(`244154e`). Hands' numbers: win32-x64 19 killed, 2 honest skips; linux-x64 21 -killed. **Soul's sixth pass dispatched 2026-09-22** (Opus). - -**Soul's sixth pass, 2026-09-22** (Opus). Hands' table reproduced exactly from -fresh `autocrlf=true` clones: win32-x64 19 killed, 2 skips; linux-x64 21 -killed; controls green. **Cut 3 still does not close.** Every scenario so far -has a single-threaded host. - -- **N1, confirmed, medium: the wait's predicate is pinned by nothing.** Dropping - it (`wait_for(lock, ms(t)) == no_timeout`, `cultmesh_quic_native.cpp:1179-1180`) - survived the whole matrix on both targets. At the bridge, a second host thread - calling `cultmesh_quic_last_error` every 20 ms made an idle `poll(1000)` return - after 0 and 20 ms, where the real bridge stays 1000. Every `CallScope` - destructor wakes all waiters (`:286`), so a host with a poll thread and a send - thread spins at the rate of its other calls. The rule "a poll with nothing to - deliver stays for its timeout" is pinned only for a host with one thread. -- **N2, confirmed, low-medium: the stated limit understates what survives.** - Survived on both targets: `min(t,5000)`, `min(t,4990)`, `t*11/10`, `t+70`. - `max(t,220)` survived on Linux and died on Windows. `max(t,250)` and `t+80` - die only on `holdtimeout`'s 150 ms probe, by margins of 22 and 2 ms, so those - kills depend on timing. "5000 is above any plausible ceiling" is wrong, - because 5000 is itself a round ceiling, and no probe can see a floor under - about 60 ms. Either add a small-timeout probe and a probe above 5000, or state - the full limit: offsets to about 75 ms, floors to about 220 ms, and clamps at - or above about 4980 ms. -- **N3, confirmed, low-medium: the LF fix does not reach an existing clone.** - A clone made at `367c9bc` and moved to `244154e` keeps the CRLF script, since - the blob did not change. Its status is clean and it still dies on `bash\r`. - Nothing tells an existing clone to check its `*.sh` out again. -- **N4, confirmed, low (development seam): no hold mutant is a function of the - input.** `(woken || timeout_ms > 1000) && HELD` survived on both targets. - Both of Hands' hold entries only rearrange the guard's syntax. - -**Held:** F2 from a fresh clone, covering every file the container runs or -parses. F3's revert and its loosening, with only `holdtimeout` failing under the -revert. The clamp entry. Both equivalence premises, checked against source, with -"narrow both wakes" recorded as not yet reached. Restores verified by hash -against `checkout-index`. Sidecar repair after a mid-mutation `taskkill`. -`polltimeout` and `holdtimeout` stayed green for five rounds under 16 burners on -8 CPUs. **Not run:** a forced red control, and Windows under a CPU burner. -**All four go to Hands as the sixth fix batch.** - -**The sixth fix batch landed** (Opus), `b3d9cf7..ff72f16`. Details: - -- **N3 (`b3d9cf7`):** the README gives an existing clone a one-line repair beside - the container path. It is `git rm --cached -q scripts/build-quic-native.sh; - git checkout HEAD -- scripts/build-quic-native.sh`. A plain checkout keeps the - CR. The line was verified on clones made at `367c9bc` from Git Bash and from - PowerShell 5.1. -- **N2 (`37967e8`):** the timeout probes are 15, 200 and 7300 ms. The 15 ms - probe takes the fastest of ten tries, and every try is still checked for an - early return. There are new entries for a round clamp, a floor and an - offset. The limits measured on both targets are now written in source: - - `t+25` survives and `t+40` dies; - - `max(t,40)` survives and `max(t,50)` dies; - - `t*106/100` survives and `t*108/100` dies; - - `min(t,7285)` survives and `min(t,7275)` dies; - - any shortening up to 20 ms survives. - - The "above any plausible ceiling" claim is deleted. -- **N1 (`4ffa4c9`):** `pollbusy` runs a second host thread that calls into the - bridge while a poll is idle. Three entries sit on it: the revert, "reads - what woke it", and "each wake restarts the whole timeout". All three die on - `pollbusy` on both targets. -- **N4 (`ff72f16`):** `holdtimeout` makes a second held poll at 1300 ms. The - input-derived hold mutant dies there and nowhere else. -- **Recorded as not yet reached:** the loosening that checks only `closing`. - It breaks "an arriving event ends the wait early", a promise no scenario - measures. - -Hands' numbers: - -| Target | Killed | Survived | Skipped | Control | -|---|---|---|---|---| -| win32-x64 | 26 | 0 | 2 | green | -| linux-x64 | 28 | 0 | 0 | green | - -Under 16 burners on 8 CPUs, 15 of 15 rounds passed on each target. The worst -single poll was 15 ms late on Windows and 41 ms on Linux. - -**The batch added one mechanism nobody specified. Self accepted it, 2026-09-22.** -On Windows the timed scenarios raise their own process priority. Without that, -Windows under load put polls up to 127 ms late. The unmodified bridge then -failed even the pre-batch tolerance: the 150 ms hold probe came back 100–115 ms -late against 78 ms allowed. The only alternative was a flat allowance of about -150 ms, which would readmit the offsets and floors this batch exists to kill. -The priority change is test-fixture only, needs no admin rights, and changes -nothing in the bridge. Accepted, and handed to Soul to attack. -**Soul's seventh pass dispatched** (Opus). - -**Soul's seventh pass, 2026-09-22** (Opus). Reproduced from a fresh clone: - -| Target | Killed | Survived | Skipped | Control | -|---|---|---|---|---| -| win32-x64 | 26 | 0 | 2 | green | -| linux-x64 | 28 | 0 | 0 | green | - -Exit 2 on win32 is by design: it means entries were skipped -(`mutate-cultmesh.mjs:1192`). A forced red control was reported red, and the -harness ran nothing after it. Restores matched `checkout-index` by hash. N3 -holds from both shells. **The priority raise is sound.** It is scoped to the -process, which exits after each scenario, and it cannot hide a lock held -across a sleep, because every thread in the process is raised equally. -**Cut 3 still does not close.** - -- **S1, medium: N1 is not closed.** Two predicate mutants survive the full - matrix on both targets: - - **K1**, `|| ++wakes > 64`. Under a thread calling in a yield loop, an idle - `poll(1000)` returns after 0–5 ms. - - **K2**, `|| !runtime->error.empty()`. After one refused call, every later - idle poll returns at 0 ms. - - `pollbusy` covers one call kind, at one pace, on a runtime with no error - recorded. -- **S2, medium: the 15 ms probe has no early check.** The check is - `elapsed < 15 - 20`, which cannot fail. Both `t<50 ? 0 : t` and `t/16*16` - survive. -- **S3, low-medium: more derived waits survive.** Rounding up to a 40 ms - quantum survives. So does any fault that only shows after a runtime's 12th - poll, because no scenario polls a runtime more than 12 times. -- **S4, low: two stated boundaries depend on timing on Windows.** `t+25` died - in one of two repetitions. `min(t,7275)` survived in one of two. -- **S5, low:** the hold guard `timeout_ms > 1500` survives. The stated limit - should put the ceiling at 1300. - -**Self's ruling, 2026-09-22: change the method, not the probes.** This rule has -now taken seven passes. Each fix batch pinned the functions the previous Soul -named, and the next Soul found another function that equals the identity at -every probe point. A rule about the arithmetic of a derived wait cannot -converge under wall-clock observation from outside, because there is always -another function that matches at the probes. The skill already says it: put -the observation where the rule is decided. - -- **The wait.** A dev-only seam, of the same kind as the existing hold seam - and folded away in release, records the timeout the bridge actually hands to - its condition wait on every poll. A scenario asserts that it equals the - host's argument **exactly**: - - over a spread of values: 0, 1, 15, 16, 39, 40, 999, 1000, 1001, 7300, - `INT32_MAX`, and the bridge's documented maximum if it has one; - - on polls well past the 12th on a single runtime. - - Every clamp, floor, round, scale, offset and later-poll mutant then dies by - equality, deterministically, on every host, with no timing tolerance. The - wall-clock scenarios stay as proof that the recorded wait is actually waited, - with **generous** margins. The wide boundary table goes: those limits existed - only because the observation was indirect. Stated limits that are no longer - true are deleted, not kept as history in source. -- **The predicate.** A `pollhammer` scenario runs one host thread that calls - every entry point which touches the gate, in a yield loop, while another - thread holds an idle poll. It runs once on a clean runtime and once after a - refused call has recorded an error. The poll must stay for its timeout, - within a generous margin. K1, K2 and a "returns on any wake" revert must die - there. -- **The seam itself** gets a revert entry and a loosening entry: a seam that - records the argument instead of the computed wait must die. The release shape - must still fold it away. - -To Hands (Sonnet) as the seventh fix batch. - -**The seventh fix batch landed** (Sonnet), `66830e9` and `b8fad13`. - -What was added: - -- **`waitseam`** checks the wait by equality. A dev-only seam, gated like the - hold seam and folded away in release, records the timeout the bridge hands - its condition wait. The scenario asserts it equals the host's argument for - 0 to `INT32_MAX` and over 40 polls on a single runtime. For - `timeout_ms <= 0` the bridge never reaches the wait, so the scenario asserts - the seam was left untouched. That is also what kills the loosening "records - unconditionally". The bridge documents no maximum below `INT32_MAX`. -- **`pollhammer`** covers the predicate. It runs every gate-touching entry - point in a yield loop, both on a clean runtime and after a recorded error. - -What was removed: - -- the fastest-of-ten mechanism; -- the tight tolerances, replaced by one generous 800 ms margin; -- the stated-limits table; -- **the Windows priority raise.** - -Which scenario kills which mutant: - -- K1, K2 and the "returns on any wake" revert die on `pollhammer`. -- Every derived wait from passes 5 to 7 dies on `waitseam`. That includes the - quantum rounds, the small-timeout zero, doubling after the 12th poll, - `t+25` and `min(t,7275)`. All of them are now deterministic. -- S5's `>1500` hold guard dies on `holdtimeout`, with a probe at 1600 ms. -- The seam's revert and loosening die on `waitseam`. - -Harness results, with restores matching `checkout-index`: - -| Target | Killed | Survived | Skipped | Control | -|---|---|---|---|---| -| win32-x64 | 39 | 0 | 2 | green | -| linux-x64 | 41 | 0 | 0 | green | - -Stress: 5 rounds with 16 burners on each target, all green. Worst overshoot -was 325 ms on Windows and 26 ms on Linux. - -**Scar:** the stress cleanup used `taskkill /IM node.exe /T`. That killed every -Node process on the shared host. The rule now in the Eureka skill is to kill -by PID. **Soul's eighth pass dispatched** (Opus). - -**Soul's eighth pass, 2026-09-22** (Opus). Hands' numbers reproduced: 39 killed -on Windows and 41 on Linux. Restores matched `checkout-index`. A forced red -control was reported red. The seam records the same evaluation that the wait -receives, and it folds out of release: there are no debug exports, and the -release macro is plain `(timeout_ms)`. The wall-clock layer is not vacuous. It -kills every shortening, and on `pollhammer` it kills K1, K2 and Soul's new P2 -and P3. **Cut 3 still does not close.** The method moved the gaps outward. - -- **F1, medium: nothing runs the timeout rule against the release build.** - The only configuration without asserts runs `closerace` alone, and no CI - builds the bridge. The seam macro creates a release-only site at `:108`. - Mutant R1 changes the `#else` to `((timeout_ms)/10)` and survives by - construction. Run against the release build, `polltimeout`, `pollbusy` and - `pollhammer` each kill it. -- **F2, medium: the seam records the integer, not the duration handed to - `wait_for`** (`:1201-1202`). Each of these survives on both targets: - - `ms(RECORD(t)) + 600ms`; - - `max(ms(RECORD(t)), 500ms)`, which turns a 15 ms realtime poll into - 500 ms; - - a 1/909 duration ratio. - - Only lengthening survives, since the timed layer's early tolerance kills - any shortening. -- **F3, medium: the predicate is covered only on a runtime with no live - objects.** `|| !runtime->connections.empty()` survives on both targets. With - one open connection, an idle `poll(1000)` returns in 0 ms, so any host with a - connection busy-spins. -- **F4, low-medium: a zero-timeout poll is never timed.** `sleep_for(500ms)` - on `timeout_ms == 0` survives, as does a 300 ms spin. A game loop that polls - with 0 every frame would stall. No negative timeout is probed, and -1 would - collide with the seam's sentinel value. -- **F5, plausible, low:** `closerace` goes red under load on Windows in 4 of 5 - rounds. It is starved of CPU, not the bridge. It fails loud, but it makes - the control flaky. -- **F6, low:** stale prose: the 1300 ms history at `mutate-cultmesh.mjs:763`, - a scenario list missing `pollhammer`, and a header §7 intro that still says - "quiesce only". - -**Self's rulings, 2026-09-22:** - -1. **F1.** The harness gains a plain **release** configuration on both - targets. It runs `polltimeout`, `pollbusy`, `pollhammer` and the new - zero-timeout check against the release library, and R1 goes in as an - entry. The release build is the thing that ships, so a rule the release - build never runs is not pinned. -2. **F2.** The seam wraps **the whole argument to `wait_for`**, meaning the - duration expression. It records that duration's count, and `waitseam` - asserts it equals `milliseconds(host argument)`. W1, W2 and W3 go in as - entries. -3. **F3.** `pollhammer` also runs against a runtime that holds live objects: - an open connection to an unroutable address, plus a listener and a stream - where the fixture can make them. P1 goes in as an entry. -4. **F4.** Any `timeout_ms <= 0` returns within a small bound, timed with a - generous margin. A negative timeout is probed. The seam's sentinel moves to - a value that cannot collide, or becomes a separate "recorded" flag. Z1 and - Z2 go in as entries. -5. **F5.** `closerace`'s fixture gets whatever it needs to survive - starvation, such as barriers or longer windows, without weakening what it - checks. Prove it with 5 rounds under 16 burners on Windows. -6. **F6.** Fix the prose. - -**Superseded the same day, 2026-09-22, by the operator's retirement of the -mutation harness.** The operator's words: *"The manual mutation testing turned -out to be a terrible idea, lots of guarding the shape of the code instead of -the behavior. Better to have nothing than a harness that punishes -refactoring."* `scripts/mutate-cultmesh.mjs` and every committed suite are -deleted, with no fallback. Four of the six rulings above named harness entries -(R1, W1–W3, P1, Z1, Z2); those clauses are dead text. What each ruling -protected is not: - -1. **F1 stands as behaviour.** The scenarios gain a **release** configuration - on both targets, run against the release library. The release build is the - thing that ships, so a rule the release build never runs is not pinned. - Without the harness there is no R1 entry; the release path is proven by the - scenarios themselves going red when the seam's `#else` shortens the wait, - which Soul checks by hand on a scratch copy. -2. **F2 stands.** The seam wraps the whole argument to `wait_for` — the - duration expression — and `waitseam` asserts it equals - `milliseconds(host argument)`. The assertion is the pin; W1–W3 are Soul's - hand probes, not committed entries. -3. **F3 stands.** `pollhammer` also runs against a runtime holding live - objects: an open connection to an unroutable address, plus a listener and a - stream where the fixture can make them. -4. **F4 stands.** Any `timeout_ms <= 0` returns within a small bound, timed - with a generous margin. A negative timeout is probed. The seam's sentinel - moves to a value that cannot collide, or becomes a separate recorded flag. -5. **F5 stands**, and its 5 rounds under 16 burners run **on Yggdrasil**. The - burners that motivated this ruling are what froze the operator's - workstation on 2026-09-22; no CPU burner runs on Starfire again. -6. **F6 widens.** The prose fix now includes removing every reference to the - deleted harness, not only the stale 1300 ms history and the scenario list. - -**There is no mutation evidence for the bridge and there will not be one -committed.** It is C++, and no ecosystem mutation tool is wired here. The -bridge's rules are pinned by behavioural scenarios at the layer that decides -them; a mutant is a thing Soul tries by hand on a scratch copy and throws away. - -**The eighth fix batch landed** (Sonnet) at `5646ff7`, before the retirement, -and the harness deletion followed at `796433f`. Both are on -`hands/cultmath-erf`, not yet on `main`. What landed: - -- the seam wraps the whole duration handed to `wait_for`, and its "unset" - sentinel became a separate `debug_wait_recorded` flag, so a recorded `-1` - cannot read as nothing recorded; -- `pollhammer` runs against a runtime holding a live connection to an - unroutable address (RFC 5737 TEST-NET-1), a stream on it, and a real - never-connected listener; -- a `zerotimeout` scenario pins that `timeout_ms <= 0` returns within 50 ms, - probing 0, -1 and `INT32_MIN`; -- `closerace`'s settle waits for `debug_peak_calls` to reach the poller count - instead of sleeping a guessed duration; -- `kGenerousLateToleranceMs` narrows from 800 ms to 400 ms, so the wall-clock - layer catches arithmetic applied outside the seam's own argument — - `waitseam`'s equality check structurally cannot see that. - -The release configuration and the seven mutation entries (R1, W1–W3, P1, -Z1–Z2) landed **inside the harness**, and went out with it two commits later. -The scenario runner and every seam survived untouched: the README and the dev -Dockerfile now document building with `-DCULTMESH_QUIC_BUILD_TESTS=ON --DCULTMESH_QUIC_DEBUG_ASSERTS=ON` and running each scenario as a direct -argument to the binary. **The release configuration is therefore owed again as -a plain build, not as a harness target**, and R1's substance — that the -release macro is unexercised — is a Soul hand probe now. - -**The Linux leg ran on Yggdrasil, 2026-09-22** (Sonnet), against -`hands/cultmath-erf` HEAD, in an image built from the committed -`scripts/quic-native-linux-dev.Dockerfile` (pinned `debian:13`, tagged by -content hash `cultlib-quic-native-dev:13d7fbd109fc`). No repo change was -needed and none was made. - -- **Asserts build, all ten scenarios pass**, 20 rounds each: `closerace`, - `holdclose`, `holdtimeout`, `polltimeout`, `pollbusy`, `pollhammer`, - `zerotimeout`, `payloadfit`, `latecall`, `waitseam` (11 probes, 40 repeated - polls on one runtime). -- **Release build, asserts off, all four required scenarios pass**: - `polltimeout`, `pollbusy`, `pollhammer`, `zerotimeout`. -- Worst overshoot anywhere was 4 ms, against `kGenerousLateToleranceMs` of - 400 ms. **F1 is closed on Linux**: the build that ships now runs the timeout - rule. - -**A defect in the README's own worked example, found by running it.** The -example builds the test binary and runs it directly, and every scenario dies -with `libmsquic.so.2: cannot open shared object file`. Both the bridge and the -test executable carry `INSTALL_RPATH "$ORIGIN"` and expect the MsQuic runtime -beside them in `bin/`, but nothing puts it there for a test build — only -`build-quic-native.sh`'s packaged output directory gets that copy. Hands -worked around it with a `cp` after each build and correctly did not treat it -as a bridge defect. **This is the documented path, written in `796433f` when -the harness was deleted, and it has never worked.** A documented path that -fails on first use is worse than no documentation: it was published in the -same commit that removed the working one. - -**Still outstanding for Cut 3:** - -- The **Windows release configuration**. It lived inside the harness and left - with it, exactly as Linux's did; Linux has now been re-run without the - harness and Windows has not. -- **F5's stress under load cannot be run on Windows at all.** It wants five - rounds with sixteen CPU burners, and those burners are what froze the - operator's workstation on 2026-09-22. `closerace` is Windows-sensitive (it - went red under load there in four of five rounds), so the check matters most - on the platform where it is now forbidden. **This is an operator fork, not - something to route around:** either Windows stress finds a host that is not - Starfire, or the check is accepted as unproven on Windows and recorded that - way. - -**Cut 1 Soul findings, 2026-09-16.** Held: every test count, both negative -greps, all twelve mutations rerun and killed, the control catching a -truncated write, the equivalent mutant confirmed (Node 24 WebCrypto refuses -every non-64-byte P1363 signature tried, across forty keys), the separator -byte matching C#, and the TypeScript-signs-C#-verifies direction proven by -a probe over adversarial inputs, which nothing in the repo yet pins. Found: - -- **The mutation runner leaves the last mutant compiled in `dist/`**, which - is git-ignored, and the browser tests then pass against it. A stale asset - impersonating logic, the doctrine's own trap. Medium. -- **The C# half of the vector check never runs in CI.** The only `dotnet - test` of that project is filtered to one lease test; a C# transcript - change would leave the committed vector self-consistent and nothing red. - Medium. -- **`verifyP256` fails closed on Node `Buffer` inputs**: `.slice().buffer` - on a pooled buffer is the whole pool. Internal callers allocate their own - bytes, so it is safe today and wrong for the Cut 4 consumer. Medium. -- **The `protocolIds` sort is untested**: the vector's ids were written - sorted, so removing the sort survives. Low-medium. -- **Four edge divergences from the C# rules**: an empty signature is - "unsigned" in C# and "invalid" in TypeScript; C#'s loopback set is wider - (`127.0.0.2`, and the TypeScript test pinned the narrower answer); root - lookup and validity window check in opposite orders; duplicate root key - ids throw in C# and take-first in TypeScript. All aligned to C# in the fix - batch. -- **The negative grep pins names, not structure**: a local shim under - another name passed every browser test and both greps. A guard-strength - note, recorded. -- **A killed runner leaves the mutant in the tree**; a sidecar restore is - added, as Epiphany's harness has. -- **`cultmesh-ts` resolves modules with `moduleResolution: "Node"`**, which - ignores `exports`, so `cultnet-ts/authority` is unresolvable from that - package until Cut 4 changes it; the root re-export covers it today. - -**The Cut 1 fix batch landed** at `c2ffc32` (`verifyP256` copies its bytes; -empty or whitespace signature is unsigned; root lookup before the validity -window; duplicate root key ids refuse at first use; the loopback set is -C#'s, probed through the public policy: `127.0.0.0/8` in any spelling -`URL` normalises, `::1`, the IPv4-mapped `::ffff:7f00:1`, `localhost`, -`loopback`; `localhost.`, `0.0.0.0`, `::` and `::ffff:127.0.0.2` are not), -`1590145` (`scripts/sign-cultmesh-authority-vectors.mjs` writes -`contracts/cultmesh/authority-route-vectors.ts-signed.json` over adversarial -inputs and a C# test verifies it; a one-character change to the committed -signature fails that test), `cf1203a` (`cultmesh-portability.yml` runs -`dotnet test --filter AuthorityProof` on both OSes on every push; the -interop workflow was Windows-only and main-only) and `0e4f479` (the runner -rebuilds `dist/` after the final restore and asserts a sentinel from the -restored source; a sidecar of the original bytes is written first and -repaired from at the next start). Twenty mutations, twenty killed. Tests -74, 17, 10; closure smoke four tarballs. - -Scars from the batch: **the runner's two multi-line anchors matched nothing -on a CRLF checkout** (the tree is `autocrlf=true`, HEAD is LF, the anchors -were `\n`), so the runner would have thrown at those entries; the earlier -"killed" claims for the transcript-order and nonce mutations came from an -LF-on-disk state. Anchors are now normalised to the file's EOL. Fixes 1 and -2 share one commit because Hands had made both before committing. -**Still open after the batch:** the browser client's local-development -short-circuit at `packages/cultmesh-browser/src/index.ts:915` tests -`!route.certificate` where the shared verifier now treats an empty -signature as unsigned, so such a route passes the verifier and is then -asked for a provider proof, where C# would not ask. **Landed** at `a801e0a`: -`isUnsignedCertificate` is exported from the shared module and used by the -verifier and the browser's session check, with a browser test for the -blank-signature loopback route, the signed loopback route that still needs -a proof, and the blank-signature remote refused in both modes. Twenty-one -mutations, twenty-one killed. Tests 74, 18, 10. - -**Second Soul pass, 2026-09-16.** Held: byte copy, root before window, duplicate -root ids refusing on the unsigned path in either insertion order, the sort -pinned, the U+001F separator on both sides, 33 of 39 field tampers failing the -C# test with the survivors explained, the runner repairing itself after a -SIGKILL and leaving `dist/` free of every mutant string, all 21 anchors -matching once on both LF and CRLF copies, and every test and grep. Cut 1 -closes on its stated invariants. Four edge divergences from the C# rules -remain, with one guard gap, to be fixed before Cut 4 makes a non-C# signer -real (in Hands): - -- **`isLoopbackEndpoint` is not `Uri.IsLoopback`**: 15 of 90 probed - endpoints disagree. Seven are reachable and TS-wider (a trailing dot on - the host, `ws:` without `//`, a backslash separator, fullwidth characters, - a tab), because `URL` normalises what C# refuses; eight are C#-wider and - unreachable in the browser (zone ids the `URL` parser throws on, - `file:`/`mailto:` schemes the filter drops). The doc comment claimed - equivalence. Low: local-development only. -- **`trim()` is not `char.IsWhiteSpace`** at U+0085 (C# whitespace, TS not) - and U+FEFF (TS trims, C# does not), so a signature of either is - "unsigned" on one side and "signed" on the other; both are carriable on - the rendezvous wire. Low, fail-open only where an absent certificate is - already accepted. -- **C# cleans the route before the transcript and TS transcribes raw**: - duplicate or padded protocol ids, padded generation and padded key ids - verify in C# and refuse in TS, and an absent protocol list gets a default - in TS and `""` in C#. Latent while every signer is C#; live at the first - TS signer. Low-medium. -- **Two C# refusals collapse into one TS message** (not base64; not P1363). - Low. -- **A local `=== ""` shim beside the shared call in the browser survives - every test**, because no browser test uses a whitespace signature; the - name grep is silent by construction. Low-medium. -- **No workflow runs the `cultnet-ts` tests on Linux**, and the interop - workflow is Windows-only and main-only; on a `codex/**` push only the C# - half of the vector check runs. Low. -- Recorded, not a defect: the TS-signed vector is not reproducible - byte-for-byte (random `k`); the C# test pins that the committed bytes - verify under current rules, and the TS test pins that the committed file - verifies under the current module. - -**The edge-alignment batch landed** (Opus) at `e67e695` (C#'s whitespace -set through three named helpers; the two signature refusals split to C#'s -texts), `c4c2330` (route fields cleaned before the transcript as the C# -constructors clean them: trim, drop blanks, distinct, sort; generation and -key ids trimmed; a null or empty protocol list transcribes as `""`, and the -TS default that wrote bytes the reference never writes is gone; `verseId` -deliberately raw, because `CanonicalRoute` passes it raw), `803ee12` (a -raw-endpoint pre-check refusing the spellings WHATWG `URL` repairs and -`System.Uri` does not; loopback disagreements 15 → 6, all six documented -unreachable), `dfe582e` (the browser's local reading of "unsigned" killed -by a whitespace-signature session test; the runner mutates the browser -target too, with per-target sidecar, control, sentinel and post-restore -assertion) and `0dfef0e` (the TypeScript authority tests in CI on both -operating systems). Soul's probes rerun: 88 endpoints, zero reachable -disagreements; 34 code points, zero; five signature shapes, byte-identical -messages; fifteen field tampers, zero mismatches against C#'s answers. -Tests 77, 18, 79, 10; closure smoke four tarballs; 33 shared and one -browser mutation killed. - -Two scars from the batch, both recorded because they are the kind a -postmortem should carry: **the runner's sidecar bit twice**, a throw inside -the mutation loop leaving it behind so the next run "repaired" from stale -bytes and silently reverted two fixes Hands had just made (the sidecar is -now removed in the `finally` immediately after the verified restore); and -**a literal NUL byte was committed inside a regex** at `803ee12`, which made -git classify the file as binary, stop normalising its line endings, and -diff the whole file as a rewrite, escaped at `dfe582e`. Residuals named, -not guessed at: `isProtectedEndpoint` did not get the pre-check and no -probe covers protected-scheme spellings; a negative priority throws in C# -and transcribes in TS, unreachable while only C# signs; two browser -mutations survive because the verifier refuses those routes first. - -**Third Soul pass, closing.** Held: all 34 whitespace code points in the -browser path too, with C#'s `VerifySessionProof` answer as the -expectation; the three signature refusals byte-identical; cleaning parity -in both directions with fresh keys (nine of nine each way, a padded -`verseId` refused cross-wise, C# confirmed not to trim it); 88 loopback -spellings with only the documented unreachable disagreements; the runner's -control writing through the same path as its mutations; CI's job, matrix, -triggers and setup order as promised; every suite, the closure smoke, both -greps. **Cut 1 is closed; Cut 2 may start.** Three residuals go with Cut 2's -Hands pass: - -- **`isProtectedEndpoint` lacks the raw-shape pre-check** that - `isLoopbackEndpoint` got, so eleven spellings C# refuses as unparseable - are accepted and dialled after repair by `URL`. A trusted signer would - have to emit a malformed endpoint, so security holds and the parity - invariant does not. Five lines. Low-medium. -- **A literal U+001F byte sits in the separator string** in source and - twice in the tests, where C# spells it ``; git does not treat it as - binary, but any editor that strips C0 controls changes every signature - with no visible diff. The NUL scar one rank lower. -- **The sidecar footgun is narrowed, not removed**: a SIGKILL still leaves - it, and an edit made before the next run is overwritten by the repair. - Repair must refuse when the file matches neither the sidecar nor the - mutant. - -Two corrections to this map's own record of the batch: **the NUL scar was -overstated**, since the byte sat past git's binary sniff and no whole-file -rewrite exists in history, only the normalisation path saw it and the blob -was LF-only anyway; and **CI was not green throughout the batch**, two of -its commits failing an unrelated Eve checkpoint job before the authority -steps existed, green from `0dfef0e`. - -Seen by Hands, recorded for section 15: `packages/cultnet-ts/src/generated/swarm-contracts.generated.ts` -is rewritten with LF by every build while the tree is `autocrlf=true`, so -it shows modified with zero content diff after any build; the generator's -line endings and the repo's normalisation disagree, and every pass discards -the churn by hand. - -Anchor: repo `F:\Projects\CultLib`, branch `main`, HEAD -`8d8ad568aa280bac34bf123503a9e38c175eb558`. The three commits since the -original anchor `f2cd2eb4` (`bf9f6f7`, `3c29fc9`, `8d8ad56`) touch only this -document `(probe: git log --stat)`, so every `file:line` below, read against -`f2cd2eb4`, is still exact at `8d8ad568`. If HEAD moves, re-anchor before -cutting. - -How to read this document: section 1 prices the largest liability first, as -the target requires, because if that price had turned out to dominate the -campaign, the cut order below would have been wrong. Section 2 is the probe -ledger; every mechanism claim in this map carries `(probe)` when it was -established by running something, or `(source read)` when it was established -by reading a file at a named line. Nothing is claimed from a package name or a -README. Section 3 is the consumer audit. Section 4 names rejected options and -why. Section 5 is the authority map for every ownership change. Sections 6-12 -are the cuts. Section 13 holds the operator questions; section 14 the -subtraction estimate; section 15 what could not be assigned. - -Two corrections to the target, recorded here rather than silently absorbed: - -- The target cites `src/GameCult.Mesh.Quic/CultMeshRealtimeTransports.cs`. - That file is `src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs` - (674 lines). `CultMeshRealtimeTransports.cs` exists, but in - `src/GameCult.Mesh/`, and holds the transport-neutral contracts - (`CultMeshRealtimeDelivery` at :8-13, `CultMeshRealtimeFrame` at :19-44, - `ICultMeshRealtimeTransport` at :46-52, - `ICultMeshRealtimeTransportConnector` at :55-63) `(source read)`. -- This map's first issue attributed the phrase "shipped through a registry" - to the target. The target contains no such phrase and no word "registry" - `(probe: rg)`; the reading was the map's, and it is withdrawn. What is - true: today only `@gamecult/cultcache-ts` has a registry publish job - (`.github/workflows/publish-packages.yml:20-24`, `:40-76`); `cultnet-ts`, - `cultmesh-ts` and `cultmesh-browser` have none, and every external consumer - reaches them by `file:` path (section 3). Q5 was ruled C on that Body: no - registry in this campaign, publication parked. - -## 1. The largest liability, priced first - -The target's biggest new surface is CultLib owning native MsQuic bindings for -Node on two platforms. The price depends on three things: what Node itself -offers, what the repo already owns, and how a native artifact reaches a -consumer host without a toolchain. Each was probed. - -### 1.1 What Node offers - -- Node `v24.15.0` on Starfire has no `node:quic`, with or without - `--experimental-quic` (`ERR_UNKNOWN_BUILTIN_MODULE`), and its internal - binding table has no `quic` entry (`No such binding: quic` under - `--expose-internals`). `process.versions` reports `openssl 3.5.5`, no - `ngtcp2`, no `nghttp3`, `napi 10` `(probe)`. Yggdrasil runs Node `24.14.1` - from NodeSource (`F:\Projects\gamecult-ops\inventory.md:660`) `(source read)`, - so the same absence holds on the deploy host. -- Conclusion: there is no first-party QUIC in this Node line. Anything that - works is native. - -### 1.2 What the repo already owns - -- `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp` (440 lines) is a - CultLib-owned C ABI over MsQuic: `cultmesh_quic_open/state/poll/error/close` - (`:309-440`). It negotiates ALPN `cultmesh-state-v1` (`:32`, `:341-344`), - uses deferred certificate validation and answers the pin decision through - `ConnectionCertificateValidationComplete` (`:239-263`), reassembles the - 1-byte-kind plus 4-byte-LE-length QUIC stream framing (`:123-157`), and - queues whole encoded frames for a polling host (`:400-418`). It is Windows - desktop only by construction: ``, ``, `` - (`:1-3`), `CryptHashCertificate2` for the pin (`:184-200`), - `__declspec` exports (`:18-22`), and the CMake build refuses non-Windows - (`CMakeLists.txt:4-6`) `(source read)`. -- The bridge is consumed by the Unity connector through `DllImport` of library - name `gamecult_mesh_quic_native` - (`src/GameCult.Mesh.Quic.Native/CultMeshNativeQuicRealtimeTransport.cs:259-277`), - which polls every 2 ms by default (`:14`, `:167-203`) `(source read)`. The - built DLL and `msquic.dll` 2.5.9 are committed at - `unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/` (last rebuilt in - `a0813c6`, 2026-09-14) `(probe: git log)`. -- The Windows build is scripted and pinned: `scripts/build-quic-native.ps1` - downloads `Microsoft.Native.Quic.MsQuic.Schannel` `2.5.9` by URL, checks its - SHA-256 (`:13-14`, `:27-41`), and builds with CMake `-A x64` (`:48-52`). - The last build on this machine used Visual Studio 17 2022 Build Tools, MSVC - 14.44 (`artifacts/quic-native-build/x64/Release/CMakeCache.txt`) `(probe)`. - `cl.exe` is not on PATH but Build Tools are installed, so the existing - script works here as-is. -- `tests/GameCult.Mesh.Quic.Native.Tests/GameCult.Mesh.Quic.Native.Tests.csproj:20-29` - builds that bridge before the test build and copies it beside the test - output `(source read)`. `scripts/build-unity-package.ps1:80-82`, `:143-147` - builds it again for the Unity package `(source read)`. - -Conclusion: CultLib already owns the MsQuic body for one platform, one role -(client), and one host (Unity). The cost of the target is extending that body -to a second role (provider), a second platform (Linux x64), and a second host -(Node), not creating a body from nothing. - -### 1.3 How Node loads it - -The decisive probe: from Node 24.15.0, with `koffi@3.3.0` installed into the -scratchpad, `koffi.load` loaded the committed `msquic.dll` then -`gamecult_mesh_quic_native.dll`, bound the five v1 exports by C prototype, -called `cultmesh_quic_open("127.0.0.1", 9, <64 zero hex>)` which returned `0` -with a non-null handle, observed `cultmesh_quic_state` move from `Connecting` -to `Failed` after 34 ms, read the bridge's structured error string through -`cultmesh_quic_error` ("shut down by the transport (status=0xFFFFFFFF800704D0, -error=0x1, msquic=F:\...\msquic.dll, events=event=1)"), got `-2` from -`cultmesh_quic_poll` on a failed client, and closed cleanly `(probe: -scratchpad/koffi-probe/probe.js)`. MsQuic ran its worker threads inside the -Node process and the poll model crossed the boundary without any callback -into JS. - -What that proves and what it does not: - -- Proves: the existing CultLib C ABI is directly loadable and drivable from - the Node line in question with no addon build, no Node headers, no MSVC at - install time, and no per-Node-version artifact. The `.async` call form - exists on bound functions (`typeof state.async === "function"`), which is - the mechanism for a blocking wait off the JS thread `(probe)`. -- Does not prove: a QUIC handshake succeeded (port 9 was intentionally dead); - that the provider role works (the bridge has none); that Linux works (the - bridge is Windows-only). Those are cut deliverables, not probe results. - -koffi facts used by the design, from its shipped documentation, not from -memory: JS callbacks always run on the main thread and calls from other -threads are queued to it, with an explicit deadlock warning if the main thread -blocks on a secondary thread (`node_modules/koffi/doc/callbacks.md:166-176`); -asynchronous calls run on a worker pool sized to the core count with a default -cap of 256 queued calls (`doc/misc.md:26`, `:37`); prebuilt binaries ship for -Windows x64 and Linux/glibc x64 among others, Node >= 16, no compiler needed -(`doc/index.md:17-38`) `(source read)`. Package weight: 1,713,562 bytes -unpacked, 87 files, MIT `(probe: npm view)`. The design below uses no JS -callbacks at all; the only crossing is one blocking `next_event` wait per -runtime issued through `.async`, so the deadlock class in `callbacks.md:176` -cannot occur. - -### 1.4 How it reaches Linux - -- `libmsquic` is published by Microsoft for Debian 13 (trixie) at - `packages.microsoft.com/debian/13/prod`, versions including `2.5.9` - (`pool/main/libm/libmsquic/libmsquic_2.5.9_amd64.deb`, 2,906,420 bytes, - SHA-256 `1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba`, - `Depends: libssl3t64, libnuma1, libxdp1, libnl-route-3-200`) `(probe)`. - Yggdrasil is Debian 13 (`gamecult-ops/inventory.md:356`) `(source read)`. - The same `2.5.9` pinned for Windows exists for the deploy host. -- The NuGet packages carry Windows binaries only: `Microsoft.Native.Quic.MsQuic.OpenSSL` - 2.5.9 contains `bin/{x64,x86,arm64}/msquic.dll`, `lib/*/msquic.lib`, - and headers `msquic.h`, `msquicp.h`, `msquic_winuser.h`; no `.so`, no - `msquic_posix.h` `(probe: nupkg listing)`. The Linux build needs - `msquic.h`, `msquic_posix.h`, `quic_sal_stub.h` from the msquic source - tree at tag `v2.5.9`; all three are fetchable at - `raw.githubusercontent.com/microsoft/msquic/v2.5.9/src/inc/` (HTTP 200, - 79,280 / 17,909 / 4,731 bytes) `(probe)`. GitHub releases `v2.5.9` carry only - `*_test.zip` bundles; `v2.5.11`, `v2.6.1` carry no assets `(probe)`, so the - deb is the only distribution-grade Linux runtime for this version. -- Build host for Linux: Docker Desktop on Starfire runs a `linux/amd64` engine - `(probe: docker version)`, so a `debian:13` container is a Linux build host - for the development loop. The release artifact must come from a Linux host - the deploy path trusts: Yggdrasil itself, or a CI runner. Q6. -- Windows toolchain: MSVC Build Tools 2022 present; CMake 4.3.2; also a MinGW - gcc from StrawberryPerl on PATH `(probe)`. The map keeps MSVC for Windows - because the committed Unity DLL is built with MSVC `/Brepro` for - reproducibility (`CMakeLists.txt:17-19`) `(source read)`; mixing toolchains - for one DLL is a liability with no capability behind it. - -### 1.5 Price verdict - -The binding does not dominate the campaign. The C body exists; the Node load -path is proven with one 1.7 MB MIT dependency and zero addon tooling; the -Linux runtime is a pinned distro package on the exact target OS; the Windows -build is already scripted. The real cost is the C++ rewrite from a -client-only, Windows-only, per-client poll bridge to a runtime-neutral, -two-role event bridge (section 8), estimated at roughly 1,000 lines of C++ -replacing 440, plus a Linux build script and CI job. That is one cut, not the -campaign. - -What would have changed this verdict: if the bridge had needed JS callbacks -from MsQuic worker threads (it does not; the wait/event model avoids them), or -if `libmsquic` had not been available for Debian 13 at the pinned version (it -is), or if the FFI load had failed on Node 24 (it did not). - -## 2. Probe ledger - -| # | Claim | Method | Evidence | -| --- | --- | --- | --- | -| P1 | Node 24.15.0 has no `node:quic` and no internal quic binding | probe | `require('node:quic')` -> `ERR_UNKNOWN_BUILTIN_MODULE` with and without `--experimental-quic`; `--expose-internals` -> `No such binding: quic`; `process.versions.ngtcp2 === undefined` | -| P2 | The committed CultLib MsQuic bridge loads and runs from Node 24 via koffi | probe | `scratchpad/koffi-probe/probe.js`: open rc 0, state 2 after 34 ms, structured error string, poll -2, clean close | -| P3 | koffi ships Windows x64 and Linux glibc x64 prebuilds, needs no compiler, Node >= 16 | source read | `koffi/doc/index.md:17-38` | -| P4 | koffi async calls run on a worker pool; JS callbacks only on the main thread | source read | `koffi/doc/misc.md:26,37`; `koffi/doc/callbacks.md:166-176` | -| P5 | koffi is 1.7 MB unpacked, MIT | probe | `npm view koffi dist.unpackedSize dist.fileCount`; `package.json:27` | -| P6 | MSVC Build Tools 2022 (14.44) and CMake 4.3.2 are present; the last bridge build used them | probe | `artifacts/quic-native-build/x64/Release/CMakeCache.txt`; `cmake --version` | -| P7 | `libmsquic 2.5.9` exists for Debian 13 amd64 with its SHA-256 and dependency set | probe | `packages.microsoft.com/debian/13/prod/dists/trixie/main/binary-amd64/Packages` | -| P8 | Yggdrasil is Debian 13 with Node 24.14.1 from NodeSource | source read | `gamecult-ops/inventory.md:356`, `:660` | -| P9 | MsQuic NuGet OpenSSL 2.5.9 carries no Linux binary or posix header | probe | nupkg listing | -| P10 | MsQuic POSIX headers are fetchable at tag v2.5.9 | probe | HTTP 200 for `msquic.h`, `msquic_posix.h`, `quic_sal_stub.h`, `msquic_winuser.h` | -| P11 | The pinned `msquic.h` has `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`, deferred/indicated certificate flags, `QUIC_SEND_FLAG_FIN`, `QUIC_STREAM_EVENT_SEND_COMPLETE`, API version 2 | source read | `artifacts/dependencies/msquic-schannel-2.5.9/package/build/native/include/msquic.h:123,132-133,142,246,394-398,1290,1530,1850` | -| P12 | Docker Desktop on Starfire runs a linux/amd64 engine | probe | `docker version --format '{{.Server.Os}}/{{.Server.Arch}}'` -> `linux/amd64` | -| P13 | `openssl` 3.5.5 CLI is available on Starfire (mingw) | probe | `which openssl` | -| P14 | npm names `cultmesh-ts`, `cultnet-ts`, `cultmesh-browser`, `@gamecult/cultmesh-ts`, `@gamecult/cultnet-ts`, `@gamecult/cultmesh-quic-native` are all unclaimed | probe | `npm view version` returned nothing for each | -| P15 | Only `@gamecult/cultcache-ts` and the three Python packages have publish jobs | source read | `.github/workflows/publish-packages.yml:20-24,40-76` | -| P16 | Every external TS consumer uses `file:` paths; none uses a registry version | probe (Explore agent over `F:\Projects`) | section 3 | -| P17 | The StreamPixels deploy path extracts a CultLib tarball beside the app and runs `pnpm install --frozen-lockfile` and `pnpm build` on Yggdrasil | source read | `gamecult-ops/scripts/deploy-streampixels-preview.sh:21,95-103` | -| P18 | The Unity native connector refuses any endpoint without a 64-hex `cert-sha256` pin | source read | `CultMeshNativeQuicRealtimeTransport.cs:103-105` | -| P19 | C# `IsProtected` accepts `wss`, `https`, or any scheme containing `quic`; the browser verifier accepts only `wss` | source read | `CultMeshAuthorityProof.cs:156-162`; `cultmesh-browser/src/index.ts:965` | -| P20 | C# and browser canonical route transcripts are field-for-field identical | source read | `CultMeshAuthorityProof.cs:275-292` vs `cultmesh-browser/src/index.ts:979-996` | - -## 3. Consumer audit - -Method: every `package.json` under `F:\Projects` (excluding `node_modules`, -`dist`, `dist-test`, `.git`, `bin`, `obj`, `Library`, `Temp`, and CultLib -itself and its worktree copies) checked for the four package names, plus a -grep of every `*.ts/*.tsx/*.mts/*.cts/*.js/*.mjs/*.cjs` import or require of -them, with symbols read from the import lines. Vendored copies inside -`Heimdall/vendor/CultLib`, `VoidBot/vendor/cultcache-ts`, and -`gamecult-ops/.artifacts/*` were classified as library-internal, not -consumers. - -Result: - -| Package | Repos | Source files | Symbols actually imported (union) | -| --- | ---: | ---: | --- | -| `cultnet-ts` | 11 (StreamPixels, Heimdall, AetheriaEve, AetheriaEve-cultlib-admission, VoidBot, Stonks, Vili, weksa, Sai, EvePlugins, Bifrost) | 28 | `CultNetRudpSession`, `decodeRudpPacket`, `encodeRudpPacket`, `encodeCultNetMessageForWire`, `parseCultNetMessage`, `invokeCultNetOperation`, `startCultNetOperationServer`, `defineCultNetDocumentBinding`, `CultNetDocumentRegistry`, runtime-presence helpers, and message/packet types | -| `cultnet-ts/contracts` | 0 | 0 | one esbuild alias only (`AetheriaEve-cultlib-admission/scripts/verify-aetheria-browser-provider.mjs:48`); `cultmesh-browser` is its only importer and is inside CultLib | -| `cultmesh-ts` | 7 (StreamPixels, Heimdall, AetheriaEve and its worktree, Stonks, weksa, Odin and its worktree) | 10 | `CultMesh`, `cultMeshRectFromBounds`, `cultMeshViewportRequest`, and types (`CultMeshRudpEndpoint`, `CultMeshDocumentCatalog`, `CultMeshViewportRequest`, diagnostics types) | -| `@gamecult/cultcache-ts` | 1 (Heimdall) | 5 | `SingleFileMessagePackBackingStore`, `defineDocumentType`, `CultCacheEnvelope` | -| `cultcache-ts` (old unscoped name) | 8 | 16 | `CultCache`, `SingleFileMessagePackBackingStore`, `defineDocumentRegistry`, `defineDocumentType`, `/inspection` | -| `cultmesh-browser` | 1 (AetheriaEve and its worktree) | 1 | `CultMeshBrowserClient`, `CultMeshBrowserOdinRendezvous`, `decodeCultNetPayload` | - -StreamPixels specifically: `apps/service/package.json:25-27` declares -`cultcache-ts`, `cultmesh-ts`, `cultnet-ts` as `file:../../../CultLib/packages/`; -`apps/service/src/verse-state.ts:4-6` imports `defineDocumentType`, -`CultMesh`, `defineCultNetDocumentBinding`; `apps/service/src/idunn-rudp-health.ts:2-7` -imports `encodeCultNetMessageForWire`, `encodeRudpPacket`, and two types. -Nothing else in StreamPixels imports these packages. No TS/JS file anywhere -under `F:\Projects` references `cultmesh-state+quic` or `CultMeshRealtime`. - -Sizing consequences: - -- Nothing this migration adds has a consumer yet. The QUIC surface is new - API; no existing export is reshaped, so no external repo changes are - required by the CultLib cuts. StreamPixels adoption is consumer work after - release (target, "Not in this migration"). -- The `verifyAuthorityRoute` family in `cultmesh-browser` is module-private - (not exported at `cultmesh-browser/src/index.ts:955`, `:979`, `:998`, - `:1012`, `:1028`), so moving it (Q2) changes no external import. The three - symbols AetheriaEve imports stay. -- `cultnet-ts/contracts` has no external consumer; adding a sibling subpath - export for the shared verifier follows an existing, internally consumed - pattern rather than inventing a new one. -- The `cultcache-ts` naming split (8 repos on the old unscoped name, Heimdall - on `@gamecult/`) is pre-existing and not touched here, but it is the - precedent Q5 has to reckon with. - -## 4. Rejected options - -- **Node built-in QUIC.** Absent in Node 24 (P1). Not a choice. -- **`@matrixai/quic` 2.0.9.** A Rust/quiche-based QUIC stack with its own - stream, certificate and event semantics `(probe: npm view description)`. - Rejected because the target requires the bytes on the wire, the ALPN, the - stream-kind byte, the deferred certificate pin decision, and the close/abort - codes to match `GameCult.Mesh.Quic` and the Unity MsQuic connector; two QUIC - stacks would have to be proven equivalent at every one of those points, and - CultLib would own neither. Semantically wrong for this campaign, not merely - unfashionable. -- **`@fails-components/webtransport` 1.6.8.** libquiche behind a WebTransport - (HTTP/3) session model `(probe)`. Wrong layer: the realtime plane is raw - QUIC with ALPN `cultmesh-state-v1`, not HTTP/3. -- **`node-quic` 0.1.3.** A wrapper around an abandoned pure-JS QUIC `(probe)`. - Not TLS 1.3 MsQuic; rejected on the same grounds. -- **A Node-API addon (node-addon-api + node-gyp/cmake-js) wrapping MsQuic - directly.** Viable, but it creates a second C++ owner of MsQuic callback - semantics beside the Unity bridge, requires Node headers and MSVC on every - Windows build host and a Node-ABI artifact per platform, and buys nothing the - C ABI plus FFI does not (P2). Rejected as duplicate authority. If koffi were - ever removed, the cheapest replacement is a thin N-API shim over the same C - ABI, not a second MsQuic body. -- **A separate .NET provider process or a WebSocket shortcut.** Already ruled - out by the operator (target, line 15-17). -- **JS callbacks from MsQuic worker threads.** koffi supports them by queuing - to the main thread (P4), but it turns every MsQuic event into a - cross-thread hop and opens the documented deadlock class. Rejected in favour - of the event-queue/wait model the existing bridge already uses. -- **Building the Windows DLL with the MinGW gcc on PATH.** Present (P6) but the - committed Unity artifact is MSVC `/Brepro`; two toolchains for one DLL is a - reproducibility liability. Rejected. -- **Building the Linux artifact on Starfire and shipping it.** Cross-building - is explicitly forbidden by the target; a Docker `debian:13` container on - Starfire is a Linux host and is fine for the development loop, but the - release artifact comes from the builder the operator names in Q6. -- **Keeping `cert-sha256` verification in C.** The v2 ABI hands the DER - certificate to the host and waits for the decision, which is the "caller - hook" the target demands and removes the Windows-only - `CryptHashCertificate2` from the portable path. The v1 entrypoint keeps its - in-C pin check only as the Unity compatibility shim (section 8). - -## 5. Authority maps - -### 5.1 Native MsQuic bridge (`native/GameCult.Mesh.Quic.Native`) - -- Owner: `cultmesh_quic_native.cpp` owns MsQuic registration, configuration, - listener, connection and stream handles, TLS credential loading, the - 1-byte-kind + 4-byte-LE-length stream framing, and a single event queue per - runtime. -- Inputs: host/port, PKCS12 bytes and password for providers, the host's - certificate decision for clients, encoded frame bytes to send. -- Outputs: events (`listener_new_connection`, `connection_connected`, - `connection_certificate_received` with DER payload, `connection_shutdown` - with code/status/text, `stream_started` with kind, `stream_frame` with the - whole encoded frame, `stream_send_complete`, `stream_shutdown`); the bound - listener port; structured error text. -- Derived state: none that the host reads as truth. Connection counts, remote - addresses and MsQuic status codes are diagnostics. -- Forbidden writers: the bridge never decodes a CultMesh frame, never decides - delivery semantics, never coalesces, never accepts a certificate on its own - in the v2 path, never reconnects. The v1 shim is the only place a pin is - compared in C, for the Unity contract only. -- Shared paths: the Unity managed connector (v1 exports) and the Node binding - (v2 exports) use the same library, the same MsQuic runtime, the same - framing code. -- Deletion line: the per-client `Client` struct with its own registration - (`:51-63`, `:316-393`), Windows-only includes and export macros (`:1-3`, - `:18-22`), `CertificateMatchesPin` as the sole trust path (`:184-200`). - -### 5.2 TypeScript QUIC realtime plane (`packages/cultmesh-ts`) - -- Owner: `src/realtime-quic.ts` owns provider and consumer semantics: which - stream kind carries which delivery mode, the per-peer keyed coalescing - outbox for `LatestOnly`, the single ordered stream for `ReliableOrdered`, - `Unreliable` failing closed, the generation filter on receive, and the - certificate pin decision on the consumer. -- Inputs: bridge events; `CultMeshRealtimeFrame` objects from application - code; an advertised `CultMeshTransportCandidate`-shaped route from the - session manager; the consumer's trust policy. -- Outputs: encoded frames to the bridge; decoded frames to the application; - the advertised endpoint string for a provider (with `cert-sha256`). -- Derived state: connection health, connected peer count, stream ids. -- Forbidden writers: application code never opens a stream, picks an - endpoint, or writes a reconnect loop (target). `src/realtime-wire.ts` never - touches a socket. `src/realtime-quic-native.ts` (the koffi binding) never - interprets frame bytes. -- Shared paths: provider broadcast and consumer send both go through - `sendFrame` on the same transport object; the coalescing outbox is the only - path for `LatestOnly` on a provider, whether the caller is a test, a tick, or - a reconnect. -- Deletion line: none inside `cultmesh-ts` (there is no prior QUIC code); the - RUDP provider transport at `src/provider-rudp-transport.ts` is untouched. - -### 5.3 Shared TypeScript route verification (Q2) - -- Owner after the cut: `packages/cultnet-ts/src/cultmesh-authority.ts`, - published as subpath `cultnet-ts/authority`. It is the only TypeScript - implementation of: Odin root lookup, validity window, canonical route - transcript, canonical session transcript, P-256/P1363 verification, - loopback detection, and the protected-scheme rule. -- Inputs: a route view (Verse, authority runtime, endpoint, protocol ids, - priority, generation, certificate), a trust policy (`mode`, `odinRoots`, - `now`), a session-open request and an accepted message. -- Outputs: resolution or a thrown `Error` whose message is the C# reference's - for the same refusal. *(First written as "the same message texts the - browser package emits today (its tests pin them)"; Soul found the browser - tests pin two messages only, and the channel-protection text was false for - QUIC. Corrected 2026-09-16.)* -- Derived state: none. -- Forbidden writers: `cultmesh-browser` must not retain or regrow - `verifyAuthorityRoute`, `canonicalRoute`, `canonicalSession`, - `canonicalFields`, `verifyP256`, or `isLoopbackEndpoint`; `cultmesh-ts` must - not implement any of them; neither transport package may depend on the - other. Negative greps in section 6 pin this. -- Shared paths: the browser WebSocket client and the Node QUIC consumer verify - the same route bytes through the same function. -- Deletion line: `cultmesh-browser/src/index.ts:955-1072` and `:797-808`. -- Behavioural note, not a silent change: the protected-scheme rule becomes the - C# rule (`wss`, `https`, scheme containing `quic`; - `CultMeshAuthorityProof.cs:156-162`) instead of `wss`-only - (`cultmesh-browser/src/index.ts:965`). The browser client still refuses - non-`ws(s)` route endpoints at `:537-540`, so browser behaviour is - unchanged; the shared module simply stops being wrong for QUIC routes. - -### 5.4 Build and packaging - -- Owner: `scripts/build-quic-native.ps1` (Windows x64) and the new - `scripts/build-quic-native.sh` (Linux x64) own producing the bridge - artifact; the new `.github/workflows/quic-native.yml` owns producing the - release-grade artifact for both platforms (Q6 A) and attaching it to a - GitHub Release; the committed tree `packages/cultmesh-ts/native/` owns - delivery to consumers (Q5 C): whatever carries the package directory (a - `git archive`, an `npm pack` tarball, a pnpm `file:` copy) carries the - binaries with it, and `packages/cultmesh-ts/native/MANIFEST.txt` plus - `SHA256SUMS` own provenance (MsQuic version, build host, digest per file). -- Inputs: pinned MsQuic 2.5.9 (Schannel NuGet on Windows; `libmsquic` deb on - Linux) with SHA-256 checks; the bridge source; the pinned `debian:13` image - digest for the Linux builder. -- Outputs: `gamecult_mesh_quic_native.dll` + `msquic.dll` (win32-x64), - `libgamecult_mesh_quic_native.so` + `libmsquic.so.2` (linux-x64), the MsQuic - license, and the two provenance files. -- Derived state: `artifacts/` (git-ignored, `.gitignore:66`) is the - development-loop build output and never delivery; the Unity plugin copies at - `unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/` remain the Unity - package's own body (section 8, operator-only rebuild), not a source for - Node. -- Forbidden writers: no `postinstall` compile or download step in any - package; a consumer host never needs CMake, a compiler, or registry access - for the binding; no workflow commits binaries to `main` (the operator - commits from a Release asset whose digest the manifest records, Q8); - `realtime-quic-native.ts` never searches `node_modules` for a platform - package. -- Deletion line: the platform packages - `packages/cultmesh-quic-native/{win32-x64,linux-x64}` and - `optionalDependencies` from the ruling-A map are never created; the Windows - script is kept and the Unity package script keeps calling it. -- Parked seam (registry publication, later pass): the same `quic-native.yml` - artifacts are the payload a publish job would wrap; the reattachment point - is a `cultmesh-ts` job shaped like `publish-packages.yml:39-78` that depends - on the two build jobs, plus `optionalDependencies` in - `packages/cultmesh-ts/package.json` and a `node_modules` branch in the - resolver. None of that exists under C. - -## 6. Cut 1 (subtraction): one TypeScript route verifier - -Purpose: land Q2 before any QUIC code exists, so the Node consumer has one -owner to call and the browser package has nothing left to grow. - -Deletes (exact): - -- `packages/cultmesh-browser/src/index.ts:955-977` `verifyAuthorityRoute` - (23 lines), `:979-996` `canonicalRoute` (18), `:998-1010` `canonicalSession` - (13), `:1012-1026` `canonicalFields` (15), `:1028-1058` `verifyP256` (31), - `:1065-1072` `isLoopbackEndpoint` (8), `:797-803` `bytesToBase64` (7), - `:805-808` `base64ToBytes` (4). Total 119 lines deleted from the browser - package. `randomNonce` (`:1060-1063`) stays: it is browser-client behaviour, - not verification. - -Keeps and moves: - -- The deleted bodies move verbatim into - `packages/cultnet-ts/src/cultmesh-authority.ts` as exported functions, with - one edit: the protected-scheme test in `verifyAuthorityRoute` becomes - `isProtectedEndpoint(endpoint)` implementing the C# rule (P19). Types - `CultMeshBrowserIdentity`, `CultMeshBrowserRoute`, - `CultMeshBrowserP256PublicKey`, `CultMeshBrowserRouteCertificate`, - `CultMeshBrowserAuthorityTrustMode`, `CultMeshBrowserAuthorityTrustPolicy` - (`cultmesh-browser/src/index.ts:19-53`) move too, renamed without the - `Browser` infix (`CultMeshAuthorityIdentity`, `CultMeshAuthorityRouteView`, - `CultMeshP256PublicKey`, `CultMeshAuthorityRouteCertificate`, - `CultMeshAuthorityTrustMode`, `CultMeshAuthorityTrustPolicy`). - `cultmesh-browser` re-exports them under the old names as type aliases. -- `validateSessionAcceptance` (`:926-953`) stays in the browser package; it - compares WebSocket handshake fields. Its crypto lines (`:944-951`) are - replaced by a call to the new `verifyProviderSessionProof(request, endpoint, - providerKey, signature)` exported from the shared module. - -Adds: - -- `packages/cultnet-ts/src/cultmesh-authority.ts` (about 190 lines: the moved - 119 plus types and `isProtectedEndpoint`). Browser-safe by construction: it - may use only `globalThis.crypto.subtle`, `TextEncoder`, `atob`, `btoa`, - `URL`, `DataView`, `Uint8Array`. No `node:` import. -- `packages/cultnet-ts/package.json:7-18` gains an `"./authority"` export - beside `"./contracts"` (`:12-16`), same three-key shape. -- `packages/cultnet-ts/src/index.ts` re-exports the module's public names. -- `packages/cultnet-ts/test/cultmesh-authority.test.ts` (about 150 lines): - signs a route with `node:crypto` `generateKeyPairSync("ec", { namedCurve: - "P-256" })` and verifies through the shared module; one negative per rule - (expired, unsigned remote, wrong Odin root, mutated endpoint, wrong signature - length); and asserts `isProtectedEndpoint` accepts `wss://`, `https://`, - `cultmesh-state+quic://` and rejects `ws://`, `cultnet+tcp://`. -- A cross-runtime vector: `tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs` - gains a test that calls `CultMeshAuthorityProof.CreateSignedRoute` - (`src/GameCult.Mesh/CultMeshAuthorityProof.cs:221`) with a fixed test key - and writes `contracts/cultmesh/authority-route-vectors.json` (route fields, - Odin public key, signature) when `CULTMESH_WRITE_VECTORS=1`, and otherwise - asserts the committed file still verifies. The TS test verifies the same - file through the shared module. This is a shared-vector check, not - cross-process interop; it proves the transcript bytes, which is what Q2 - needs. - -Per-file changes: - -- `packages/cultmesh-browser/src/index.ts:1-17` add - `import { verifyAuthorityRoute, verifyProviderSessionProof, isLoopbackEndpoint, type ... } from "cultnet-ts/authority";` - (the package already imports `cultnet-ts/contracts` at `:2-17`, so - resolution is proven). `:536` and `:914` call the imported function - unchanged. `:944-951` call `verifyProviderSessionProof`. `:961` and `:965` - are gone with the function. -- `packages/cultmesh-browser/tsconfig.json` unchanged (`moduleResolution: - "Bundler"` already resolves the `exports` map). - -Verification: - -- `npm run test --workspace packages/cultnet-ts` (build + unit; pins the - shared verifier and the vector file). -- `npm run test --workspace packages/cultmesh-browser` (all 17 existing tests - at `test/cultmesh-browser.test.ts:29-472` must pass unchanged; they pin the - error strings and the refusal order, including "rejects a mutated or expired - signed route before opening a provider socket" at `:239-258`). -- `dotnet test tests/GameCult.Mesh.Tests --filter FullyQualifiedName~AuthorityProof` - (vector generation/verification; focused, one project). -- `node scripts/test-typescript-package-closure.mjs` (the packed - `cultmesh-browser` tarball must resolve `cultnet-ts/authority` from the - registry-shaped install at `scripts/test-typescript-package-closure.mjs:63-72`). -- Negative greps (must return nothing): - `rg -n "crypto\.subtle\.(verify|importKey)|function (verifyAuthorityRoute|canonicalRoute|canonicalSession|canonicalFields|verifyP256|isLoopbackEndpoint|isProtectedEndpoint|isUnsignedCertificate)" packages/cultmesh-browser/src packages/cultmesh-ts/src` - and `rg -n "cultmesh-browser" packages/cultmesh-ts/package.json packages/cultnet-ts/package.json`. - *(The name list grew by two after the fix batch: `isProtectedEndpoint`, - written fresh as the C# rule, and `isUnsignedCertificate`, the shared - reading of "unsigned" the browser's session check now uses. Soul noted - the grep pins names, not structure; a shim under another name passes it.)* -- Operator-only: none. - -## 7. Cut 2 (behaviour, pure TypeScript): the realtime frame codec - -Purpose: byte parity with `CultMeshRealtimeWireProtocol` before any transport -exists, proven in both directions with shared vectors. - -Deletes: none. - -Adds: - -- `packages/cultmesh-ts/src/realtime-wire.ts` (about 130 lines). Exports - `CULTMESH_REALTIME_ALPN = "cultmesh-state-v1"`, - `CULTMESH_REALTIME_CONNECTION_CLOSE_CODE = 0x43554c54n`, - `CULTMESH_REALTIME_STREAM_ABORT_CODE = 0x53544154n`, - `CULTMESH_REALTIME_RELIABLE_STREAM = 1`, `CULTMESH_REALTIME_LATEST_ONLY_STREAM = 2`, - `CULTMESH_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024`, - `CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES` (= max payload + 37 + 3 * 65535, - matching `CultMeshRealtimeWireProtocol.cs:20`), type - `CultMeshRealtimeDelivery = "reliable-ordered" | "latest-only" | "unreliable"` - with wire bytes 0/1/2 in that order (`CultMeshRealtimeTransports.cs:8-13`), - interface `CultMeshRealtimeFrame { channelId; schemaId; bodyId; - producerEpoch: bigint; sequence: bigint; delivery; payload: Uint8Array }`, - `encodeRealtimeFrame(frame): Uint8Array`, `decodeRealtimeFrame(bytes): - CultMeshRealtimeFrame`. Layout exactly `CultMeshRealtimeWireProtocol.cs:37-54` - (magic `0x31545343` LE u32, delivery u8, epoch i64 LE, sequence i64 LE, - three u16 LE lengths, payload i32 LE, header size i32 LE = 37, version u16 - LE = 1, then UTF-8 identities, then payload). Decode rejections match - `:60-76` one for one: truncated header, wrong magic, unsupported version or - header size, delivery out of range, negative payload length, length sum - mismatch. Epoch and sequence are `bigint` because i64 exceeds the safe - integer range; validation mirrors `CultMeshRealtimeTransports.cs:29-39` - (non-empty identities, non-negative epoch/sequence). -- `contracts/cultmesh/realtime-frame-vectors.json`: written by a new C# test - `tests/GameCult.Mesh.Tests/CultMeshRealtimeWireProtocolTests.cs` under - `CULTMESH_WRITE_VECTORS=1` and otherwise asserted against. Cases: one frame - per delivery mode with small payload; empty payload; identities at exactly - 65,535 UTF-8 bytes (multi-byte characters included so byte length and - character length differ); epoch and sequence at `long.MaxValue`; and a - 64 MiB-payload case recorded as `{ sha256OfEncoding, encodedLength }` rather - than bytes. Malformed cases: wrong magic, version 2, header size 36, - delivery 3, payload length -1, length sum off by one, each with the C# - exception message. There is no existing C# test for this codec - (`rg CultMeshRealtimeWireProtocol tests` returns nothing `(probe)`), so this - is also the first direct C# codec test. -- `packages/cultmesh-ts/test/realtime-wire.test.ts` (about 120 lines): every - vector decodes to the recorded fields and re-encodes to the recorded bytes; - every malformed vector throws; the 64 MiB case is built in-process and its - SHA-256 compared; TypeScript-produced bytes for the same fields equal the - C#-produced bytes (this direction is what "TypeScript encodings decode in - C#" means when the vector file is the medium; the cross-process direction is - cut 5). -- `packages/cultmesh-ts/src/index.ts:37-40` gains `export * from "./realtime-wire";`. - -Verification: - -- `dotnet test tests/GameCult.Mesh.Tests --filter FullyQualifiedName~RealtimeWireProtocol`. -- `npm run test --workspace packages/cultmesh-ts`. -- Negative grep: `rg -n "0x31545343|cultmesh-state-v1" packages --glob '!**/dist*/**'` - must hit only `realtime-wire.ts` and its test in TypeScript (one codec). -- Operator-only: none. - -## 8. Cut 3 (native, subtraction then behaviour): the runtime-neutral bridge - -Purpose: one MsQuic body for Unity and Node, two roles, two platforms, no -callbacks across the host boundary. - -Deletes first (in `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp`): - -- `:1-3` Windows includes; `:18-22` `__declspec` macros; `:51-63` per-client - `Client` with its own `api/registration/configuration`; `:184-200` - `CertificateMatchesPin` as the trust path; `:288-305` `CloseHandles`; - `:309-393` `cultmesh_quic_open` as the primary API. About 200 of 440 lines - are removed or rewritten; the framing consumer (`:123-157`), stream callback - (`:159-182`), trace/error helpers (`:65-86`, `:202-221`) are kept in spirit - and re-homed on the runtime. -- `CMakeLists.txt:4-6` (Windows-only fatal) and `:7-9` (`MSQUIC_ROOT` - mandatory) are replaced by a platform branch. - -Adds (C ABI v2, all `extern "C"`, exported with a portable macro -`CULTMESH_API` that is `__declspec(dllexport)` on MSVC and -`__attribute__((visibility("default")))` elsewhere): - -``` -int32_t cultmesh_quic_runtime_open(const char* app_name, void** out_runtime); -void cultmesh_quic_runtime_close(void* runtime); -int32_t cultmesh_quic_listener_open(void* runtime, const char* host, uint16_t port, - const uint8_t* pkcs12, int32_t pkcs12_len, const char* password, - uint64_t* out_listener_id, uint16_t* out_bound_port); -void cultmesh_quic_listener_close(void* runtime, uint64_t listener_id); -int32_t cultmesh_quic_connection_open(void* runtime, const char* host, uint16_t port, - uint64_t* out_connection_id); -int32_t cultmesh_quic_connection_certificate_complete(void* runtime, uint64_t connection_id, - int32_t accept); -void cultmesh_quic_connection_shutdown(void* runtime, uint64_t connection_id, uint64_t code); -int32_t cultmesh_quic_stream_open(void* runtime, uint64_t connection_id, uint8_t kind, - uint64_t* out_stream_id); -int32_t cultmesh_quic_stream_send_frame(void* runtime, uint64_t stream_id, - const uint8_t* encoded_frame, int32_t length, int32_t fin); -void cultmesh_quic_stream_shutdown(void* runtime, uint64_t stream_id, uint64_t code); -int32_t cultmesh_quic_next_event(void* runtime, int32_t timeout_ms, - cultmesh_quic_event* out_event, uint8_t* payload, int32_t payload_capacity, - int32_t* out_required); -int32_t cultmesh_quic_last_error(void* runtime, char* destination, int32_t capacity); -``` - -- `cultmesh_quic_event` is a fixed 64-byte struct: `uint32 type; uint32 - stream_kind; uint64 listener_id; uint64 connection_id; uint64 stream_id; - uint64 code; int32 status; int32 payload_length; uint8 reserved[16]`. - Types: 1 `listener_new_connection`, 2 `connection_connected`, 3 - `connection_certificate_received` (payload = DER), 4 `connection_shutdown` - (payload = UTF-8 reason), 5 `stream_started` (inbound, `stream_kind` set - from the first byte), 6 `stream_frame` (payload = one whole encoded frame, - length prefix stripped), 7 `stream_send_complete`, 8 `stream_shutdown`, 9 - `listener_stopped`. `next_event` returns 0 on timeout, 1 with the event - filled and payload copied, 2 when `payload_capacity` is too small (event - stays at the head; `out_required` set), negative on error. This is the v1 - two-phase poll convention (`:400-418`) applied to a queue of typed events. -- Client connections use `QUIC_CREDENTIAL_TYPE_NONE` with - `INDICATE_CERTIFICATE_RECEIVED | DEFER_CERTIFICATE_VALIDATION | - USE_PORTABLE_CERTIFICATES` (already at `:358-364`), return - `QUIC_STATUS_PENDING` from the certificate event, enqueue event 3, and call - `ConnectionCertificateValidationComplete` only when the host calls - `certificate_complete` (P11: `msquic.h:132-133,142,1290`). Timeout: the - bridge sets a 10 s handshake idle; if the host never answers, MsQuic's - handshake timeout closes the connection and event 4 is emitted. -- Listeners load `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12` - (`msquic.h:123,394-398`), one credential type for Schannel and OpenSSL; the - provider's advertised pin is the SHA-256 of the DER certificate computed on - the TypeScript side from the same PKCS12 with `node:crypto` (no hashing in - C). -- Settings mirror the C# server/connector: `PeerUnidiStreamCount 1024` - (`CultMeshQuicRealtimeTransport.cs:77`, `:202`), idle timeout 30 s and - keep-alive 5 s as in the existing bridge (`:335-338`). -- `stream_send_frame` copies the bytes, prepends the 4-byte LE length, calls - `StreamSend` (with `QUIC_SEND_FLAG_FIN` when `fin`), and emits event 7 on - `QUIC_STREAM_EVENT_SEND_COMPLETE`. `stream_open` writes the kind byte as the - first send. Framing therefore lives in C on both directions, exactly where - the v1 bridge already keeps it (`:123-157`), and `CultMeshQuicRealtimeProtocol` - keeps it in C# (`CultMeshQuicRealtimeTransport.cs:635-655`). -- The v1 exports `cultmesh_quic_open/state/poll/error/close` are kept, Windows - only (`#ifdef _WIN32`), implemented on top of a private runtime with the pin - check retained from `:184-200`. External contract protected: the Unity - package `DllImport` at `CultMeshNativeQuicRealtimeTransport.cs:259-277` and - `CanConnect`'s Windows gate at `:39-41`. They delegate; they decide nothing - new. -- `CMakeLists.txt`: Windows branch unchanged in effect (MSVC, `/Brepro`, - `MSQUIC_ROOT` from the NuGet layout, links `msquic crypt32 bcrypt`); Linux - branch links `msquic` from `MSQUIC_LIB_DIR` (the extracted deb's - `usr/lib/x86_64-linux-gnu`) with headers from `MSQUIC_INCLUDE_DIR`, `-fvisibility=hidden`, - output `libgamecult_mesh_quic_native.so`, `RPATH $ORIGIN` so the sibling - `libmsquic.so.2` resolves. -- `scripts/build-quic-native.sh` (about 70 lines): fetches - `libmsquic_2.5.9_amd64.deb` from the packages.microsoft.com pool URL in P7, - verifies its SHA-256, extracts with `dpkg-deb -x`, fetches the three headers - at tag `v2.5.9` (P10) and verifies their SHA-256 (record the hashes at first - run into the script, the same way `build-quic-native.ps1:14` pins the - NuGet), configures CMake with Ninja, and copies `libgamecult_mesh_quic_native.so`, - `libmsquic.so.2`, and the MsQuic LICENSE into `artifacts/quic-native/linux-x64`. -- `native/GameCult.Mesh.Quic.Native/README.md` rewritten to describe both - roles, both platforms, the v1/v2 split, and the "no host callbacks" rule. - -Per-file changes outside `native/`: - -- `scripts/build-quic-native.ps1:54-58` output path becomes - `artifacts/quic-native/win32-x64` by default (parameter default at `:4`); - callers `build-unity-package.ps1:80-82` and the Native.Tests csproj `:20-22` - pass explicit `-OutputDirectory` today, so they are unaffected. -- No change to `src/GameCult.Mesh.Quic.Native/*.cs`. The Unity managed - connector keeps polling v1. - -Verification: - -- Windows: `powershell -File scripts/build-quic-native.ps1` then - `dotnet test tests/GameCult.Mesh.Quic.Native.Tests` (the csproj rebuilds the - bridge itself at `:20-22`; all four tests at - `CultMeshNativeQuicRealtimeTransportTests.cs:15-141` must pass, including the - wrong-pin refusal at `:79-99` and the departed-client test at `:101-141`). - This is the regression gate for the v1 shim. -- Windows FFI smoke, before any TypeScript transport exists: - `scratchpad`-style script (to be committed as - `packages/cultmesh-ts/test/realtime-quic-native.test.ts` in cut 4) that - opens a runtime, a listener with a PKCS12 generated by `openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 ... | openssl pkcs12 -export` - (P13), a client connection to it, answers event 3 with accept, sees event 2 - on both sides, opens a latest-only stream, sends one frame with `fin`, and - reads event 6 on the server side with the identical bytes. -- Linux, development loop: build the committed image - `scripts/quic-native-linux-dev.Dockerfile`, pinned to the same `debian:13` - digest as the release workflow, and follow the recipe in the bridge's own - README. The image carries the compiler, Ninja, Node, `setarch` and the - library's runtime dependencies, and the sanitizer configurations need the - container's default seccomp profile relaxed so randomisation can be - disabled. The README and the Dockerfile header own that recipe; this map - does not restate it. This is a Linux build on a Linux host; it is not the - release artifact (Q6). *(The earlier one-line `docker run` recipe here - installed no JavaScript runtime and could not run the mutation runner at - all, and said nothing about randomisation or seccomp, which is how the - Linux path came to be red for everyone but the agent that had found the - flags by hand.)* -- Build economy: the only builds are the native CMake target and - `tests/GameCult.Mesh.Quic.Native.Tests` (which pulls `GameCult.Mesh.Quic`, - `GameCult.Mesh.Quic.Native`, `GameCult.Mesh`, `GameCult.Networking`, - `GameCult.Caching` by project reference). Before the first `dotnet build`, - take `Get-ChildItem -Recurse F:\Projects\CultLib\bin,F:\Projects\CultLib\obj,F:\Projects\CultLib\artifacts | Select FullName,Length` - to a scratch file and diff afterwards; restore or delete anything outside - those five projects' outputs. -- Negative greps: `rg -n "CryptHashCertificate2|wincrypt" native/` must hit - only inside the `#ifdef _WIN32` v1 block; `rg -n "windows.h" native/` only - inside `#ifdef _WIN32`. -- Operator-only: rebuilding and re-committing the Unity plugin DLL - (`unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll`) - and cutting a Unity package version through `scripts/build-unity-package.ps1` - is a release action; the map does not schedule it. Until it happens, Unity - keeps the old DLL, which is fine because v1 exports are unchanged. - -## 9. Cut 4 (behaviour): Node binding, consumer, session manager, trust - -Purpose: a TypeScript consumer that connects by advertised candidate, verifies -the Odin route through the cut-1 module, pins the TLS certificate, and -receives frames from the C# managed provider in a separate process. - -Deletes: none. - -Adds: - -- `packages/cultmesh-ts/src/realtime-quic-native.ts` (about 260 lines): the - koffi binding. Resolves the binary directory as `CULTMESH_QUIC_NATIVE_DIR` - when set (the development loop and CI point it at - `artifacts/quic-native/` straight from a build), otherwise - `path.join(__dirname, "..", "native", `${process.platform}-${process.arch}`)`, - the committed tree cut 6 lands (Q5 C; `__dirname` is `dist/` at runtime, so - the path holds in the repo, in an `npm pack` tarball, and in a pnpm `file:` - copy alike). No `node_modules` lookup, no platform package, no registry. - When neither directory holds `msquic` and the bridge, throw one error naming - both paths tried and the env var; until cut 6 lands, that is the error every - caller outside the test suite gets. Loads `msquic` first, then the bridge, by absolute path - (the probe showed the dependent library must be resolvable before the - bridge loads). Declares the twelve v2 prototypes. Exposes - `CultMeshQuicNativeRuntime` with a single event pump: a loop that awaits - `nextEvent.async(runtime, 250, ...)` and dispatches typed events to - registered listeners/connections by id. One pump per process-wide runtime; - reference-counted open/close. No koffi callbacks anywhere. -- `packages/cultmesh-ts/src/realtime-quic.ts` (about 650 lines across cuts 4 - and 5). Cut 4 lands: `CultMeshQuicRealtimeTransport` (implements - `sendFrame`, `receiveFrame`, `dispose`, `transportId = "msquic-realtime"`, - `endpoint`; the `isVerifiedFor(verseId, authorityRuntimeId, protocolId, - routeGeneration)` check mirroring `CultMeshQuicRealtimeTransport.cs:352-361`), - `CultMeshQuicRealtimeConnector` (`connectorId`, `priority`, `canConnect(candidate)`, - `connect(candidate, target, signal)`), and the receive path: stream kind - vs frame delivery consistency (`:519-524`), and the per-`(channel, body)` - generation filter that drops older `(producerEpoch, sequence)` (`:538-560`). - Certificate decision on event 3: caller `validateProviderCertificate(target, - der)` if supplied, else SHA-256 of DER equals the endpoint's `cert-sha256` - (`:111-119`, case-insensitive hex); otherwise reject. Endpoint parsing: - scheme `cultmesh-state+quic`, host, port, optional pin (`:98-109`). -- `CultMeshQuicRealtimeSessionManager` in the same file: takes an - `ICultMeshRealtimeLookupSource`-shaped port `{ resolve(target): Promise }`, - a trust policy from `cultnet-ts/authority`, and connectors. Binds - candidates from `authorityRoutes` whose `protocolIds` include - `cultmesh.realtime_state.v1` (`CultMeshSessions.cs:77`) and whose - `authorityRuntimeId` matches; verifies each with `verifyAuthorityRoute` - before any connector is asked; orders by connector priority then route - priority; races at most `maxRacedCandidates` per tier (`:833-880`); requires - `isVerifiedFor` after connect (`:885-896`); keys sessions by - `verseId + "\u001f" + authorityRuntimeId` (`:46`); marks offline on transport - failure. Ships with `CultMeshStaticRealtimeLookupSource` (an array of - `CultMeshVerseDescriptorMessage` from `cultnet-ts/contracts:350-369`). No - Odin WebSocket rendezvous in Node in this migration (Q7). -- `CultMesh` facade (`packages/cultmesh-ts/src/index.ts:4700`) gains statics - beside the RUDP ones at `:6008-6186`: `parseQuicRealtimeEndpoint`, - `createQuicRealtimeConnector(options)`, `createQuicRealtimeSessionManager(options)`, - and in cut 5 `createQuicRealtimeProvider(options)`. -- `packages/cultmesh-ts/package.json:44-48` gains `"koffi": "3.3.0"` (exact). - No `optionalDependencies`: under Q5 C the binaries are files of this - package, not packages of their own. `koffi` is required lazily inside - `realtime-quic-native.ts` so consumers that never touch QUIC (AetheriaEve - Electron, Odin, Stonks, weksa) load nothing native. `koffi` itself is a - registry dependency like `@msgpack/msgpack` (`:45`); every consumer host - already installs from the registry (`deploy-streampixels-preview.sh:102`), - so this adds no new kind of fetch. -- `packages/cultmesh-ts/test/realtime-quic-native.test.ts` (about 150 lines): - the cut-3 FFI smoke, now against the runtime class. -- `packages/cultmesh-ts/test/realtime-quic-consumer.test.ts` (about 250 - lines): TypeScript consumer against a TypeScript listener opened through - the raw runtime (no provider semantics yet): connect with correct pin; - refuse wrong pin (`cert-sha256` of 64 zeros, mirroring - `CultMeshNativeQuicRealtimeTransportTests.cs:79-99`); refuse missing - certificate when no validator; caller validator observed with the target - identity; generation filter drops a late older generation (mirrors - `CultMeshQuicRealtimeTransportTests.cs:111-140`). -- Trust negatives, each a separate test with its own mutation, in the same - file, through the session manager with a static lookup source and a signed - route built with `node:crypto`: (a) expired certificate (`now` past - `expiresAt`); (b) unsigned remote route (certificate absent, mode - `authenticated-remote`); (c) wrong Odin root (trust has a different - `keyId`); (d) `cert-sha256` pin mismatch (route verifies, TLS pin fails, so - the connector rejects before `isVerifiedFor`); (e) replayed nonce. On the - QUIC plane there is no `session_open` nonce exchange; the route's - freshness is its generation plus the TLS handshake. The replay test - therefore mutates `generation` on the connected transport's target and - asserts `isVerifiedFor` fails and the session manager disposes the - transport (`CultMeshSessions.cs:885-896`). If the operator wants a nonce on - QUIC, that is a protocol addition on both runtimes and belongs to a later - pipeline; this map does not invent it. -- Interop lane, C# managed provider to TypeScript consumer, separate - processes: `tests/GameCult.Networking.InteropPeer/Program.cs:27-49` gains - mode `quic-realtime-serve` (`--port`, `--frames N`, `--delivery - latest-only|reliable-ordered`, `--interval-ms`): creates a self-signed - certificate exactly as `CultMeshQuicRealtimeTransportTests.cs:203-228`, - starts `CultMeshQuicRealtimeServer`, prints one JSON line - `{ endpoint: "cultmesh-state+quic://127.0.0.1:?cert-sha256=" }` - to stdout, then broadcasts `N` frames with increasing sequence and exits - after the last `send_complete` or on stdin close. - `GameCult.Networking.InteropPeer.csproj:9-11` gains a `ProjectReference` to - `src/GameCult.Mesh.Quic/GameCult.Mesh.Quic.csproj`. - `packages/cultnet-ts/test/interop/cultnet-interop.test.ts` gains - `test("CultMesh QUIC realtime: C# managed provider and TypeScript consumer", ...)` - using `spawnServeProcess` (`:2491`) for the C# peer and `runJsonCommand` - (`:2557`) for a new TypeScript peer script - `packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts` (`dial --endpoint - --expect N`) that prints the received frames as JSON (fields plus payload - hex). The harness drives processes; it does not import `cultmesh-ts` - (dependency direction: `cultmesh-ts` depends on `cultnet-ts`, so the reverse - import is forbidden). The peer script path follows the `tsPeerScript` - pattern at `:41`. - -Verification: - -- `npm run test --workspace packages/cultmesh-ts` with - `CULTMESH_QUIC_NATIVE_DIR=artifacts/quic-native/win32-x64`. -- `npm run test:interop --workspace packages/cultnet-ts` filtered with - `--test-name-pattern "QUIC realtime"`; it builds the C# interop peer through - the existing `csharpInteropPeerBuild` path (`:45-58`, `:71`). -- Negative greps: `rg -n "new Function|koffi\.register|\.async\(" packages/cultmesh-ts/src` - must show `.async(` only on `nextEvent` in `realtime-quic-native.ts` and no - `koffi.register` (no callbacks); - `rg -n "verifyP256|canonicalRoute" packages/cultmesh-ts/src` must return - nothing. -- Operator-only: none. - -## 10. Cut 5 (behaviour): TypeScript provider and the coalescing outbox - -Purpose: the StreamPixels role. A TypeScript listener that broadcasts with an -explicit delivery mode and cannot be backpressured by a slow consumer, proven -against both C# connectors in separate processes. - -Deletes: none. - -Adds (in `packages/cultmesh-ts/src/realtime-quic.ts`): - -- `CultMeshQuicRealtimeProvider.listen({ host, port, serverCertificate: - { pkcs12, password }, handshakeTimeoutMs })`: opens a listener, computes - `advertisedEndpoint` (`cultmesh-state+quic://:?cert-sha256=`, - uppercase, because the Unity connector requires the pin, P18), accepts - connections into a peer set, exposes `connectionCount`, `broadcast(frame)`, - `receive()` for client-originated frames, `dispose()`. -- Delivery semantics, mirroring `CultMeshQuicRealtimeTransport.cs`: - `reliable-ordered` opens one unidirectional stream of kind 1 per peer on - first use and serialises writes behind a per-peer promise chain; `broadcast` - resolves when every peer's `send_complete` arrives or the peer is evicted - (`:216-235`, `:237-255`). `latest-only` enqueues into a per-peer keyed - outbox (`Map<"channel\u001fbody", frame>` plus a ready queue) and returns - synchronously (`:363-367`, `:572-618`); each peer's pump opens a fresh kind-2 - stream per frame, sends with `fin`, and only dequeues the next frame after - `send_complete`, so a stalled peer accumulates at most one pending frame per - `(channel, body)` and never blocks `broadcast`. `unreliable` throws - `NotSupported` with the same sentence as `:373-375` adjusted for the - runtime. -- Peer eviction on send failure or `connection_shutdown`; no reconnect logic - on the provider. - -Interop lanes (all separate processes; all in -`packages/cultnet-ts/test/interop/cultnet-interop.test.ts`): - -- TypeScript provider to C# native connector (the Unity one): the harness - starts `cultmesh-quic-peer.ts serve --frames 5 --delivery latest-only`, - reads its advertised endpoint from stdout, then runs - `dotnet test tests/GameCult.Mesh.Quic.Native.Tests --filter FullyQualifiedName~NativeConnectorReceivesFromExternalManagedProvider` - with `CULTMESH_NATIVE_EXTERNAL_ENDPOINT=` (the existing test at - `CultMeshNativeQuicRealtimeTransportTests.cs:15-33` is reused unchanged: it - already asserts a frame arrives with non-empty schema, body and payload). - Rename the test to `NativeConnectorReceivesFromExternalProvider` since the - provider is no longer necessarily managed; the env var name stays. -- TypeScript provider to C# managed connector: add - `ManagedConnectorReceivesFromExternalProvider` to - `tests/GameCult.Mesh.Quic.Tests/CultMeshQuicRealtimeTransportTests.cs`, - gated by the same `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`, using - `CultMeshQuicRealtimeTransportConnector` with the pin from the endpoint - (`:111-119`) and no validator, asserting frame fields and payload bytes - equal what the TypeScript peer prints. The harness runs it the same way. -- `LatestOnly` under a stalled consumer: `cultmesh-quic-peer.ts serve - --frames 200 --delivery latest-only --interval-ms 1` while the harness dials - twice with the TypeScript consumer, pauses one consumer's pump for 500 ms - (a test hook on the runtime that stops calling `next_event`), and asserts: - the provider's 200 `broadcast` calls complete within a bounded time - regardless of the pause; the healthy consumer reaches sequence 199; the - paused consumer, once resumed, converges on 199 with fewer than 200 frames - received. This is the TypeScript form of - `CultMeshNativeQuicRealtimeTransportTests.cs:101-141` plus the convergence - assertion from `CultMeshQuicRealtimeTransportTests.cs:111-140`. -- Golden bytes across processes: the C# managed connector lane above prints - the received frame's re-encoding (via `CultMeshRealtimeWireProtocol.EncodeFrame`) - as hex; the harness compares it with the TypeScript peer's `encodeRealtimeFrame` - output for the same frame. That closes "TypeScript encodings decode in C#" - by process, not by shared file. - -Verification: - -- `npm run test --workspace packages/cultmesh-ts` (provider unit tests with - the TypeScript consumer in-process but separate connections: reliable - ordering in both directions mirrors `CultMeshQuicRealtimeTransportTests.cs:71-110`; - latest-only coalescing; unreliable fails closed). -- `npm run test:interop --workspace packages/cultnet-ts --test-name-pattern "QUIC realtime"`. -- `dotnet test tests/GameCult.Mesh.Quic.Tests` and - `dotnet test tests/GameCult.Mesh.Quic.Native.Tests` without the env var - (the new tests `Assert.Ignore`, the existing ones still pass). -- Negative greps: `rg -n "setInterval|setTimeout" packages/cultmesh-ts/src/realtime-quic.ts` - must show no polling of the bridge (the pump is the only wait); - `rg -n "reconnect" packages/cultmesh-ts/src/realtime-quic.ts` must return - nothing on the provider side. -- Operator-only: none. - -## 11. Cut 6 (packaging and release, remapped under Q5 C) - -Purpose: a consumer host with no toolchain and no registry access for the -binding gets it by receiving the `cultmesh-ts` package directory, which is how -every consumer already receives the package (P16, P17). Registry publication -is parked, not ruled out; the seam it reattaches to is named at the end of -this section. - -### 11.1 Body facts this cut stands on - -- The deploy tarball is a `git archive`. The Idunn actuator on Starfire runs - `git -C CultLib archive --format=tar --output= origin/main packages/cultnet-ts packages/cultcache-ts` - (`F:\Projects\Odin\scripts\deploy-yggdrasil-streampixels.ps1:59`), uploads - it by `sftp` (`:127`), and `deploy-streampixels-preview.sh:95-99` extracts it - into `/srv/streampixels/CultLib` after deleting only those two package - directories (`:97`) `(source read)`. Two consequences: only committed files - ride (a `git archive` cannot carry untracked or ignored files), and - `packages/cultmesh-ts` is not in the archive today at all, although - StreamPixels declares it (`apps/service/package.json:26`). Adding it to the - archive list and to `:97` is deploy work in Odin and gamecult-ops, outside - this campaign (section 16); the Yggdrasil smoke below therefore ships its - own archive of the same shape rather than waiting on that change. -- pnpm materialises a `file:` directory dependency as a copy of the - package's `files` entries only: with `pnpm 10.33.0`, `node_modules/cultmesh-ts` - held `README.md`, `dist`, `package.json` and nothing else (no `src`, no - `test`) `(probe: scratch consumer with file: overrides)`. `files` is the - gate, so `native` must be listed in it. -- The MsQuic binaries the koffi probe loaded (P2) are the Unity plugin copies - at `unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/` (`probe.js:4-7`), - committed in plain git (`git ls-files`; `.gitattributes` is `* text=auto` - only, no LFS), `gamecult_mesh_quic_native.dll` 40,448 bytes and - `msquic.dll` 536,928 bytes `(probe)`. Committing binaries beside the code - that loads them is therefore existing practice in this repo, not a new one. -- The Linux runtime is one file: `libmsquic.so.2 -> libmsquic.so.2.5.9`, - 7,375,872 bytes, inside the pinned deb (P7); the deb also carries - `libmsquic.lttng.so.2.5.9` and `datapath_raw_xdp_kern.o`, neither needed - `(probe: dpkg-deb -c in debian:13)`. The repo pack is 31.68 MiB today - `(probe: git count-objects)`. -- `debian:13` resolved to digest - `sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1` - on 2026-09-16 and carries `g++ 4:14.2.0-1` `(probe: docker pull; apt-cache - policy)`. The builder is pinned by that digest, not by the moving tag. -- `GameCult/CultLib` is public `(probe: gh repo view)` and already carries two - GitHub Releases with binary assets, `cultlib-unity-v1.0.3` - (`org.gamecult.cultlib-1.0.3.tgz`) and `gamecult-geometry-v0.1.0` (a crate - and nupkgs) `(probe: gh release view)`; nothing in `scripts/`, `docs/` or - `.github/` creates them, so they were cut by hand `(source read: rg)`. - Release assets on a public repo download with plain `curl`, no token. -- The npm registry is reachable from Starfire: `npm ping` answers `PONG`, - `npm view koffi` is HTTP 200, and `npm view @gamecult/cultcache-ts` is a - 404 because it was never published `(probe)`. This confirms the corrected - premise in section 13: C is deferral, not necessity. -- `.gitignore` ignores `artifacts/` (`:66`) and `node_modules`; it has no rule - for `*.so` or `*.dll` `(source read)`, so nothing blocks the commit path. -- The root `package-lock.json` is committed `(probe: git ls-files)`, so - `npm ci` is the real install path (as `publish-packages.yml:55` uses it); - `cultnet-interop.yml:95` uses `npm install --no-package-lock` instead, a - pre-existing inconsistency this cut does not touch. - -### 11.2 Q8, the fork C surfaces (operator decision, recommended option first) - -Where do the built binaries live so that the `git archive` above carries -them to the host? - -- **Option 1, commit them.** `packages/cultmesh-ts/native/{win32-x64,linux-x64}/` - hold the four binaries, committed by the operator from the Release assets - the workflow attaches, with `MANIFEST.txt` and `SHA256SUMS` beside them. - Price: about 8.0 MB in the tree (7,375,872 + 536,928 + 40,448 + the bridge - `.so`, on the order of 60 KB); pack growth roughly the compressed size, - about 3 MB, and that only when the MsQuic pin moves, because - `libmsquic.so.2` and `msquic.dll` are copies of Microsoft's files and - change with the pin, not with the bridge. A bridge rebuild adds tens of KB. - A CI job checks `sha256sum -c SHA256SUMS` on every push, so the manifest - and the bytes cannot drift. Deploy path change required: none beyond the - `packages/cultmesh-ts` archive-list fix already owed. Human step: download, - verify, commit (operator-only, section 11.7). -- **Option 2, fetch them.** `native//` is git-ignored; the tree - commits only `MANIFEST.txt`, `SHA256SUMS` and a `scripts/fetch-quic-native.mjs` - (about 80 lines) that downloads the Release assets and verifies digests. - Price: zero binary bytes in git, but every carrier of the package directory - becomes incomplete until the fetch runs: a fresh clone, every worktree, - Heimdall's vendored copy, `npm pack` (a tarball without binaries looks - valid), and the actuator, which must run the fetch and then `tar --append` - the ignored files after `git archive` (cross-repo edit in Odin), or the - deploy script must fetch from GitHub on Yggdrasil at deploy time (cross-repo - edit in gamecult-ops, plus GitHub egress the StreamPixels path does not - have today). It is the "tarball with a `.so` in it by hand" failure the - original Q5 text warned about, moved one step left. - -**Recommended: Option 1.** The tarball is a `git archive`, so committed is the -only state that rides it without a second mechanism; the repo already commits -the same `msquic.dll` for Unity; and the growth is bounded by the MsQuic pin, -not by bridge iteration. Everything below is written for Option 1; Option 2 -changes only 11.4's manifest paragraph, the closure smoke, and the operator -steps, and is not mapped further unless chosen. - -**Ruled 2026-09-16: Option 1.** Operator: "Commit them, yeah." Option 2 is -history. - -### 11.3 Deletes - -None in the tree. The ruling-A surfaces (`packages/cultmesh-quic-native/*`, -`optionalDependencies`, `cultnet-ts`/`cultmesh-ts` publish jobs, new tag -prefixes in `publish-packages.yml:20-24`) are never created; that is the -subtraction, and it is an estimate change, not a diff (section 11.8). - -The existing `@gamecult/cultcache-ts` publish job -(`.github/workflows/publish-packages.yml:39-78`) stays. It is out of this -campaign's scope: it publishes a package this migration does not touch, its -only consumer today reaches it by `file:` (`F:\Projects\Heimdall\package.json:22` -`(source read)`), and it is the shape the parked registry pass will copy for -`cultnet-ts` and `cultmesh-ts`. Whether it should run before then is a -question for that pass. - -### 11.4 Keeps and moves - -- `scripts/build-quic-native.ps1` and the Unity plugin binaries are untouched - (5.4 deletion line). `scripts/build-quic-native.sh` is the cut-3 script, - unchanged here. -- The MsQuic license text already committed at - `unity/org.gamecult.cultlib/Third Party Notices/MSQUIC-LICENSE.txt` is - copied, not referenced, to `packages/cultmesh-ts/native/MSQUIC-LICENSE.txt`, - because the package directory must be self-contained once copied out of the - repo (11.1, pnpm). Same MIT text for both platforms. - -### 11.5 Adds - -- `packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll`, - `packages/cultmesh-ts/native/win32-x64/msquic.dll`, - `packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so`, - `packages/cultmesh-ts/native/linux-x64/libmsquic.so.2` (the real file, not - the symlink; `RPATH $ORIGIN` from cut 3 resolves it beside the bridge). - Directory names are exactly `${process.platform}-${process.arch}` so the - cut-4 resolver needs no table. -- `packages/cultmesh-ts/native/SHA256SUMS`: `sha256sum` format, one line per - binary, relative paths, generated by the workflow and committed verbatim. -- `packages/cultmesh-ts/native/MANIFEST.txt` (about 20 lines, `key=value`): - `msquic.version=2.5.9`; `msquic.windows.source` = the NuGet URL at - `build-quic-native.ps1:30` with its SHA-256 from `:14`; - `msquic.linux.source` = the deb pool URL from P7 with its SHA-256; - `bridge.commit` = the CultLib commit the bridge was built from; - `build.workflow_run` = the Actions run URL; `build.release` = the Release - tag; `build.win32-x64.host` = runner image plus MSVC version as printed by - the job; `build.linux-x64.host` = `ubuntu-latest` plus the `debian:13` - digest plus `g++` version as printed; and the date. Every value is copied - from the run log by the operator, then the CI check below keeps the file - and the bytes agreeing. The binding never reads this file; it exists for - readers and for the digest check. -- `.github/workflows/quic-native.yml` (about 120 lines), four jobs: - - `build-win32-x64` on `windows-latest`: checkout, run - `scripts/build-quic-native.ps1 -OutputDirectory artifacts/quic-native/win32-x64`, - print `cl.exe` version and `Get-FileHash` of both files, upload - `actions/upload-artifact` named `quic-native-win32-x64`. - - `build-linux-x64` on `ubuntu-latest` with - `container: debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1` - (Q6 A; the digest is the pin, the tag is the label): `apt-get install -y - cmake ninja-build g++ curl ca-certificates dpkg`, run - `scripts/build-quic-native.sh`, print `g++ --version` and `sha256sum`, - upload `quic-native-linux-x64`. No Node in this job; loading is proven by - the interop job below, on the same image. - - `release`, `needs` both, `if: startsWith(github.ref, 'refs/tags/cultmesh-quic-native-v')`, - `permissions: contents: write`: download both artifacts, write - `SHA256SUMS` and a draft `MANIFEST.txt` with every value the jobs printed, - and run `gh release create "$GITHUB_REF_NAME" --verify-tag` with the two - per-platform `.tar.gz` files, `SHA256SUMS` and `MANIFEST.txt` as assets - (`GH_TOKEN: ${{ github.token }}`; `gh` is preinstalled on hosted runners, - no third-party release action). The release notes are the manifest. - - `verify-committed` on `push` and `pull_request`, `ubuntu-latest`, one - step: `cd packages/cultmesh-ts/native && sha256sum -c SHA256SUMS`. This is - the check that the tree's bytes are the bytes the manifest names. - - Triggers: `push` to `main` and `pull_request` run `verify-committed` - only; `workflow_dispatch` runs the two builds (dry run, uploads - artifacts, no release); a `cultmesh-quic-native-v*` tag runs all four. - Only `release` has write permission. -- `packages/cultmesh-ts/package.json:14-16`: `"files": ["dist", "native"]`. - No version bump: under C nothing keys on the version; provenance keys on - the commit in `MANIFEST.txt`. -- `packages/cultmesh-ts/src/realtime-quic-native.ts` (cut 4): the runtime - class gains a readonly `nativeDirectory` (the absolute directory it loaded - from). Diagnostic only; the closure smoke asserts on it. -- `scripts/test-typescript-package-closure.mjs`: - - `:39-41`: for `cultmesh-ts`, assert the packed file list contains all - four binaries, `native/SHA256SUMS`, `native/MANIFEST.txt` and - `native/MSQUIC-LICENSE.txt`, on every host (the tarball carries both - platforms; the host only decides which loads). - - `:59-70`: the runtime smoke adds - `assert.equal(typeof mesh.CultMesh.createQuicRealtimeConnector, "function")`, - the same for `createQuicRealtimeProvider`, then opens a - `CultMeshQuicNativeRuntime`, asserts - `runtime.nativeDirectory` ends with `node_modules/cultmesh-ts/native/` - (path separators normalised), and closes it. The child at `:70` runs with - `CULTMESH_QUIC_NATIVE_DIR` deleted from its env so the default path is the - one exercised. The point: the binaries load from the installed copy of the - package, not from a repo path or a registry. -- `.github/workflows/cultnet-interop.yml`: - - After the C# RUDP step (`:127-128`): a step building the Windows bridge - (`scripts/build-quic-native.ps1 -OutputDirectory artifacts/quic-native/win32-x64`) - and a step running the `QUIC realtime` lanes with - `CULTMESH_QUIC_NATIVE_DIR` set to that directory, so CI proves the lanes - against a fresh build, then a second run of - `node scripts/test-typescript-package-closure.mjs` proves the committed - Windows binaries load without the env var. - - A second job `interop-quic-linux` on `ubuntu-latest` with the same pinned - `debian:13` container: `apt-get install` the build tools plus `libicu` - (dotnet's runtime need), `actions/setup-dotnet@v4` 10.0.x and - `actions/setup-node@v4` 24 (both work inside a container), build the - bridge with `scripts/build-quic-native.sh`, `npm ci`, run only the - TypeScript-provider-to-managed-connector and TypeScript-to-TypeScript - lanes (section 15: the native-connector lane is Windows by design), then - the closure script without the env var. The container is not optional: - the artifact is linked against Debian 13's glibc 2.41 and `ubuntu-latest` - (24.04) carries glibc 2.39 `(probe: ldd --version in both images)`, so a - symbol version the bridge or `libmsquic` references can be absent on the - bare runner; run the lanes on the image the artifact targets rather than - discover that per symbol. -- `README.md` of `packages/cultmesh-ts` gains a "QUIC Realtime Plane" section - after "RUDP Helpers" (`README.md:332`) with provider and consumer examples - matching the C# README (`src/GameCult.Mesh.Quic/README.md:13-62`), and a - "Native binaries" paragraph: where they live, that a `file:` dependency or - an `npm pack` tarball carries them, the `CULTMESH_QUIC_NATIVE_DIR` - override, the two provenance files, Windows x64 and Linux x64 (Debian 13 - glibc) only, and the four `libmsquic` runtime dependencies (P7) a Linux host - must already have. - -### 11.6 Per-file changes - -- `packages/cultmesh-ts/package.json:14-16`, `files` gains `"native"`. -- `packages/cultmesh-ts/src/realtime-quic-native.ts` (cut 4 file), one - readonly field `nativeDirectory` set at load. -- `scripts/test-typescript-package-closure.mjs:39-41`, `:59-70` as above; - `:9` unchanged (no new packages). -- `.github/workflows/cultnet-interop.yml`, two steps after `:128`, one job - after `:140`. -- `.github/workflows/quic-native.yml`, new. -- `.github/workflows/publish-packages.yml`, unchanged. -- `packages/cultmesh-ts/native/` (seven files), new, four of them binary and - operator-committed (11.7). -- `packages/cultmesh-ts/README.md`, one section after `:332`. - -### 11.7 Verification - -- `node scripts/test-typescript-package-closure.mjs` on Windows, with - `CULTMESH_QUIC_NATIVE_DIR` unset in the shell. Pins: the tarball carries - both platforms and the provenance files; the installed copy loads its own - binaries. -- `cd packages/cultmesh-ts/native && sha256sum -c SHA256SUMS` (Git Bash on - Windows). Pins: committed bytes equal the manifest. -- `git check-ignore -v packages/cultmesh-ts/native/linux-x64/libmsquic.so.2` - must exit 1 (not ignored), and - `git ls-files packages/cultmesh-ts/native | wc -l` must print 7. Pins: the - binaries are tracked, so a `git archive` carries them. -- `npm pack packages/cultmesh-ts --dry-run --json | node -p "JSON.parse(require('fs').readFileSync(0,'utf8'))[0].files.map(f=>f.path).filter(p=>p.startsWith('native/')).join('\n')"` - must list seven `native/` paths; the closure script already asserts this, - this is the one-liner for a reader. -- `workflow_dispatch` of `quic-native.yml`: both build jobs green, artifacts - present, no release created. A tag push `cultmesh-quic-native-v` - creates the Release with four assets. Pins: Q6 A, the builder is a Linux - host on the deploy OS and the artifact has a public, digest-named home. -- Negative greps (must return nothing): - `rg -n "optionalDependencies|cultmesh-quic-native-(win32|linux)|@gamecult/cultmesh-quic-native" packages scripts .github --glob '!**/node_modules/**' --glob '!**/dist*/**'` - (no platform packages exist); - `rg -n "node_modules|require\.resolve" packages/cultmesh-ts/src/realtime-quic-native.ts` - (the resolver never searches for a package); - `rg -n "\"(pre|post)install\"" packages/*/package.json` (no install-time - compile or download); - `rg -n "NPM_TOKEN|npm publish|registry-url" .github/workflows/quic-native.yml` - (the native workflow publishes to no registry). -- Operator-only, in order: - 1. After the tag build, download the Release assets, compare every digest - with the run log and with `SHA256SUMS`, place the binaries under - `packages/cultmesh-ts/native//`, fill `MANIFEST.txt` from the - log, commit. This is the only path bytes take into the tree; no workflow - writes to `main`. - 2. Yggdrasil smoke, which installs from what C ships (the archived tree) - and nothing else. On Starfire, at the commit from step 1: - `git -C F:\Projects\CultLib archive --format=tar --output=$env:TEMP\cultlib-quic-smoke.tar ` - (the actuator's own mechanism, `deploy-yggdrasil-streampixels.ps1:51,59`, - whole tree), then `sftp` it to `gamecultadmin@yggdrasil.gamecult.org:/home/gamecultadmin/`. - On Yggdrasil: - `mkdir -p /tmp/cultmesh-quic-smoke && tar -xf ~/cultlib-quic-smoke.tar -C /tmp/cultmesh-quic-smoke && cd /tmp/cultmesh-quic-smoke`; - `(cd packages/cultmesh-ts/native && sha256sum -c SHA256SUMS)` (the bytes - that arrived are the bytes the manifest names); - `ldd packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 | grep "not found"` - must print nothing. If it prints, the missing ones are among - `libnuma1`, `libxdp1`, `libnl-route-3-200` (P7; `libssl3t64` is base): - under C nothing installs them because the deb itself is never installed - on the host, so `sudo apt-get install` them once and record it in - `gamecult-ops/inventory.md` beside the Node line (`:660`). Then - `npm ci` (registry access from Yggdrasil is the deploy path's own - assumption, `deploy-streampixels-preview.sh:102`) and - `node scripts/test-typescript-package-closure.mjs` with the env var - unset: this is the same smoke CI runs, now on Node 24.14.1 (P8) on the - deploy host, loading `node_modules/cultmesh-ts/native/linux-x64`. - Then the handshake: `npm run test --workspace packages/cultmesh-ts` - (builds `dist-test` and runs the cut-4/5 suites on the host), then a - throwaway PKCS12 - (`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -subj /CN=127.0.0.1 -days 2 -keyout k.pem -out c.pem && openssl pkcs12 -export -inkey k.pem -in c.pem -passout pass:smoke -out smoke.p12`), - `node packages/cultmesh-ts/dist-test/test/interop/cultmesh-quic-peer.js serve --frames 5 --delivery latest-only --pkcs12 smoke.p12 --password smoke` - in one shell (it prints the advertised endpoint), and - `node packages/cultmesh-ts/dist-test/test/interop/cultmesh-quic-peer.js dial --endpoint --expect 5` - in a second, which must print five frames. If cut 5 landed `serve` with - a generated certificate instead of `--pkcs12/--password` flags, add the - flags in this cut; the smoke needs a certificate it can name. Finish - with `rm -rf /tmp/cultmesh-quic-smoke ~/cultlib-quic-smoke.tar`. This is - the only place the Linux artifact is proven on the deploy host, and only - the operator holds the key. - 3. Nothing here touches `/srv/streampixels`; StreamPixels adoption - (archive list, `:97`, its stale lockfile) is consumer work (section 15, - section 16). - -### 11.8 Subtraction estimate delta against section 14 - -Row 6 of the table read `0 removed, 60 (manifests) + 130 (workflow) + 30 -(closure) + 60 (README) = +280` with `+2 optional platform packages` and `+3 -publish jobs` in the dependencies line. Under C: - -- Removed: 0 (the `cultcache-ts` job stays, 11.3). -- Added text: about 120 (`quic-native.yml`) + 40 (interop workflow) + 30 - (closure) + 70 (README) + 25 (`MANIFEST.txt`, `SHA256SUMS`) + 1 - (`package.json`) = about +285, the same order as before; the 60 lines of - platform-package manifests are gone and the release/verify jobs take their - place. -- Added binary: four files, about 8.0 MB in the tree under Option 1, 0 under - Option 2. Section 14 has no column for this; it is the real price of C and - is stated here so it is not hidden in a line count. -- Dependencies line becomes: +0 optional platform packages (was +2); +0 - publish jobs (was +3); +1 workflow file with four jobs (two builds, one - release, one digest check) and +1 job in the interop workflow (was "+1 CI - job"). Runtime dependency unchanged: `koffi` only. -- Campaign total: about +3,900 source lines, unchanged; plus 8.0 MB of - committed binaries. - -### 11.9 Parked seam: registry publication (later pass) - -Not in this campaign, by the operator's choice (section 13). When it is -picked up, it reattaches here without remapping the rest: - -- `publish-packages.yml` gains `cultnet-ts` and `cultmesh-ts` jobs shaped like - `:39-78`, tag prefixes at `:20-24`, dispatch options at `:30-33`; the - `cultmesh-ts` job `needs` the two `quic-native.yml` build jobs (or the - workflow is merged) and publishes the artifacts they already produce as - `@gamecult/cultmesh-quic-native-{win32-x64,linux-x64}` platform packages. -- `packages/cultmesh-ts/package.json` gains `optionalDependencies` on those - two names; `realtime-quic-native.ts` gains a third resolution branch after - the env var and before the committed `native/` directory, or the committed - directory is retired in the same pass. -- Naming (unscoped `cultnet-ts`/`cultmesh-ts` versus `@gamecult/*`) is the - original Q5 A/B fork, still open for that pass; P14 recorded all names - unclaimed on 2026-09-16. -- Its Body precondition is an `NPM_TOKEN` repository secret minted on - npmjs.com, which the operator can now reach (section 13). - -## 12. Cut 7 (documentation and ledgers) - -- `docs/runtime-parity-scope.md:38` TypeScript row: add "CultMesh QUIC - realtime provider and consumer over CultLib-owned MsQuic bindings (Windows - x64, Linux x64), byte-parity with C#, cross-process interop lanes" to - "Claimed parity"; add "QUIC datagrams (`Unreliable`), browser QUIC" to "Not - claimed". -- `src/GameCult.Mesh/docs/transport-planes.md:35-46`: name the TypeScript - provider/consumer beside the .NET and Unity bodies; `:122-125`: state that - `CULTMESH_NATIVE_EXTERNAL_ENDPOINT` now accepts any provider and is driven - by the TypeScript interop harness. -- `docs/cultnet-transport-parity.md`: no change. It maps RUDP/TCP/LiteNetLib - ownership; the realtime plane is a CultMesh plane, not a CultNet transport - profile, and pretending otherwise would put QUIC into a document whose cut - line says "Do not put UDP packet mechanics into CultMesh" the other way - round. -- `docs/parked-features.md`: one entry, "Node Odin WebSocket rendezvous", - pointing at `cultmesh-browser/src/index.ts:84-191` as the browser owner and - the `ICultMeshRealtimeLookupSource` port as the seam (Q7). -- `docs/typescript-quic-realtime-target.md:5-7`: status line updated to point - at this map. -- Under Q5 C, the delivery story every document tells is the one in section - 11: the binding ships inside `packages/cultmesh-ts/native/`, provenance - lives in `MANIFEST.txt` and `SHA256SUMS`, and consumers keep `file:` paths. - `native/GameCult.Mesh.Quic.Native/README.md` (cut 3) and - `packages/cultmesh-ts/README.md` (cut 6) both point at `MANIFEST.txt` as the - provenance owner rather than restating versions or digests. The parity row - above says "bindings shipped in the `cultmesh-ts` package tree", not - "published". Registry publication is described in exactly one place, as - parked: section 11.9 of this map. - -Verification: `rg -n "CultMeshRealtimeTransports.cs" docs src/GameCult.Mesh/docs` -returns only correct paths; -`rg -n -i "registry|npm publish|platform package|optionalDependencies" packages/cultmesh-ts/README.md native/GameCult.Mesh.Quic.Native/README.md docs/runtime-parity-scope.md src/GameCult.Mesh/docs/transport-planes.md` -returns nothing (the only permitted mentions are this map's sections 11.9 and -13 and the target's history). - -## 13. Operator questions - -The four rulings in the target (Q1-Q4) are closed and not reopened. These are -the forks the probes surfaced. - -- **Q5. Registry names.** The binding cannot ship as prebuilt platform - packages without `cultmesh-ts` itself being on a registry, and today every - consumer uses `file:` paths and only `@gamecult/cultcache-ts` is published - (P15, P16). All candidate names are unclaimed (P14). A: publish `cultnet-ts` - and `cultmesh-ts` under their existing unscoped names (no import changes in - any of the 11 consuming repos) and put only the new native platform packages - under `@gamecult/cultmesh-quic-native-{win32-x64,linux-x64}`. B: scope - everything as `@gamecult/*` now and take the import rename across consumers - as a separate consumer task. C: no registry; ship the binaries inside the - CultLib tarball the deploy script already extracts on Yggdrasil - (`deploy-streampixels-preview.sh:95-99`), keeping `file:` consumption. - **Recommended: A.** It reshapes nothing consumers use, matches the - `cultcache-ts` precedent of scoping only what is new, and gives StreamPixels - a versioned artifact instead of a tarball with a `.so` in it. C is the - cheapest but leaves the deploy path owning a native binary by hand, which is - the failure the target's "shipped through a registry" phrase was written to - avoid. -- **Q6. Linux release builder.** A: GitHub Actions `ubuntu-latest` with - `container: debian:13` (same OS as Yggdrasil, P8), artifact published by the - workflow, verified on Yggdrasil by the operator smoke in cut 6. B: build on - Yggdrasil in the deploy script (installs CMake and g++ on a live public - host; every deploy compiles). C: build on Starfire's Docker `debian:13` - engine (P12) and publish from the workstation. **Recommended: A.** It is a - Linux host, it is reproducible from a tag, and it keeps compilers off - Yggdrasil. C is kept for the development loop only. -- **Q7. Node-side Odin rendezvous.** The TypeScript consumer needs a route - source. The browser package has an Odin WebSocket rendezvous - (`cultmesh-browser/src/index.ts:84-191`); Node 24 has a global `WebSocket`, - so it would run, but `cultmesh-ts` may not depend on `cultmesh-browser` - (Q2's spirit), and the first consumer of the Node QUIC path is the interop - harness, not a product. A: ship the lookup-source port plus a static source - now, park the Node Odin rendezvous with a note. B: move the Odin rendezvous - into `cultnet-ts` beside the verifier in this migration so Node and browser - share it. **Recommended: A.** B is the right end state but is a second - ownership move in one pipeline; it should follow once a Node consumer that - discovers through Odin exists. - -Not asked, because the target already decided them: datagrams stay out (Q4); -no TLS on TCP planes (Q1); Windows x64 and Linux x64 only (Q3); the verifier -moves to a shared browser-safe module (Q2, section 6 names the module). - -### Rulings, 2026-09-16 - -The operator took all three recommendations the same day the map was written. -Each is now a standing ruling; the options above are history. - -- **Q5 ruled A, then re-ruled C the same day.** A had `cultnet-ts` and - `cultmesh-ts` publishing under their unscoped names with only the two native - platform packages scoped. The re-ruling was first stated as necessity ("we - don't even have npm access and cannot get it because our entire subnet is - blocked"); the operator corrected that Body fact the same day after - reaching npm signup: "apparently that was true yesterday." The registry is - reachable from Starfire (`npm ping` answers, section 11.1 `(probe)`), and an - `NPM_TOKEN` can be minted. **C still stands for this campaign, by choice: - no registry.** In the operator's words, registry publication is "still for - a later pass". Consumers keep `file:` paths. The native binaries ship inside - the `cultmesh-ts` package tree that the deploy path's `git archive` carries, - and Q6's Actions build attaches both platform artifacts to a GitHub Release - instead of publishing packages. Registry publication of `cultnet-ts`, - `cultmesh-ts` and the native binaries is parked, not ruled out; section 11.9 - names the seam it reattaches to, and the original A/B naming fork stays - open for that pass. Cut 6 is remapped under C (section 11); section 5.4, - Cut 4's binary resolution and dependency lines, and Cut 7's ledger follow - it. The one fork C surfaces, whether the binaries are committed or fetched - from the Release, is Q8 in section 11.2. -- **Q8 ruled Option 1.** The four native binaries are committed at - `packages/cultmesh-ts/native//` with their manifest and digests, - so they ride the `git archive` the deploy path already builds. Operator's - words: "Commit them, yeah." -- **Q6 ruled A.** The Linux release artifact is built by GitHub Actions on - `ubuntu-latest` inside `container: debian:13`, published by the workflow, - and verified by the operator smoke on Yggdrasil in cut 6. The Docker - `debian:13` engine on the workstation is the development loop only; nothing - it produces is a release artifact. No compiler is installed on Yggdrasil. -- **Q7 ruled A.** Cut 4 ships the `ICultMeshRealtimeLookupSource` port and a - static source. The Node Odin WebSocket rendezvous is parked with a note in - cut 7's ledger; it moves into `cultnet-ts` beside the verifier in a later - pipeline, once a Node consumer that discovers through Odin exists. - -## 14. Subtraction estimate - -Both sides of the number, by cut, in source lines (tests included, generated -`dist*` excluded, vector JSON excluded): - -| Cut | Removed or replaced | Added | Net | -| --- | ---: | ---: | ---: | -| 1 trust module | 119 (browser) | 190 (module) + 150 (tests) + 40 (C# vector test) | +261 | -| 2 codec | 0 | 130 + 120 (TS test) + 120 (C# test) | +370 | -| 3 bridge | ~200 rewritten of 440 C++; 24 CMake | ~1,000 C++ (replacing 440), 60 CMake, 70 sh, 40 README | +~700 net C++/build | -| 4 consumer | 0 | 260 (binding) + 400 (consumer/session manager) + 400 (tests) + 200 (C# interop mode) + 150 (harness) + 120 (peer script) | +1,530 | -| 5 provider | 0 | 250 (provider) + 300 (tests) + 150 (harness) + 40 (C# tests) | +740 | -| 6 packaging | 0 | 60 (package manifests) + 130 (workflow) + 30 (closure smoke) + 60 (README) | +280 | -| 7 docs | ~10 | ~40 | +30 | -| Total | ~350 | ~4,260 | about +3,900 | - -Dependencies: +1 runtime dependency on `cultmesh-ts` (`koffi`, 1.7 MB, MIT, -loaded lazily); +0 optional platform packages; +0 build-time Node -dependencies (no node-gyp, no node-addon-api, no cmake-js). Targets: +0 .NET -projects (the C# interop peer gains one project reference); +1 CMake platform -branch; +1 shell build script; +1 workflow file with four jobs; +1 job in -the interop workflow; and, under Q8 option 1, about 8.0 MB of committed -binaries. *(This line first read "+2 optional platform packages; +3 publish -jobs; +1 CI job" under ruling A; section 11.8 carries the delta.)* - -Why the number is positive and still acceptable: the target orders a new -capability (a QUIC realtime plane in a runtime that has none), and every line -above either is that capability, proves it against the C# reference, or ships -it. The subtraction that was available (one verifier instead of two, one -MsQuic body instead of two, one framing implementation per language instead -of one per host) is taken in cuts 1 and 3. Anything that would have shrunk -the number further (a third-party QUIC stack, a callback-driven addon, a -tarball with a `.so`) was rejected in section 4 for reasons that are not -about line count. - -## 15. Not assignable to a cut - -- **The Unity package release** carrying the rebuilt bridge DLL. The v1 ABI - is preserved, so no cut requires it, but the committed DLL will lag the - source until the operator runs `scripts/build-unity-package.ps1` and - commits. Recorded here so nobody reads the stale DLL as evidence the - rewrite did not happen. -- **StreamPixels certificate provisioning** (which PKCS12, where it lives on - Yggdrasil, how its pin reaches the Odin route). Consumer work by the target. -- **A nonce on the QUIC plane.** The target lists "a replayed nonce" among - trust negatives, but the QUIC plane has no nonce exchange in C# either - (`CultMeshQuicRealtimeTransport.cs` opens no session message; trust is the - Odin route plus the TLS pin, `transport-planes.md:71-73`). Cut 4 tests the - replay class that does exist (route generation). Adding a nonce is a - cross-runtime protocol change and needs its own target. -- **Linux coverage of the C# native connector.** `CanConnect` is Windows-only - (`CultMeshNativeQuicRealtimeTransport.cs:40`); the Linux interop job in cut 6 - therefore runs the TypeScript-provider-to-managed-connector lane and the - TypeScript-to-TypeScript lanes only. The native-connector lane is Windows - by design, matching where Unity runs. -- **The `cultcache-ts` naming split** across consumers (section 3). Out of - scope. Q5 C touches no package name and publishes nothing, so it makes the - split neither better nor worse; the split becomes the parked registry - pass's problem (section 11.9), where the A/B naming fork is still open. - One consequence to carry there: StreamPixels' lockfile resolves - `cultmesh-ts`'s cache dependency as unscoped `cultcache-ts` - (`F:\Projects\StreamPixels\pnpm-lock.yaml:2561-2564` `(source read)`) while - `packages/cultmesh-ts/package.json:46` now names `@gamecult/cultcache-ts`, - so a `pnpm install --frozen-lockfile` against the current tree would refuse - before any QUIC code is reached. Consumer work, recorded so the Yggdrasil - smoke in section 11.7 is not mistaken for StreamPixels adoption. - -## 16. Reminder of what a wrong cut looks like - -If a cut touches `src/GameCult.Mesh/CultMeshTcp*.cs`, adds TLS to the TCP -schema or content connectors, adds WebTransport to `cultmesh-browser`, adds -QUIC to `cultnet-py`, `cultnet-rs`, or `cultmesh-kotlin`, or edits anything -under `F:\Projects\StreamPixels`, it has left the target's "Not in this -migration" list and is wrong. Stop and re-read this map. diff --git a/docs/typescript-quic-realtime-target.md b/docs/typescript-quic-realtime-target.md deleted file mode 100644 index 751d5277..00000000 --- a/docs/typescript-quic-realtime-target.md +++ /dev/null @@ -1,160 +0,0 @@ -# TypeScript QUIC Realtime Plane Target - -Date: 2026-09-16 - -Status: target accepted by the operator (2026-09-16), ready for Eureka. No cut -map exists yet. When the pipeline starts, Imagination maps the cut into -`docs/typescript-quic-realtime-cut.md` against `main` at `a0813c6` or later. - -The TypeScript runtime gains the CultMesh QUIC realtime state plane that the C# -runtime already has: a provider that serves frames and a consumer that receives -them, byte-compatible with `GameCult.Mesh.Quic` and the native MsQuic connector -the Unity package ships. This document states the end state and the invariants -that constrain getting there. - -Operator ruling, 2026-09-16, on providing QUIC for a Node service: "Option 2 all -the way, this is what CultLib is supposed to be, we're not half assing it." A -separate .NET provider process and a WebSocket shortcut were both rejected. - -## Why now - -The first consumer is StreamPixels. Its service (Node 24, TypeScript, on -Yggdrasil) must publish realtime state to a Unity overlay running on a -streamer's machine. The overlay is a receive-only client using the Unity -package's `CultMeshNativeQuicRealtimeTransportConnector`. Today no TypeScript -path exists: - -- Node v24.15.0 has no `node:quic`, with or without `--experimental-quic`. -- `packages/cultmesh-ts` and `packages/cultnet-ts` contain no QUIC code. -- `GameCult.Mesh.Quic` (.NET 10, `System.Net.Quic`) is the only QUIC provider. -- The C# TCP control and content connectors are plaintext and restricted to - loopback (`src/GameCult.Mesh/docs/transport-planes.md`, "Security and release - gate"), so they cannot carry the overlay's remote session either. - -## This touches the foundation - -- **Wire parity is an invariant.** The C# codec is the reference: - `src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs`. The TypeScript runtime - must produce and accept identical bytes: - - ALPN `cultmesh-state-v1`; connection close code `0x43554c54`, stream abort - code `0x53544154`; reliable stream type `1`, latest-only stream type `2`. - - Frame: magic `0x31545343`, then a 37-byte little-endian fixed header - (delivery byte, producer epoch i64, sequence i64, channel/schema/body id - lengths u16, payload length i32, header size i32 = 37, wire version u16 = - 1), then UTF-8 channel id, schema id, body id, then payload. - - Limits: payload at most 64 MiB; each identity at most 65,535 UTF-8 bytes; - decode rejects wrong magic, unknown version, out-of-range delivery, and any - length that does not sum exactly. -- **Delivery semantics are an invariant** - (`src/GameCult.Mesh.Quic/README.md`, `CultMeshRealtimeTransports.cs`): - - `ReliableOrdered`: one persistent ordered stream; broadcast is - completion-bearing. - - `LatestOnly`: independent streams; at most one pending frame per - `(channel, body)`, replaced by a newer `(producer epoch, sequence)` - generation; a keyed coalescing outbox per physical peer, so a slow or - departed consumer never backpressures the producer. - - `Unreliable`: fails closed until datagrams are exposed, matching the managed - C# adapter. -- **Trust is an invariant.** No remote session comes online without the chain - in `transport-planes.md`: an Odin-signed `CultMeshRouteCertificate` (Verse, - authority runtime, protocol set, endpoint, generation, validity, provider - P-256 key) checked against consumer-pinned Odin roots, TLS 1.3, and the - endpoint certificate bound to that route, including the - `cultmesh-state+quic://host:port?cert-sha256=` pin. - Signatures are ECDSA P-256 over SHA-256 with P1363 `r || s` encoding and the - length-prefixed canonical transcript. Unsigned traffic exists only under an - explicit `LocalDevelopment` policy on loopback. -- **One implementation of TypeScript route verification.** `cultmesh-browser` - already verifies Odin route certificates and provider nonce possession with - WebCrypto (`packages/cultmesh-browser/src/index.ts`, `verifyAuthorityRoute`, - `canonicalRoute`, `canonicalSession`, `verifyP256`). The Node QUIC path must - reuse that owner, not grow a second verifier. -- **The realtime plane carries state frames only.** Schemas, commands, - receipts, manifests, and immutable content do not travel through it - (`ICultMeshRealtimeTransport` doc comment). RUDP is never installed - implicitly in its place. -- **Existing consumers survive.** The StreamPixels service already imports - `cultcache-ts`, `cultnet-ts`, and `cultmesh-ts` (`verse-state.ts`, - `idunn-rudp-health.ts`) for Odin advertisement and Idunn health over RUDP. - Imagination audits every other TypeScript consumer across `F:\Projects` - before sizing the cut. - -## Target state - -- `cultmesh-ts` exposes a QUIC realtime provider and a QUIC realtime consumer - with the same ergonomic shape as the C# `CultMeshQuicRealtimeServer` - (listen, broadcast with an explicit delivery mode) and - `ICultMeshRealtimeTransportConnector` (connect by advertised candidate, - receive frames), under the branded `CultMesh` entrypoint. -- QUIC comes from native MsQuic bindings owned by CultLib, not from a Node - polyfill or a third-party QUIC stack with different semantics. How the binding - is built and shipped is the means (Imagination probes it); what it must - deliver is TLS 1.3, ALPN, independent unidirectional or bidirectional streams, - certificate validation with a caller hook, and clean close and abort codes. -- Frame encoding and decoding exist once in TypeScript and are tested against - bytes produced by the C# codec. -- Application code never picks endpoints, ranks routes, or writes reconnect - loops; `CultMeshSessionManager`-equivalent route selection owns that, as in C#. -- The build produces binaries for the hosts that run them: Windows x64 for - local development, and Linux x64 for Yggdrasil, built on Linux or the deploy - path's builder, not cross-compiled on Starfire. - -## Not in this migration - -One foundation per pipeline. These are separate, named pipelines or consumer -work: - -- **TypeScript content plane** (a provider in `cultmesh-ts`, a verifying HTTPS - reader in `cultmesh-browser`) for exported character artifacts. HTTPS content - transport and single-host trust on Yggdrasil were already ruled. -- **Authenticated remote control plane** (TLS on the TCP schema and content - connectors). See Q1. -- **Browser QUIC** (WebTransport). Browsers keep the verified WebSocket path. -- **Python, Rust, and Kotlin QUIC.** The parity scope is role-scoped; this - migration claims TypeScript and C# only. -- **StreamPixels integration** (service publication, overlay subscription, and - the catalog and scene-intent schemas) is consumer work in StreamPixels after a - released `cultmesh-ts` exists. - -## Verification the cut must reach - -- Golden frame bytes from the C# codec decode in TypeScript, and TypeScript - encodings decode in C#, for every delivery mode and at the length limits. - Malformed inputs are rejected identically. -- Separate processes, both directions: a TypeScript provider to the C# native - connector (the one Unity uses) and to the managed connector, and a C# managed - provider to the TypeScript consumer. Extend the existing parity harnesses - (`CULTMESH_NATIVE_EXTERNAL_ENDPOINT`, `packages/cultnet-ts/test/interop`) rather - than adding a parallel one. -- `LatestOnly` coalescing: a stalled consumer does not delay the producer, and - the consumer converges on the terminal generation. -- Trust negatives: an expired or unsigned route, the wrong Odin root, a - mismatched `cert-sha256` pin, and a replayed nonce are each refused, with a - mutation per rule. -- The Linux x64 binary runs on the Yggdrasil deploy path, verified on that host. - -## Rulings (operator, 2026-09-16) - -The operator accepted every recommendation below ("You got it"). Each question -is kept with its options as the record of what was decided against. - -- **Q1. Which remote plane carries the overlay's non-frame data?** The realtime - plane forbids schemas, commands, and content, and the TCP planes are - loopback-only. A: publish her character, the subathon window, and scene - intents as QUIC state frames with their CultCache schema id and payload, and - ship the costume catalog as a versioned immutable artifact on the HTTPS - content plane; the overlay sends no commands, so it needs no remote control - plane. B: add TLS to the TCP schema and content connectors as its own - pipeline first. **Recommended: A.** It needs no new plane for M1, matches a - receive-only client, and leaves TLS TCP to the first consumer that sends - commands. -- **Q2. Where does shared TypeScript route verification live?** It is inside - `cultmesh-browser` today, and `cultmesh-ts` is Node-only. A: move it to a - browser-safe shared module that both import (for example beside - `cultnet-ts/contracts`). B: have `cultmesh-ts` depend on `cultmesh-browser`. - **Recommended: A**, so neither transport package owns the other's trust. -- **Q3. Which platforms must the native binding support at release?** - **Recommended: Windows x64 and Linux x64 only**, the two hosts that run it - today. -- **Q4. Are QUIC datagrams (`Unreliable`) in scope?** **Recommended: no.** Fail - closed like the managed C# adapter; no consumer needs them. diff --git a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt index c3dfc304..2850951d 100644 --- a/native/GameCult.Mesh.Quic.Native/CMakeLists.txt +++ b/native/GameCult.Mesh.Quic.Native/CMakeLists.txt @@ -1,118 +1,20 @@ cmake_minimum_required(VERSION 3.24) project(gamecult_mesh_quic_native LANGUAGES CXX) -# Two platforms, one source. Windows takes MsQuic from the pinned OpenSSL NuGet -# layout (MSQUIC_ROOT); Linux takes it from the extracted libmsquic deb, whose -# headers and library land in unrelated places, so they are named separately. -if(NOT WIN32 AND NOT (CMAKE_SYSTEM_NAME STREQUAL "Linux")) - message(FATAL_ERROR "The CultMesh native QUIC bridge supports Windows x64 and Linux x64") +if(NOT WIN32) + message(FATAL_ERROR "The current CultMesh native QUIC connector supports Windows desktop only") +endif() +if(NOT DEFINED MSQUIC_ROOT) + message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.Schannel package build/native directory") endif() add_library(gamecult_mesh_quic_native SHARED cultmesh_quic_native.cpp) target_compile_features(gamecult_mesh_quic_native PRIVATE cxx_std_20) -# include/cultmesh_quic_native.h is the host-facing contract, not an internal -# detail: the bridge includes it, and so does any consumer that can. -target_include_directories(gamecult_mesh_quic_native PUBLIC include) - -if(WIN32) - if(NOT DEFINED MSQUIC_ROOT) - message(FATAL_ERROR "MSQUIC_ROOT must point at the extracted Microsoft.Native.Quic.MsQuic.OpenSSL package build/native directory") - endif() - # Static CRT. A Node or Unity host without the Visual C++ redistributable - # installed cannot load a DLL that imports vcruntime140.dll, and the failure - # arrives as "the specified module could not be found" with nothing naming the - # missing runtime. The bridge carries its own instead. - set_property(TARGET gamecult_mesh_quic_native PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded") - target_compile_definitions(gamecult_mesh_quic_native PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) - target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include") - target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64") - # crypt32 and bcrypt belong to the v1 shim alone: CryptHashCertificate2 - # compares the Unity pin. Nothing in the v2 path needs them. ntdll carries - # RtlIpv4/6StringToAddressEx, which msquic_winuser.h's inline QuicAddrFromString - # calls to parse the listener host; the Linux header parses it in-tree instead. - target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt ntdll) - # The DLL is committed beside its source, so a rebuild must reproduce it: /Brepro drops the link timestamps. - target_compile_options(gamecult_mesh_quic_native PRIVATE /Brepro) - target_link_options(gamecult_mesh_quic_native PRIVATE /Brepro) -else() - if(NOT DEFINED MSQUIC_INCLUDE_DIR OR NOT DEFINED MSQUIC_LIB_DIR) - message(FATAL_ERROR "MSQUIC_INCLUDE_DIR and MSQUIC_LIB_DIR must point at the msquic v2.5.9 headers and the extracted libmsquic library directory") - endif() - target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_INCLUDE_DIR}") - target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_LIB_DIR}") - target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic pthread) - # Only the CULTMESH_API exports leave the .so; everything else stays internal. - target_compile_options(gamecult_mesh_quic_native PRIVATE -fvisibility=hidden -fvisibility-inlines-hidden) - # libmsquic.so.2 ships beside the bridge rather than being installed - # system-wide, so the loader is pointed at the bridge's own directory. - set_target_properties(gamecult_mesh_quic_native PROPERTIES - BUILD_WITH_INSTALL_RPATH TRUE - INSTALL_RPATH "$ORIGIN") -endif() +target_compile_definitions(gamecult_mesh_quic_native PRIVATE GAMECULT_MESH_QUIC_NATIVE_EXPORTS WIN32_LEAN_AND_MEAN NOMINMAX) +target_include_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/include") +target_link_directories(gamecult_mesh_quic_native PRIVATE "${MSQUIC_ROOT}/lib/x64") +target_link_libraries(gamecult_mesh_quic_native PRIVATE msquic crypt32 bcrypt) set_target_properties(gamecult_mesh_quic_native PROPERTIES OUTPUT_NAME "gamecult_mesh_quic_native" - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" - LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") - -# The runtime-lifetime scenarios. Off by default: the shipped artifact is the -# library alone, and a delivery build has no business producing an executable. -# CULTMESH_QUIC_DEBUG_ASSERTS goes with them, never into a shipped binary — it -# makes the bridge state the quiesce invariant instead of leaving it to whatever -# a sanitizer happens to catch. -option(CULTMESH_QUIC_BUILD_TESTS "Build the runtime-lifetime scenario runner" OFF) -option(CULTMESH_QUIC_DEBUG_ASSERTS "Abort the bridge on a violated internal invariant" OFF) - -if(CULTMESH_QUIC_DEBUG_ASSERTS) - target_compile_definitions(gamecult_mesh_quic_native PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS) -endif() - -if(CULTMESH_QUIC_BUILD_TESTS) - add_executable(cultmesh_quic_native_tests tests/cultmesh_quic_native_tests.cpp) - target_compile_features(cultmesh_quic_native_tests PRIVATE cxx_std_20) - # The header only. The scenarios drive the bridge as a host does, so they get - # no more of it than a host has. - target_link_libraries(cultmesh_quic_native_tests PRIVATE gamecult_mesh_quic_native) - # The scenarios see the development seam (section 7 of the header) exactly when - # the library has one, so `holdclose` compiles out of the same builds the - # bridge's own assertions compile out of. - if(CULTMESH_QUIC_DEBUG_ASSERTS) - target_compile_definitions(cultmesh_quic_native_tests PRIVATE CULTMESH_QUIC_DEBUG_ASSERTS) - endif() - set_target_properties(cultmesh_quic_native_tests PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") - 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" - "$/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 - # the build; point it at the extracted package instead. - target_link_options(cultmesh_quic_native_tests PRIVATE "-Wl,-rpath-link,${MSQUIC_LIB_DIR}") - set_target_properties(cultmesh_quic_native_tests PROPERTIES - BUILD_WITH_INSTALL_RPATH TRUE - INSTALL_RPATH "$ORIGIN") - # Both binaries carry INSTALL_RPATH "$ORIGIN" and expect libmsquic.so.2 - # beside them at load time. build-quic-native.sh's packaged output gets - # that copy from its own recipe; a test build has no packaging step, so - # the runner would otherwise die at once with "cannot open shared object - # file". CMake already knows where MSQUIC_LIB_DIR is, so it places the - # file itself instead of leaving the reader to run an extra cp by hand. - add_custom_command(TARGET cultmesh_quic_native_tests POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy - "${MSQUIC_LIB_DIR}/libmsquic.so.2" - "$/libmsquic.so.2") - endif() -endif() + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") diff --git a/native/GameCult.Mesh.Quic.Native/README.md b/native/GameCult.Mesh.Quic.Native/README.md index ae5d78d2..875ebb3b 100644 --- a/native/GameCult.Mesh.Quic.Native/README.md +++ b/native/GameCult.Mesh.Quic.Native/README.md @@ -1,168 +1,10 @@ # GameCult.Mesh.Quic.Native -One MsQuic body for every CultMesh host that is not .NET: two roles (provider -and consumer), two platforms (Windows x64 and Linux x64), and no callbacks -across the host boundary. +This Windows desktop bridge keeps MsQuic's callback-heavy C API below the +managed runtime boundary. It accepts a discovery-authorized SHA-256 certificate +pin, negotiates `cultmesh-state-v1`, and emits complete CultMesh realtime frame +payloads. It owns transport only; frame meaning remains in `GameCult.Mesh`. -The bridge owns MsQuic registration, configuration, listener, connection and -stream handles, TLS credential loading, the `cultmesh-state-v1` ALPN, the -1-byte-kind plus 4-byte-LE-length QUIC stream framing, and one event queue per -runtime. It owns transport and nothing else: it never decodes a CultMesh frame, -never decides delivery semantics, never coalesces, never reconnects, and in the -v2 path never decides whether to trust a certificate. Frame meaning stays in -`GameCult.Mesh` and in the TypeScript codec; trust stays with the host. - -## No host callbacks - -Every crossing is the host calling in, including the one blocking wait. MsQuic's -worker threads only ever push onto the queue and wake that wait, so no host -runtime is called from a thread it does not own. That is what lets one binary -serve a Node process through FFI and a Unity player through P/Invoke without -either one's threading rules leaking into the other. - -It is also why nothing here includes a Node header, a Unity header, or a -JavaScript engine: the bridge does not know what is on the other side. - -## Two ABIs - -**v2** is the runtime-neutral queue API, and the one new hosts should use. It is -stated once, in [`include/cultmesh_quic_native.h`](include/cultmesh_quic_native.h): -the 64-byte event struct with its field order and asserted offsets, the thirteen -exports with their signatures, the id and return-code conventions, the object -lifetime rules and the threading rules. That file is the contract. This README -does not restate it, and where the two ever disagree the header is right. - -A host that can include it should. A host that cannot — koffi, Unity's -P/Invoke — declares the same thing in its own FFI and is checked against the -header by a test rather than by eye. - -Two things worth reading before writing a host, because they are easy to get -wrong from the outside: - -- **Return codes are negative on a bad call, on every platform.** `-1` is a bad - call, `-2` a rejected argument, `<= -1000` a MsQuic refusal. `QUIC_STATUS` - itself is not portable — a negative HRESULT on Windows, a positive errno on - POSIX — so it never reaches a host; `cultmesh_quic_last_status` hands over the - raw value for diagnostics and nothing else. -- **`cultmesh_quic_runtime_close` quiesces.** It marks the runtime closing, wakes - every blocked `cultmesh_quic_next_event`, and waits for every in-flight call to - return before it frees anything. No host call may begin after it starts. - -A client connection defers certificate validation: the bridge indicates the -certificate, emits event 3 carrying the DER, and answers MsQuic only when the -host calls `cultmesh_quic_connection_certificate_complete`. If the host never -answers, MsQuic's 10-second handshake timeout closes the connection and event 4 -reports it. Providers load their certificate as PKCS12; the advertised -`cert-sha256` pin is computed by the host from that same PKCS12, not here. - -**v1** is the five exports the shipped Unity package imports by name -(`cultmesh_quic_open`, `_state`, `_poll`, `_error`, `_close`). They are Windows -only, matching `CultMeshNativeQuicRealtimeTransport.CanConnect`, and are -implemented on top of a private v2 runtime holding one connection. They decide -nothing the v2 path does not, with one exception: a v1 connection carries a -SHA-256 pin that is compared here, in C, because that is the contract the -managed connector was built against. A v2 connection never carries one. - -## Building - -Windows x64, from the repo root: - - powershell -File scripts/build-quic-native.ps1 - -MsQuic comes from `Microsoft.Native.Quic.MsQuic.OpenSSL` 2.5.9, pinned twice: the -NuGet zip by digest and size, and the `msquic.dll` inside it by digest and size, -because the archive is what the download is checked against and the DLL is what -actually ships beside the bridge. The OpenSSL flavour rather than Schannel -because Schannel's MsQuic refuses `QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12`, -which is the only credential type a provider has on both platforms. - -The build uses MSVC with `/Brepro` so that a build reproduces itself: the link -timestamps are dropped, and building the same source twice gives the same bytes. -It links the CRT statically (`/MT`), because a Node or Unity host without the -Visual C++ redistributable cannot load a DLL that imports `vcruntime140.dll`, -and the failure it reports names no missing runtime; the bridge carries its own. - -The committed Unity plugin -(`unity/org.gamecult.cultlib/Runtime/Plugins/x86_64/gamecult_mesh_quic_native.dll`) -is this build: 302,080 bytes, `/MT`, OpenSSL MsQuic, no CRT import. It replaced -an older 40,448-byte build — before `/MT` and before the move off Schannel, -still importing `VCRUNTIME140.dll` and `MSVCP140.dll` — at the CultLib 1.0.60 / -Studio 1.4.0 release. - -Linux x64, on a Debian 13 host: - - scripts/build-quic-native.sh - -MsQuic comes from Microsoft's Debian 13 pool as a pinned `.deb`, and the POSIX -headers from the msquic source tree at the matching tag; all four downloads are -verified by SHA-256. The bridge is linked with `-fvisibility=hidden` so only the -v2 exports leave the `.so`, and with `RPATH $ORIGIN` so the `libmsquic.so.2` -shipped beside it resolves without being installed system-wide. - -A Linux host still needs MsQuic's own runtime dependencies present: -`libssl3t64`, `libnuma1`, `libxdp1`, `libnl-route-3-200`. - -The development loop for it is one committed image, -[`scripts/quic-native-linux-dev.Dockerfile`](../../scripts/quic-native-linux-dev.Dockerfile), -pinned to the same `debian:13` digest the release workflow builds in. It carries -the toolchain, MsQuic's runtime dependencies, and `setarch`, because a -sanitizer build of the runtime-lifetime scenarios needs `personality(ADDR_NO_RANDOMIZE)` -and a container that cannot ask for that cannot run them: - - docker build -t cultlib-quic-native-dev -f scripts/quic-native-linux-dev.Dockerfile scripts - docker run --rm --security-opt seccomp=unconfined -v "${PWD}:/src" -w /src cultlib-quic-native-dev bash -lc " - 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=\$(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 - done - " - -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. `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 -assertions and the four scenarios (`holdclose`, `latecall`, `holdtimeout`, -`waitseam`) that need the development seam in section 7 of the header. Each -scenario name is a positional argument to the built binary; run them individually -to bisect a failure, or all ten as shown to cover the runtime-lifetime rules. - -A Windows clone made before `.gitattributes` kept shell scripts at LF still has -`scripts/build-quic-native.sh` with carriage returns, and pulling does not fix -it: the script's content did not change, so git neither rewrites it nor reports -it modified. The container then dies at once on the interpreter `bash\r`. From -the repository root, in either shell, this checks the script out again under the -current attributes: - - git rm --cached -q scripts/build-quic-native.sh; git checkout HEAD -- scripts/build-quic-native.sh - -`--security-opt seccomp=unconfined` is load-bearing, not caution. A -ThreadSanitizer configuration needs the process's address space where it expects -it, so a TSan run must be re-executed under `setarch -R`, which asks the kernel -for `personality(ADDR_NO_RANDOMIZE)`. Docker's default seccomp profile denies -that call. Without it ThreadSanitizer dies before `main` with "unexpected memory -mapping" — a configuration that never starts, not a scenario that passed. - -A win32-x64 build of the scenario runner needs the checkout somewhere short — -near a drive root rather than under a deep temporary directory. MSVC builds the -bridge and its scenario runner through MSBuild, whose file tracker gives out on -long paths, and the build then fails outright rather than naming the path as the -cause. - -Where an artifact was built is part of what it is. The Linux binary is linked -against Debian 13's glibc, so a build from anywhere else is a different artifact -whatever the file name says. +The build consumes Microsoft's pinned +`Microsoft.Native.Quic.MsQuic.Schannel` package. It does not contain +Aetheria-specific code. diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp index 5964de43..bbb6e193 100644 --- a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp +++ b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp @@ -1,116 +1,24 @@ -// The CultMesh MsQuic bridge: one MsQuic body, two roles, two platforms. -// -// This file owns MsQuic registration, configuration, listener, connection and -// stream handles, TLS credential loading, the 1-byte-kind plus 4-byte-LE-length -// QUIC stream framing, and a single event queue per runtime. It owns transport -// only. It never decodes a CultMesh frame, never decides delivery semantics, -// never coalesces, never reconnects, and in the v2 path never accepts a -// certificate on its own: the host answers, through -// `cultmesh_quic_connection_certificate_complete`. -// -// No host callbacks. Every crossing is the host calling in, including the one -// blocking wait (`cultmesh_quic_next_event`). MsQuic's worker threads only ever -// push onto the queue and wake that wait, so the bridge is runtime-neutral: it -// includes no Node headers, no Unity headers, and nothing platform-specific -// outside the `_WIN32` block at the bottom of this file. -// -// Two ABIs live here. v2 is the queue API above, for any host. v1 is the five -// exports the Unity managed connector already imports by name -// (`CultMeshNativeQuicRealtimeTransport.cs`, `NativeMethods`), kept Windows-only -// and implemented on top of a private v2 runtime. v1 is the only place a -// certificate pin is compared in C, because that is the contract Unity was -// shipped with; it delegates everything else and decides nothing new. - -// The host-facing contract lives in include/cultmesh_quic_native.h and is -// included here so this file cannot drift from it: the event layout, the -// exports, the return codes, the lifetime rules and the threading rules are -// stated there once and asserted at compile time. -#define CULTMESH_QUIC_BUILD 1 -#include - +#include +#include +#include #include -#include #include #include -#include #include #include -#include -#include #include #include #include #include #include #include -#include -#include #include -#define CULTMESH_API extern "C" CULTMESH_QUIC_API - -// Development builds only, and never in a shipped binary: after -// `cultmesh_quic_runtime_close` has quiesced, no host call may still be inside -// the library. The wait is what makes that true, and its absence is otherwise -// visible only as a use-after-free a sanitizer catches by luck — a poller has to -// be preempted in the right window. With pollers blocked, this states the -// invariant directly, so a build without the wait dies on the first close rather -// than on the run that happens to be unlucky. -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) -#define CULTMESH_QUIC_ASSERT_QUIESCED(count) \ - do { \ - const int cultmesh_quic_remaining = (count); \ - if (cultmesh_quic_remaining != 0) { \ - std::fprintf(stderr, \ - "cultmesh_quic_runtime_close: %d host call(s) still inside the " \ - "library after the quiesce\n", cultmesh_quic_remaining); \ - std::fflush(stderr); \ - std::abort(); \ - } \ - } while (false) +#if defined(GAMECULT_MESH_QUIC_NATIVE_EXPORTS) +#define CULTMESH_API extern "C" __declspec(dllexport) #else -#define CULTMESH_QUIC_ASSERT_QUIESCED(count) ((void)0) -#endif - -// Development builds only, and never in a shipped binary: the seam the scenario -// runner drives the quiesce through. A scenario that only sleeps and hopes its -// pollers are inside the library cannot tell a bridge that counts host calls -// from one that does not, so the bridge says so itself: -// -// - PEAK raises the high-water mark of calls counted inside, so a scenario can -// assert that the calls it launched were counted rather than assumed. -// - AT_CLOSE records how many were counted when the close began its wait, which -// is the number the wait exists for. -// - HELD parks a call inside the library until the scenario releases it, so the -// close races calls that are genuinely there for as long as the scenario -// wants them there, instead of for as long as the schedule happens to give. -// It returns true when it parked, and a held call makes no further call of -// its own; what it does still do is leave its CallScope, which takes the gate, -// decrements the count and notifies. That is the ordinary exit every export -// makes, and it is the bridge's wait — not the hold — that keeps the runtime -// alive underneath it. -// - RECORD_WAIT wraps the whole duration handed to the condition wait in -// `cultmesh_quic_next_event`, in place, as a pass-through: it stores that -// duration's count and returns the duration unchanged, so a mutation to -// what is actually waited on changes what this reports rather than the two -// ever being able to diverge. It wraps `timeout_ms` after conversion to -// `std::chrono::milliseconds`, not before — Self's ruling of 2026-09-22, -// after arithmetic applied to the converted duration survived a seam that -// only recorded the raw integer underneath it. A scenario reads it and -// asserts equality against the timeout it asked for, deterministically and -// with no timing tolerance, in place of inferring the same fact from how -// long a call took to return. -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) -#define CULTMESH_QUIC_DEBUG_PEAK(count) DebugRaisePeak(count) -#define CULTMESH_QUIC_DEBUG_AT_CLOSE(count) DebugRecordCallsAtClose(count) -#define CULTMESH_QUIC_DEBUG_HELD(lock) DebugHoldCall(lock) -#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(duration) DebugRecordWait(duration) -#else -#define CULTMESH_QUIC_DEBUG_PEAK(count) ((void)0) -#define CULTMESH_QUIC_DEBUG_AT_CLOSE(count) ((void)0) -#define CULTMESH_QUIC_DEBUG_HELD(lock) (false) -#define CULTMESH_QUIC_DEBUG_RECORD_WAIT(duration) (duration) +#define CULTMESH_API extern "C" __declspec(dllimport) #endif namespace { @@ -118,1168 +26,11 @@ namespace { constexpr uint32_t kApiVersion = 2; constexpr uint64_t kConnectionCloseCode = 0x43554c54; constexpr uint64_t kStreamAbortCode = 0x53544154; -constexpr uint8_t kReliableStream = CULTMESH_QUIC_STREAM_RELIABLE; -constexpr uint8_t kLatestOnlyStream = CULTMESH_QUIC_STREAM_LATEST_ONLY; +constexpr uint8_t kReliableStream = 1; +constexpr uint8_t kLatestOnlyStream = 2; constexpr uint32_t kMaximumEncodedFrameBytes = (64u * 1024u * 1024u) + 37u + (3u * 65535u); constexpr char kAlpn[] = "cultmesh-state-v1"; -// Mirrors the C# server and connector: CultMeshQuicRealtimeTransport.cs:77,:202 -// for the stream ceiling, and the idle/keep-alive pair the v1 bridge already -// used (:335-338). -constexpr uint32_t kIdleTimeoutMs = 30000; -constexpr uint32_t kKeepAliveIntervalMs = 5000; -constexpr uint16_t kPeerUnidiStreamCount = 1024; -// If the host never answers the certificate event, this is what closes the -// connection instead of leaking it: MsQuic's own handshake timeout fires and -// the shutdown arrives as event 4. -constexpr uint32_t kHandshakeIdleTimeoutMs = 10000; - -// Short names for the header's event codes; the header is the contract. -constexpr uint32_t kEventListenerNewConnection = CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION; -constexpr uint32_t kEventConnectionConnected = CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED; -constexpr uint32_t kEventConnectionCertificateReceived = CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED; -constexpr uint32_t kEventConnectionShutdown = CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN; -constexpr uint32_t kEventStreamStarted = CULTMESH_QUIC_EVENT_STREAM_STARTED; -constexpr uint32_t kEventStreamFrame = CULTMESH_QUIC_EVENT_STREAM_FRAME; -constexpr uint32_t kEventStreamSendComplete = CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE; -constexpr uint32_t kEventStreamShutdown = CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN; -constexpr uint32_t kEventListenerStopped = CULTMESH_QUIC_EVENT_LISTENER_STOPPED; - -struct Runtime; -struct Connection; - -struct QueuedEvent { - cultmesh_quic_event header{}; - std::vector payload; -}; - -// Objects are shared-owned, and that is the whole lifetime model. The runtime's -// maps hold one reference each; an export that names an id takes its own -// reference under the lock and holds it for the entire MsQuic call. MsQuic's -// worker, on shutdown-complete, drops the map's reference only. The handle -// follows the object: it is closed exactly once, in the destructor, by whichever -// holder drops last, under a one-shot exchange guard. See section 4 of -// include/cultmesh_quic_native.h, which is where this is promised to hosts. - -struct Stream { - Runtime* runtime = nullptr; - // Shared, not raw: this is what keeps a connection's handle open for as long - // as any of its streams still exist, so stream handles always close first. - std::shared_ptr owner; - HQUIC handle = nullptr; - uint64_t id = 0; - uint8_t kind = 0; - bool inbound = false; - bool has_kind = false; - std::atomic handle_closed{false}; - // Inbound reassembly only. MsQuic delivers stream bytes in arbitrary - // chunks, so the length prefix and the frame it introduces can arrive in - // separate receives. - std::vector buffered; - - ~Stream(); -}; - -struct Connection : std::enable_shared_from_this { - Runtime* runtime = nullptr; - HQUIC handle = nullptr; - uint64_t id = 0; - uint64_t listener_id = 0; - bool inbound = false; - // Written by the host thread in `cultmesh_quic_connection_shutdown`, read on - // an MsQuic worker when the shutdown completes: the code the host passed is - // what its own event 4 has to carry. - std::atomic closing{false}; - std::atomic shutdown_code{0}; - // Why it ended, recorded by whichever initiated-by callback saw it and read - // by shutdown-complete, which is the single emitter of event 4. Both run on - // this connection's own worker, serialized. - std::string shutdown_reason; - int32_t shutdown_status = 0; - // Set when MsQuic is closing the handle itself (an app close already in - // progress), so the destructor's one-shot close stands down. - std::atomic handle_closed{false}; - // v1 only. A connection carrying a pin has its certificate answered in C - // against that pin; a v2 connection never sets this and is always answered - // by the host. Nothing outside the `_WIN32` block below ever sets it. - bool has_pin = false; - std::array pin{}; - // v1 diagnostics: the MsQuic event sequence, which the v1 error string - // reports and nothing in v2 reads. - std::string trace; - - ~Connection(); -}; - -struct Listener { - Runtime* runtime = nullptr; - HQUIC handle = nullptr; - HQUIC configuration = nullptr; - uint64_t id = 0; - std::atomic handle_closed{false}; - - ~Listener(); -}; - -struct Runtime { - const QUIC_API_TABLE* api = nullptr; - HQUIC registration = nullptr; - // One client configuration serves every outbound connection: the ALPN, - // settings and credential flags are identical for all of them. - HQUIC client_configuration = nullptr; - - std::mutex gate; - std::condition_variable signal; - std::deque events; - std::unordered_map> listeners; - std::unordered_map> connections; - std::unordered_map> streams; - uint64_t next_id = 1; - std::string error; - // The raw platform status behind the last MsQuic refusal, kept because the - // portable return code deliberately throws its bits away. - int32_t last_status = 0; - bool closing = false; - // Host calls currently inside the library. `cultmesh_quic_runtime_close` - // waits for this to reach zero before it touches a handle. - int active_calls = 0; -}; - -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) -// The development seam behind the three CULTMESH_QUIC_DEBUG_ macros. None of it -// is compiled into a shipped library. -std::atomic debug_peak_calls{0}; -std::atomic debug_calls_at_close{-1}; -std::mutex debug_hold_gate; -std::condition_variable debug_hold_signal; -bool debug_hold_armed = false; -std::atomic debug_last_wait_ms{-1}; -// Separate from the value above on purpose: once the seam records the whole -// duration (not just a millisecond count already known to be positive), a -// mutation could in principle make a wait get recorded at exactly -1, and an -// int32_t alone cannot tell that apart from "nothing recorded yet". This flag -// is the "recorded" bit; the getter below is the only thing that still hands -// out -1 for the unset case. -std::atomic debug_wait_recorded{false}; - -void DebugRaisePeak(int count) { - int peak = debug_peak_calls.load(std::memory_order_relaxed); - while (count > peak && !debug_peak_calls.compare_exchange_weak(peak, count)) { - } -} - -void DebugRecordCallsAtClose(int count) { debug_calls_at_close.store(count); } - -// Parks the calling host thread inside the library until the scenario releases -// it. The runtime gate is dropped first, so nothing else is blocked behind the -// held call, and it is never retaken: the call is still counted, because its -// CallScope is still alive, and that is the whole point. -bool DebugHoldCall(std::unique_lock& lock) { - std::unique_lock hold(debug_hold_gate); - if (!debug_hold_armed) return false; - lock.unlock(); - debug_hold_signal.wait(hold, [] { return !debug_hold_armed; }); - return true; -} - -// A pass-through: it stores the duration's count and returns the duration -// unmodified, so wrapping it around the whole expression handed to the -// condition wait makes the two impossible to desync by construction. Whatever -// that expression evaluates to — the plain converted argument today, or a -// derivation a future change or a mutation introduces inside this call — is -// what gets recorded, because it is the same evaluation. Anything applied -// outside this call, after it returns, is what the wall-clock scenarios exist -// to catch instead; see their generous-tolerance comment. -std::chrono::milliseconds DebugRecordWait(std::chrono::milliseconds duration) { - debug_last_wait_ms.store(static_cast(duration.count())); - debug_wait_recorded.store(true); - return duration; -} -#endif - -// Every host-facing export enters through this. It refuses once -// `cultmesh_quic_runtime_close` has started, and otherwise holds the runtime -// open for the length of the call. -class CallScope { -public: - explicit CallScope(Runtime* runtime) : runtime_(runtime) { - if (runtime_ == nullptr) return; - std::lock_guard lock(runtime_->gate); - if (runtime_->closing) return; - ++runtime_->active_calls; - CULTMESH_QUIC_DEBUG_PEAK(runtime_->active_calls); - entered_ = true; - } - // The notify stays under `gate`, and that is the whole point of it. Released - // first, the closer's predicate is already true, so it can return from its - // wait, run the teardown and destroy the runtime — condition variable - // included — before this thread reaches the notify. Holding the lock across - // both means the closer cannot leave `signal.wait` until this scope has let - // go, so the condition variable is still alive when it is signalled. - ~CallScope() { - if (!entered_) return; - std::lock_guard lock(runtime_->gate); - --runtime_->active_calls; - runtime_->signal.notify_all(); - } - CallScope(const CallScope&) = delete; - CallScope& operator=(const CallScope&) = delete; - bool entered() const { return entered_; } - -private: - Runtime* runtime_ = nullptr; - bool entered_ = false; -}; - -Stream::~Stream() { - if (handle != nullptr && runtime != nullptr && runtime->api != nullptr && - !handle_closed.exchange(true)) - runtime->api->StreamClose(handle); -} - -Connection::~Connection() { - if (handle != nullptr && runtime != nullptr && runtime->api != nullptr && - !handle_closed.exchange(true)) - runtime->api->ConnectionClose(handle); -} - -Listener::~Listener() { - if (runtime == nullptr || runtime->api == nullptr) return; - if (handle != nullptr && !handle_closed.exchange(true)) runtime->api->ListenerClose(handle); - if (configuration != nullptr) runtime->api->ConfigurationClose(configuration); -} - -// Every send buffer outlives its `StreamSend` call: MsQuic reads the bytes -// asynchronously and only releases them at SEND_COMPLETE, where this is freed. -struct SendRequest { - QUIC_BUFFER buffer{}; - std::vector bytes; - // Which send this was. The bridge writes the stream's kind byte itself, and - // that completion is not the host's to hear about; event 7 is one per - // `cultmesh_quic_stream_send_frame` and nothing else. - bool is_frame = false; -}; - -// A `QUIC_STATUS` reaches this through `uint32_t`, never straight to `uint64_t`: -// it is a signed HRESULT on Windows, and widening it signed printed every -// Windows failure as `0xFFFFFFFF80072B18`. The status is 32 bits on both -// platforms and is read as 32 bits here. -std::string Hex(uint64_t value) { - std::ostringstream text; - text << "0x" << std::hex << std::uppercase << value; - return text.str(); -} - -void SetError(Runtime* runtime, const std::string& message) { - if (runtime == nullptr) return; - std::lock_guard lock(runtime->gate); - runtime->error = message; -} - -// `QUIC_STATUS` never reaches a host. It is a negative HRESULT on Windows and a -// positive errno on POSIX, so a host that checked for a negative value saw every -// Linux failure as success. One negative space on both platforms instead; the -// raw bits go to `cultmesh_quic_last_status`. See section 3 of the header. -int32_t MsQuicCode(QUIC_STATUS status) { - return CULTMESH_QUIC_RESULT_MSQUIC_BASE - - static_cast(static_cast(status) & 0xffffu); -} - -int32_t Refuse(Runtime* runtime, QUIC_STATUS status, const std::string& message) { - if (runtime != nullptr) { - std::lock_guard lock(runtime->gate); - runtime->error = message; - runtime->last_status = static_cast(status); - } - return MsQuicCode(status); -} - -// The only way anything reaches the host. Callers must not hold `gate`. -void Publish(Runtime* runtime, const cultmesh_quic_event& header, const uint8_t* payload, size_t payload_length) { - QueuedEvent queued; - queued.header = header; - queued.header.payload_length = static_cast(payload_length); - if (payload != nullptr && payload_length > 0) - queued.payload.assign(payload, payload + payload_length); - { - std::lock_guard lock(runtime->gate); - if (runtime->closing) return; - runtime->events.push_back(std::move(queued)); - } - runtime->signal.notify_all(); -} - -void PublishSimple(Runtime* runtime, uint32_t type, uint64_t listener_id, uint64_t connection_id, - uint64_t stream_id, uint64_t code, int32_t status, uint32_t stream_kind = 0) { - cultmesh_quic_event header{}; - header.type = type; - header.stream_kind = stream_kind; - header.listener_id = listener_id; - header.connection_id = connection_id; - header.stream_id = stream_id; - header.code = code; - header.status = status; - Publish(runtime, header, nullptr, 0); -} - -// v1 diagnostics only, and bounded. A v2 connection has no reader for this, and -// a long-lived one appended to it once per MsQuic event for as long as it lived. -constexpr size_t kMaximumTraceBytes = 512; - -void AppendTrace(Connection* connection, const std::string& event) { - if (connection == nullptr || !connection->has_pin) return; - std::lock_guard lock(connection->runtime->gate); - if (connection->trace.size() >= kMaximumTraceBytes) return; - if (!connection->trace.empty()) connection->trace += ","; - connection->trace += event; -} - -uint32_t ReadUInt32LittleEndian(const uint8_t* value) { - return static_cast(value[0]) | - (static_cast(value[1]) << 8) | - (static_cast(value[2]) << 16) | - (static_cast(value[3]) << 24); -} - -void WriteUInt32LittleEndian(uint8_t* destination, uint32_t value) { - destination[0] = static_cast(value & 0xff); - destination[1] = static_cast((value >> 8) & 0xff); - destination[2] = static_cast((value >> 16) & 0xff); - destination[3] = static_cast((value >> 24) & 0xff); -} - -uint64_t NextId(Runtime* runtime) { - std::lock_guard lock(runtime->gate); - return runtime->next_id++; -} - -// The three lookups. Each returns a reference the caller owns for as long as it -// holds it, taken under the lock, so nothing the caller then names can be freed -// under it. A raw pointer here was the use-after-free. -std::shared_ptr FindConnection(Runtime* runtime, uint64_t id) { - std::lock_guard lock(runtime->gate); - const auto found = runtime->connections.find(id); - return found == runtime->connections.end() ? nullptr : found->second; -} - -std::shared_ptr FindStream(Runtime* runtime, uint64_t id) { - std::lock_guard lock(runtime->gate); - const auto found = runtime->streams.find(id); - return found == runtime->streams.end() ? nullptr : found->second; -} - -// Takes the object out of its map and returns it, so the caller's reference — -// and any destructor it ends up running — lives outside the lock. -template -auto Detach(std::mutex& gate, Map& map, uint64_t id) { - typename Map::mapped_type detached; - std::lock_guard lock(gate); - const auto found = map.find(id); - if (found == map.end()) return detached; - detached = std::move(found->second); - map.erase(found); - return detached; -} - -void ShutdownConnectionForProtocolFault(const std::shared_ptr& connection, const std::string& message) { - if (connection == nullptr) return; - SetError(connection->runtime, message); - if (connection->runtime->api != nullptr && connection->handle != nullptr) - connection->runtime->api->ConnectionShutdown( - connection->handle, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, kStreamAbortCode); -} - -// Reassembles whole encoded frames out of the stream byte sequence and -// publishes one event 6 per frame, length prefix stripped. The first byte of an -// inbound stream is its kind, which becomes event 5. -bool ConsumeFrames(Stream* stream) { - Runtime* runtime = stream->runtime; - auto& bytes = stream->buffered; - if (!stream->has_kind) { - if (bytes.empty()) return true; - stream->kind = bytes.front(); - bytes.erase(bytes.begin()); - stream->has_kind = true; - if (stream->kind != kReliableStream && stream->kind != kLatestOnlyStream) { - ShutdownConnectionForProtocolFault(stream->owner, "CultMesh QUIC stream kind is invalid."); - return false; - } - PublishSimple(runtime, kEventStreamStarted, stream->owner->listener_id, stream->owner->id, - stream->id, 0, 0, stream->kind); - } - - while (bytes.size() >= sizeof(uint32_t)) { - const uint32_t length = ReadUInt32LittleEndian(bytes.data()); - if (length == 0 || length > kMaximumEncodedFrameBytes) { - ShutdownConnectionForProtocolFault(stream->owner, "CultMesh QUIC frame length is invalid."); - return false; - } - const size_t framed_length = sizeof(uint32_t) + static_cast(length); - if (bytes.size() < framed_length) return true; - - cultmesh_quic_event header{}; - header.type = kEventStreamFrame; - header.stream_kind = stream->kind; - header.listener_id = stream->owner->listener_id; - header.connection_id = stream->owner->id; - header.stream_id = stream->id; - Publish(runtime, header, bytes.data() + sizeof(uint32_t), length); - - bytes.erase(bytes.begin(), bytes.begin() + static_cast(framed_length)); - } - return true; -} - -// Drops the map's reference and nothing else. If the map was the last holder the -// object dies here, outside the lock, and its destructor closes the handle; if a -// host call is still inside, the object outlives this by exactly that long. -void DestroyStream(Runtime* runtime, uint64_t stream_id) { - [[maybe_unused]] const auto detached = Detach(runtime->gate, runtime->streams, stream_id); -} - -QUIC_STATUS QUIC_API StreamCallback(HQUIC, void* context, QUIC_STREAM_EVENT* event) { - auto* stream = static_cast(context); - Runtime* runtime = stream->runtime; - switch (event->Type) { - case QUIC_STREAM_EVENT_RECEIVE: - for (uint32_t index = 0; index < event->RECEIVE.BufferCount; ++index) { - const auto& buffer = event->RECEIVE.Buffers[index]; - stream->buffered.insert(stream->buffered.end(), buffer.Buffer, buffer.Buffer + buffer.Length); - } - ConsumeFrames(stream); - break; - case QUIC_STREAM_EVENT_SEND_COMPLETE: { - // Which send finished is the client context's job to say. Without it the - // bridge's own kind byte completed as an anonymous event 7 and the host - // counted six completions for five frames. - auto* request = static_cast(event->SEND_COMPLETE.ClientContext); - const bool is_frame = request != nullptr && request->is_frame; - delete request; - if (is_frame) - PublishSimple(runtime, kEventStreamSendComplete, stream->owner->listener_id, stream->owner->id, - stream->id, - event->SEND_COMPLETE.Canceled - ? CULTMESH_QUIC_SEND_CANCELED - : CULTMESH_QUIC_SEND_COMPLETED, - 0, stream->kind); - break; - } - case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN: - if (!stream->buffered.empty()) - ShutdownConnectionForProtocolFault(stream->owner, "CultMesh QUIC stream ended with a truncated frame."); - break; - case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: { - // Everything this event reports is read before the map's reference goes, - // because that reference may be the last one and `stream` may not - // survive `DestroyStream`. - const uint64_t stream_id = stream->id; - const uint64_t connection_id = stream->owner->id; - const uint64_t listener_id = stream->owner->listener_id; - const uint32_t kind = stream->kind; - // An app close already in progress means MsQuic closes this handle - // itself; anything else leaves it to the last holder's destructor. - if (event->SHUTDOWN_COMPLETE.AppCloseInProgress) stream->handle_closed.store(true); - DestroyStream(runtime, stream_id); - PublishSimple(runtime, kEventStreamShutdown, listener_id, connection_id, stream_id, 0, 0, kind); - break; - } - default: - break; - } - return QUIC_STATUS_SUCCESS; -} - -void DestroyConnection(Runtime* runtime, uint64_t connection_id) { - // Declared first so it dies last: whatever order these drop in, a stream - // holds its connection, so every stream handle closes before the connection - // handle does. - std::shared_ptr connection; - std::vector> streams; - { - std::lock_guard lock(runtime->gate); - for (auto it = runtime->streams.begin(); it != runtime->streams.end();) { - if (it->second->owner != nullptr && it->second->owner->id == connection_id) { - streams.push_back(std::move(it->second)); - it = runtime->streams.erase(it); - } else { - ++it; - } - } - const auto found = runtime->connections.find(connection_id); - if (found != runtime->connections.end()) { - connection = std::move(found->second); - runtime->connections.erase(found); - } - } -} - -#if defined(_WIN32) -// Declared here, defined in the v1 block at the bottom: the pin comparison is -// the one piece of the certificate path that is Windows-only and v1-only. -bool CertificateMatchesPin(Connection* connection, QUIC_CERTIFICATE* certificate); -#endif - -QUIC_STATUS QUIC_API ConnectionCallback(HQUIC handle, void* context, QUIC_CONNECTION_EVENT* event) { - auto* connection = static_cast(context); - Runtime* runtime = connection->runtime; - AppendTrace(connection, "event=" + std::to_string(static_cast(event->Type))); - switch (event->Type) { - case QUIC_CONNECTION_EVENT_CONNECTED: - PublishSimple(runtime, kEventConnectionConnected, connection->listener_id, connection->id, 0, 0, 0); - break; - case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: { - auto stream = std::make_shared(); - stream->runtime = runtime; - stream->owner = connection->shared_from_this(); - stream->handle = event->PEER_STREAM_STARTED.Stream; - stream->inbound = true; - stream->id = NextId(runtime); - Stream* raw = stream.get(); - { - std::lock_guard lock(runtime->gate); - runtime->streams.emplace(raw->id, std::move(stream)); - } - runtime->api->SetCallbackHandler( - event->PEER_STREAM_STARTED.Stream, reinterpret_cast(StreamCallback), raw); - break; - } - case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED: { - const auto* encoded = reinterpret_cast(event->PEER_CERTIFICATE_RECEIVED.Certificate); - AppendTrace(connection, "certificate-status=" + - Hex(static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus))); -#if defined(_WIN32) - if (connection->has_pin) { - // The v1 contract: the pin decision is made here, in C, and the host - // is never asked. Only `cultmesh_quic_open` sets `has_pin`. - const bool matches = CertificateMatchesPin( - connection, event->PEER_CERTIFICATE_RECEIVED.Certificate); - if (!matches) - SetError(runtime, - "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin."); - const auto completion = runtime->api->ConnectionCertificateValidationComplete( - handle, - matches ? static_cast(1) : static_cast(0), - matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE); - if (QUIC_FAILED(completion)) { - SetError(runtime, "CultMesh QUIC certificate validation completion failed (status=" + - Hex(static_cast(completion)) + ")."); - return completion; - } - return QUIC_STATUS_PENDING; - } -#endif - // v2: the DER goes to the host and the handshake waits. Nothing here - // decides trust. If the host never answers, MsQuic's handshake timeout - // closes the connection and event 4 reports it. - cultmesh_quic_event header{}; - header.type = kEventConnectionCertificateReceived; - header.listener_id = connection->listener_id; - header.connection_id = connection->id; - header.status = static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus); - const uint8_t* der = (encoded != nullptr) ? encoded->Buffer : nullptr; - const size_t der_length = (encoded != nullptr) ? encoded->Length : 0; - Publish(runtime, header, der, der_length); - return QUIC_STATUS_PENDING; - } - // The two initiated-by events say why a connection is ending, not that it - // has ended; the object and its id are still live here. They record the - // reason and the code, and shutdown-complete below is the one place event 4 - // is emitted, once per connection id, after the id has stopped resolving. - // All three of these run on the connection's own MsQuic worker, serialized, - // which is what lets these fields be plain members. - case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: - connection->shutdown_reason = "CultMesh QUIC connection was shut down by the transport (status=" + - Hex(static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) + ", error=" + - Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) + ")."; - connection->shutdown_code.store(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode); - connection->shutdown_status = static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status); - break; - case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: - connection->shutdown_reason = "CultMesh QUIC connection was shut down by the peer."; - connection->shutdown_code.store(event->SHUTDOWN_INITIATED_BY_PEER.ErrorCode); - break; - case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: { - const uint64_t connection_id = connection->id; - if (event->SHUTDOWN_COMPLETE.AppCloseInProgress) connection->handle_closed.store(true); - // A host that ended the connection itself gets nothing else from MsQuic - // — no initiated-by-peer, no initiated-by-transport — so without this - // its own connection id simply stopped existing and the end of a - // connection was observable only from the other side. - cultmesh_quic_event header{}; - header.type = kEventConnectionShutdown; - header.listener_id = connection->listener_id; - header.connection_id = connection_id; - header.code = connection->shutdown_code.load(); - header.status = connection->shutdown_status; - const std::string reason = connection->shutdown_reason.empty() - ? std::string("CultMesh QUIC connection was shut down by the host.") - : connection->shutdown_reason; - DestroyConnection(runtime, connection_id); - Publish(runtime, header, reinterpret_cast(reason.data()), reason.size()); - break; - } - default: - break; - } - return QUIC_STATUS_SUCCESS; -} - -QUIC_STATUS QUIC_API ListenerCallback(HQUIC, void* context, QUIC_LISTENER_EVENT* event) { - auto* listener = static_cast(context); - Runtime* runtime = listener->runtime; - switch (event->Type) { - case QUIC_LISTENER_EVENT_NEW_CONNECTION: { - auto connection = std::make_shared(); - connection->runtime = runtime; - connection->handle = event->NEW_CONNECTION.Connection; - connection->inbound = true; - connection->listener_id = listener->id; - connection->id = NextId(runtime); - Connection* raw = connection.get(); - { - std::lock_guard lock(runtime->gate); - runtime->connections.emplace(raw->id, std::move(connection)); - } - runtime->api->SetCallbackHandler( - event->NEW_CONNECTION.Connection, reinterpret_cast(ConnectionCallback), raw); - const auto status = runtime->api->ConnectionSetConfiguration( - event->NEW_CONNECTION.Connection, listener->configuration); - if (QUIC_FAILED(status)) { - SetError(runtime, "CultMesh QUIC inbound connection configuration failed (status=" + - Hex(static_cast(status)) + ")."); - // Returning a failure here is MsQuic's signal to reject and close - // this connection itself, so the entry made a moment ago has to go - // with it rather than sitting in the map naming a dead handle. - raw->handle_closed.store(true); - DestroyConnection(runtime, raw->id); - return status; - } - PublishSimple(runtime, kEventListenerNewConnection, listener->id, raw->id, 0, 0, 0); - break; - } - case QUIC_LISTENER_EVENT_STOP_COMPLETE: - PublishSimple(runtime, kEventListenerStopped, listener->id, 0, 0, 0, 0); - break; - default: - break; - } - return QUIC_STATUS_SUCCESS; -} - -void FillSettings(QUIC_SETTINGS& settings) { - settings.IdleTimeoutMs = kIdleTimeoutMs; - settings.IsSet.IdleTimeoutMs = 1; - settings.KeepAliveIntervalMs = kKeepAliveIntervalMs; - settings.IsSet.KeepAliveIntervalMs = 1; - settings.PeerUnidiStreamCount = kPeerUnidiStreamCount; - settings.IsSet.PeerUnidiStreamCount = 1; - settings.HandshakeIdleTimeoutMs = kHandshakeIdleTimeoutMs; - settings.IsSet.HandshakeIdleTimeoutMs = 1; -} - -QUIC_BUFFER AlpnBuffer() { - return QUIC_BUFFER{ - static_cast(sizeof(kAlpn) - 1), - reinterpret_cast(const_cast(kAlpn)) - }; -} - -// Opened once per runtime, on first outbound connection. -QUIC_STATUS EnsureClientConfiguration(Runtime* runtime) { - if (runtime->client_configuration != nullptr) return QUIC_STATUS_SUCCESS; - - QUIC_SETTINGS settings{}; - FillSettings(settings); - QUIC_BUFFER alpn = AlpnBuffer(); - HQUIC configuration = nullptr; - auto status = runtime->api->ConfigurationOpen( - runtime->registration, &alpn, 1, &settings, sizeof(settings), nullptr, &configuration); - if (QUIC_FAILED(status)) return status; - - QUIC_CREDENTIAL_CONFIG credentials{}; - credentials.Type = QUIC_CREDENTIAL_TYPE_NONE; - credentials.Flags = static_cast( - QUIC_CREDENTIAL_FLAG_CLIENT | - QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED | - QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION | - QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES); - status = runtime->api->ConfigurationLoadCredential(configuration, &credentials); - if (QUIC_FAILED(status)) { - runtime->api->ConfigurationClose(configuration); - return status; - } - runtime->client_configuration = configuration; - return QUIC_STATUS_SUCCESS; -} - -// `pin` is the v1 shim's and nothing else passes it. It must be attached before -// `ConnectionStart`, not after it returns: the handshake runs on an MsQuic -// worker thread and reaches the certificate event immediately on loopback, so a -// pin applied afterwards loses the race, the connection takes the v2 path, and -// the handshake stalls waiting for an answer from a host that was never told to -// give one. -int32_t OpenConnection(Runtime* runtime, const char* host, uint16_t port, uint64_t* out_connection_id, - const std::array* pin) { - if (runtime == nullptr || host == nullptr || *host == '\0' || port == 0 || out_connection_id == nullptr) - return -1; - *out_connection_id = 0; - - auto status = EnsureClientConfiguration(runtime); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC client configuration failed (status=" + - Hex(static_cast(status)) + ")."); - } - - auto connection = std::make_shared(); - connection->runtime = runtime; - connection->id = NextId(runtime); - if (pin != nullptr) { - connection->has_pin = true; - connection->pin = *pin; - } - Connection* raw = connection.get(); - - status = runtime->api->ConnectionOpen(runtime->registration, ConnectionCallback, raw, &raw->handle); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC connection open failed (status=" + - Hex(static_cast(status)) + ")."); - } - { - std::lock_guard lock(runtime->gate); - runtime->connections.emplace(raw->id, connection); - } - - status = runtime->api->ConnectionStart( - raw->handle, runtime->client_configuration, QUIC_ADDRESS_FAMILY_UNSPEC, host, port); - if (QUIC_FAILED(status)) { - // The map's reference goes and this local one goes with the scope; the - // handle is closed by whichever of the two drops last, exactly once. - DestroyConnection(runtime, raw->id); - return Refuse(runtime, status, "CultMesh QUIC connection start failed (status=" + - Hex(static_cast(status)) + ")."); - } - - *out_connection_id = raw->id; - return 0; -} - -} // namespace - -// --------------------------------------------------------------------------- -// C ABI v2: the runtime-neutral bridge. -// --------------------------------------------------------------------------- - -CULTMESH_API int32_t cultmesh_quic_runtime_open(const char* app_name, void** out_runtime) { - if (out_runtime == nullptr) return -1; - *out_runtime = nullptr; - auto runtime = std::make_unique(); - - // No runtime yet to hang `last_status` on, so these two refusals lose the - // raw bits. Everything after this point keeps them. - auto status = MsQuicOpenVersion(kApiVersion, reinterpret_cast(&runtime->api)); - if (QUIC_FAILED(status)) return MsQuicCode(status); - - const QUIC_REGISTRATION_CONFIG registration_config = { - (app_name != nullptr && *app_name != '\0') ? app_name : "GameCult.Mesh.Quic.Native", - QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME - }; - status = runtime->api->RegistrationOpen(®istration_config, &runtime->registration); - if (QUIC_FAILED(status)) { - MsQuicClose(runtime->api); - return MsQuicCode(status); - } - - *out_runtime = runtime.release(); - return 0; -} - -CULTMESH_API void cultmesh_quic_runtime_close(void* handle) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return; - - // Quiesce before anything is closed or freed. Marking the runtime closing - // refuses every call that has not started; waking the pollers gets the - // blocked ones out of `next_event`; waiting for `active_calls` to reach zero - // is what makes the teardown below safe at all. A wake without the wait is - // how a poller returns into freed memory, so the wait is the contract, not - // the wake. Objects are taken out of the maps here and released after the - // lock, so their destructors run on this thread and close their handles. - std::vector> streams; - std::vector> connections; - std::vector> listeners; - { - std::unique_lock lock(runtime->gate); - if (runtime->closing) return; - runtime->closing = true; - runtime->signal.notify_all(); - CULTMESH_QUIC_DEBUG_AT_CLOSE(runtime->active_calls); - runtime->signal.wait(lock, [runtime] { return runtime->active_calls == 0; }); - CULTMESH_QUIC_ASSERT_QUIESCED(runtime->active_calls); - for (auto& entry : runtime->streams) streams.push_back(std::move(entry.second)); - for (auto& entry : runtime->connections) connections.push_back(std::move(entry.second)); - for (auto& entry : runtime->listeners) listeners.push_back(std::move(entry.second)); - runtime->streams.clear(); - runtime->connections.clear(); - runtime->listeners.clear(); - } - - // Bottom-up, and by the app's own hand. `RegistrationClose` blocks until - // every child handle the app opened has been closed *by the app*, and a - // silent shutdown delivers no shutdown events at all, so leaning on the - // callbacks to do the closing deadlocks the caller forever. - streams.clear(); - for (auto& connection : connections) - if (connection->handle != nullptr && !connection->handle_closed.load()) - runtime->api->ConnectionShutdown( - connection->handle, QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, kConnectionCloseCode); - connections.clear(); - listeners.clear(); - if (runtime->client_configuration != nullptr) { - runtime->api->ConfigurationClose(runtime->client_configuration); - runtime->client_configuration = nullptr; - } - - // No callback can be running past this point. - if (runtime->registration != nullptr) { - runtime->api->RegistrationClose(runtime->registration); - runtime->registration = nullptr; - } - if (runtime->api != nullptr) { - MsQuicClose(runtime->api); - runtime->api = nullptr; - } - delete runtime; -} - -CULTMESH_API int32_t cultmesh_quic_listener_open( - void* handle, const char* host, uint16_t port, - const uint8_t* pkcs12, int32_t pkcs12_len, const char* password, - uint64_t* out_listener_id, uint16_t* out_bound_port) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || pkcs12 == nullptr || pkcs12_len <= 0 || out_listener_id == nullptr) - return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - *out_listener_id = 0; - if (out_bound_port != nullptr) *out_bound_port = 0; - - // Held here until the listener is fully started. Every failure path below - // simply returns: the destructor closes whatever was opened, and a partial - // open therefore leaves neither a handle nor a map entry behind. - auto listener = std::make_shared(); - listener->runtime = runtime; - listener->id = NextId(runtime); - - QUIC_SETTINGS settings{}; - FillSettings(settings); - QUIC_BUFFER alpn = AlpnBuffer(); - auto status = runtime->api->ConfigurationOpen( - runtime->registration, &alpn, 1, &settings, sizeof(settings), nullptr, &listener->configuration); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC listener configuration failed (status=" + - Hex(static_cast(status)) + ")."); - } - - // PKCS12 is the one credential type Schannel and OpenSSL both take, which - // is why the provider ships a PKCS12 rather than a platform store handle. - QUIC_CERTIFICATE_PKCS12 pkcs12_certificate{}; - pkcs12_certificate.Asn1Blob = pkcs12; - pkcs12_certificate.Asn1BlobLength = static_cast(pkcs12_len); - pkcs12_certificate.PrivateKeyPassword = (password != nullptr && *password != '\0') ? password : nullptr; - - QUIC_CREDENTIAL_CONFIG credentials{}; - credentials.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_PKCS12; - credentials.CertificatePkcs12 = &pkcs12_certificate; - credentials.Flags = QUIC_CREDENTIAL_FLAG_NONE; - status = runtime->api->ConfigurationLoadCredential(listener->configuration, &credentials); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC listener certificate could not be loaded (status=" + - Hex(static_cast(status)) + ")."); - } - - Listener* raw = listener.get(); - status = runtime->api->ListenerOpen(runtime->registration, ListenerCallback, raw, &raw->handle); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC listener open failed (status=" + - Hex(static_cast(status)) + ")."); - } - - QUIC_ADDR address{}; - QuicAddrSetFamily(&address, QUIC_ADDRESS_FAMILY_UNSPEC); - QuicAddrSetPort(&address, port); - if (host != nullptr && *host != '\0' && !QuicAddrFromString(host, port, &address)) { - SetError(runtime, "CultMesh QUIC listener host is not an IP address."); - return -2; - } - - // In the map before it starts accepting, because the callback that accepts - // an inbound connection reads the listener through it. - { - std::lock_guard lock(runtime->gate); - runtime->listeners.emplace(raw->id, listener); - } - - QUIC_BUFFER listener_alpn = AlpnBuffer(); - status = runtime->api->ListenerStart(raw->handle, &listener_alpn, 1, &address); - if (QUIC_FAILED(status)) { - Detach(runtime->gate, runtime->listeners, raw->id); - return Refuse(runtime, status, "CultMesh QUIC listener start failed (status=" + - Hex(static_cast(status)) + ")."); - } - - // Port 0 means "pick one"; the host needs the chosen port to advertise it. - QUIC_ADDR bound{}; - uint32_t bound_size = sizeof(bound); - status = runtime->api->GetParam(raw->handle, QUIC_PARAM_LISTENER_LOCAL_ADDRESS, &bound_size, &bound); - if (QUIC_FAILED(status)) { - Detach(runtime->gate, runtime->listeners, raw->id); - return Refuse(runtime, status, "CultMesh QUIC listener address could not be read (status=" + - Hex(static_cast(status)) + ")."); - } - if (out_bound_port != nullptr) *out_bound_port = QuicAddrGetPort(&bound); - *out_listener_id = raw->id; - return 0; -} - -CULTMESH_API void cultmesh_quic_listener_close(void* handle, uint64_t listener_id) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return; - CallScope scope(runtime); - if (!scope.entered()) return; - // Taken out of the map, then released here: ~Listener closes the handle - // (which is what produces event 9) and its configuration, exactly once. - [[maybe_unused]] const auto detached = Detach(runtime->gate, runtime->listeners, listener_id); -} - -CULTMESH_API int32_t cultmesh_quic_connection_open( - void* handle, const char* host, uint16_t port, uint64_t* out_connection_id) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - return OpenConnection(runtime, host, port, out_connection_id, nullptr); -} - -CULTMESH_API int32_t cultmesh_quic_connection_certificate_complete( - void* handle, uint64_t connection_id, int32_t accept) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - const auto connection = FindConnection(runtime, connection_id); - if (connection == nullptr) return -1; - const auto status = runtime->api->ConnectionCertificateValidationComplete( - connection->handle, - accept != 0 ? static_cast(1) : static_cast(0), - accept != 0 ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC certificate validation completion failed (status=" + - Hex(static_cast(status)) + ")."); - } - return 0; -} - -CULTMESH_API void cultmesh_quic_connection_shutdown(void* handle, uint64_t connection_id, uint64_t code) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return; - CallScope scope(runtime); - if (!scope.entered()) return; - const auto connection = FindConnection(runtime, connection_id); - if (connection == nullptr || connection->closing.exchange(true)) return; - // Kept for the shutdown-complete callback: this is the code the initiator's - // own event 4 carries, matching the one the peer sees. - connection->shutdown_code.store(code); - if (connection->handle != nullptr) - runtime->api->ConnectionShutdown(connection->handle, QUIC_CONNECTION_SHUTDOWN_FLAG_NONE, code); -} - -CULTMESH_API int32_t cultmesh_quic_stream_open( - void* handle, uint64_t connection_id, uint8_t kind, uint64_t* out_stream_id) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || out_stream_id == nullptr) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - *out_stream_id = 0; - if (kind != kReliableStream && kind != kLatestOnlyStream) return -2; - const auto connection = FindConnection(runtime, connection_id); - if (connection == nullptr) return -1; - - auto stream = std::make_shared(); - stream->runtime = runtime; - stream->owner = connection; - stream->kind = kind; - stream->has_kind = true; - stream->id = NextId(runtime); - Stream* raw = stream.get(); - - auto status = runtime->api->StreamOpen( - connection->handle, QUIC_STREAM_OPEN_FLAG_UNIDIRECTIONAL, - StreamCallback, raw, &raw->handle); - if (QUIC_FAILED(status)) { - return Refuse(runtime, status, "CultMesh QUIC stream open failed (status=" + - Hex(static_cast(status)) + ")."); - } - // In the map before it starts. `StreamStart` can reach shutdown-complete on - // an MsQuic worker before it returns here, and that callback has to find the - // stream to report it and to drop the map's reference. - { - std::lock_guard lock(runtime->gate); - runtime->streams.emplace(raw->id, stream); - } - status = runtime->api->StreamStart(raw->handle, QUIC_STREAM_START_FLAG_IMMEDIATE); - if (QUIC_FAILED(status)) { - DestroyStream(runtime, raw->id); - return Refuse(runtime, status, "CultMesh QUIC stream start failed (status=" + - Hex(static_cast(status)) + ")."); - } - - // The kind byte is the first thing on the stream, which is what the peer's - // reassembly reads before any length prefix. - auto* request = new SendRequest(); - request->bytes.assign(1, kind); - request->buffer.Buffer = request->bytes.data(); - request->buffer.Length = 1; - status = runtime->api->StreamSend(raw->handle, &request->buffer, 1, QUIC_SEND_FLAG_NONE, request); - if (QUIC_FAILED(status)) { - delete request; - return Refuse(runtime, status, "CultMesh QUIC stream kind could not be sent (status=" + - Hex(static_cast(status)) + ")."); - } - - *out_stream_id = raw->id; - return 0; -} - -CULTMESH_API int32_t cultmesh_quic_stream_send_frame( - void* handle, uint64_t stream_id, const uint8_t* encoded_frame, int32_t length, int32_t fin) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || encoded_frame == nullptr || length <= 0) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - if (static_cast(length) > kMaximumEncodedFrameBytes) return -2; - // The reference is held for the whole `StreamSend` below, so the worker - // cannot free the stream or close its handle underneath this call. - const auto stream = FindStream(runtime, stream_id); - if (stream == nullptr || stream->handle == nullptr) return -1; - - // The bytes are copied because MsQuic reads them after this returns; the - // 4-byte LE length prefix is the QUIC stream framing, and it is added here - // so that framing lives in one place for both directions. - auto* request = new SendRequest(); - request->is_frame = true; - request->bytes.resize(sizeof(uint32_t) + static_cast(length)); - WriteUInt32LittleEndian(request->bytes.data(), static_cast(length)); - std::memcpy(request->bytes.data() + sizeof(uint32_t), encoded_frame, static_cast(length)); - request->buffer.Buffer = request->bytes.data(); - request->buffer.Length = static_cast(request->bytes.size()); - - const auto flags = (fin != 0) ? QUIC_SEND_FLAG_FIN : QUIC_SEND_FLAG_NONE; - const auto status = runtime->api->StreamSend(stream->handle, &request->buffer, 1, flags, request); - if (QUIC_FAILED(status)) { - delete request; - return Refuse(runtime, status, "CultMesh QUIC frame send failed (status=" + - Hex(static_cast(status)) + ")."); - } - return 0; -} - -CULTMESH_API void cultmesh_quic_stream_shutdown(void* handle, uint64_t stream_id, uint64_t code) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return; - CallScope scope(runtime); - if (!scope.entered()) return; - const auto stream = FindStream(runtime, stream_id); - if (stream == nullptr || stream->handle == nullptr) return; - runtime->api->StreamShutdown(stream->handle, QUIC_STREAM_SHUTDOWN_FLAG_ABORT, code); -} - -// The one blocking crossing; see section 6 of the header for its returns. The -// call scope is what `cultmesh_quic_runtime_close` waits on: a poller blocked -// here is woken by `closing` and counted out before anything is freed. -CULTMESH_API int32_t cultmesh_quic_next_event( - void* handle, int32_t timeout_ms, cultmesh_quic_event* out_event, - uint8_t* payload, int32_t payload_capacity, int32_t* out_required) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || out_event == nullptr || out_required == nullptr || payload_capacity < 0) - return -1; - // A null buffer with a non-zero capacity is a lie about the buffer. Taking - // it meant the event was consumed and its payload silently dropped. - if (payload == nullptr && payload_capacity > 0) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - *out_required = 0; - - std::unique_lock lock(runtime->gate); - if (runtime->events.empty() && timeout_ms > 0) { - // The host's timeout bounds the wait; an event or the close ends it - // sooner, and `woken` is which of the two happened. RECORD_WAIT wraps - // this exact argument; see the development seam note near its macros. - const bool woken = runtime->signal.wait_for( - lock, CULTMESH_QUIC_DEBUG_RECORD_WAIT(std::chrono::milliseconds(timeout_ms)), - [runtime] { return !runtime->events.empty() || runtime->closing; }); - // Development builds only; a no-op unless a scenario has armed the hold. - // A held call returns the same 0 an empty queue would have, and makes no - // further call of its own. Only a woken call is held: one whose own - // timeout expired is on its way out, and parking it would let a scenario - // report a call the bridge kept when what kept it was the fixture. - if (woken && CULTMESH_QUIC_DEBUG_HELD(lock)) return 0; - } - if (runtime->events.empty()) return 0; - - const QueuedEvent& queued = runtime->events.front(); - const auto required = static_cast(queued.payload.size()); - if (required > payload_capacity) { - *out_required = required; - return 2; - } - *out_event = queued.header; - if (required > 0 && payload != nullptr) - std::memcpy(payload, queued.payload.data(), static_cast(required)); - *out_required = required; - runtime->events.pop_front(); - return 1; -} - -CULTMESH_API int32_t cultmesh_quic_last_error(void* handle, char* destination, int32_t capacity) { - auto* runtime = static_cast(handle); - if (runtime == nullptr || destination == nullptr || capacity <= 0) return -1; - CallScope scope(runtime); - if (!scope.entered()) return -1; - std::lock_guard lock(runtime->gate); - const size_t count = (std::min)(runtime->error.size(), static_cast(capacity - 1)); - std::memcpy(destination, runtime->error.data(), count); - destination[count] = '\0'; - return static_cast(count); -} - -CULTMESH_API int32_t cultmesh_quic_last_status(void* handle) { - auto* runtime = static_cast(handle); - if (runtime == nullptr) return 0; - CallScope scope(runtime); - if (!scope.entered()) return 0; - std::lock_guard lock(runtime->gate); - return runtime->last_status; -} - -// --------------------------------------------------------------------------- -// C ABI v1: the Unity shim. Windows only, by the same construction as its only -// caller (`CultMeshNativeQuicRealtimeTransport.CanConnect` is Windows-gated). -// -// These five exports are the contract the shipped Unity package imports by -// name. They are preserved byte for byte in signature and behaviour, and they -// decide nothing the v2 path does not: a v1 client is a private runtime with one -// connection. The single exception is the pin, which v1 compares here in C -// because that is what the managed connector was built against. -// --------------------------------------------------------------------------- - -#if defined(_WIN32) - -#include -#include -#include - -namespace { - enum class ClientState : int32_t { Connecting = 0, Connected = 1, @@ -1287,33 +38,50 @@ enum class ClientState : int32_t { Closed = 3, }; -struct V1Client { - Runtime* runtime = nullptr; - uint64_t connection_id = 0; - ClientState state = ClientState::Connecting; - std::deque> frames; - std::string error; - bool closing = false; - // The managed connector polls `state` and `poll` from its own loop and may - // read `error` from the caller's thread, so the drain and everything it - // mutates sit behind one lock. Held only around `Pump` and the fields; it is - // always taken before the runtime's, never after. - std::mutex gate; +struct Client; + +struct StreamContext { + explicit StreamContext(Client* client) : owner(client) {} + Client* owner; + std::vector buffered; + uint8_t kind = 0; + bool has_kind = false; }; -std::string LoadedMsQuicPath() { - const auto module = GetModuleHandleW(L"msquic.dll"); - if (module == nullptr) return "unresolved"; - std::array path{}; - const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); - if (length == 0 || length >= path.size()) return "unresolved"; - const auto required = WideCharToMultiByte( - CP_UTF8, 0, path.data(), static_cast(length), nullptr, 0, nullptr, nullptr); - if (required <= 0) return "unresolved"; - std::string utf8(static_cast(required), '\0'); - WideCharToMultiByte( - CP_UTF8, 0, path.data(), static_cast(length), utf8.data(), required, nullptr, nullptr); - return utf8; +struct Client { + const QUIC_API_TABLE* api = nullptr; + HQUIC registration = nullptr; + HQUIC configuration = nullptr; + HQUIC connection = nullptr; + std::array certificate_pin{}; + std::atomic state{ClientState::Connecting}; + std::atomic closing{false}; + std::mutex gate; + std::deque> received; + std::string error; + std::string trace; +}; + +void AppendTrace(Client* client, const std::string& event) { + if (client == nullptr || client->state.load() == ClientState::Connected) return; + std::lock_guard lock(client->gate); + if (!client->trace.empty()) client->trace += ","; + client->trace += event; +} + +std::string TraceSnapshot(Client* client) { + if (client == nullptr) return "none"; + std::lock_guard lock(client->gate); + return client->trace.empty() ? "none" : client->trace; +} + +void SetFailure(Client* client, const std::string& message) { + if (client == nullptr || client->closing.load()) return; + { + std::lock_guard lock(client->gate); + if (client->error.empty()) client->error = message; + } + client->state.store(ClientState::Failed); } bool ParseHexNibble(char value, uint8_t& nibble) { @@ -1336,90 +104,287 @@ bool ParsePin(const char* value, std::array& pin) { return true; } -// Drains the runtime's event queue into the v1 client's own shape: frames for -// `poll`, a state for `state`, an error string for `error`. This is where the -// v1 contract is reconstructed from v2 events, and the only place it is. -void Pump(V1Client* client) { - cultmesh_quic_event header{}; - std::vector payload; - for (;;) { - int32_t required = 0; - auto result = cultmesh_quic_next_event( - client->runtime, 0, &header, - payload.empty() ? nullptr : payload.data(), - static_cast(payload.size()), &required); - if (result == 2) { - payload.resize(static_cast(required)); - continue; - } - if (result != 1) break; - - switch (header.type) { - case kEventConnectionConnected: - if (client->state == ClientState::Connecting) client->state = ClientState::Connected; - break; - case kEventStreamFrame: - client->frames.emplace_back(payload.begin(), payload.begin() + header.payload_length); - break; - case kEventConnectionShutdown: - if (client->closing) { - client->state = ClientState::Closed; - } else if (client->state != ClientState::Failed) { - client->state = ClientState::Failed; - if (client->error.empty()) { - char runtime_error[1024]{}; - cultmesh_quic_last_error(client->runtime, runtime_error, sizeof(runtime_error)); - // The pin refusal is the runtime's, and the managed - // connector matches on its wording; anything else is the - // transport's own reason plus where msquic came from. - client->error = runtime_error[0] != '\0' - ? std::string(runtime_error) - : std::string(reinterpret_cast(payload.data()), - static_cast(header.payload_length)); - client->error += " (msquic=" + LoadedMsQuicPath() + ")"; - } - } - break; - default: - break; - } - } +uint32_t ReadUInt32LittleEndian(const uint8_t* value) { + return static_cast(value[0]) | + (static_cast(value[1]) << 8) | + (static_cast(value[2]) << 16) | + (static_cast(value[3]) << 24); } -bool CertificateMatchesPin(Connection* connection, QUIC_CERTIFICATE* certificate) { +void FailStream(StreamContext* stream, const char* message) { + SetFailure(stream->owner, message); + if (stream->owner != nullptr && stream->owner->api != nullptr && stream->owner->connection != nullptr) + stream->owner->api->ConnectionShutdown( + stream->owner->connection, + QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, + kStreamAbortCode); +} + +bool ConsumeFrames(StreamContext* stream) { + auto& bytes = stream->buffered; + if (!stream->has_kind) { + if (bytes.empty()) return true; + stream->kind = bytes.front(); + bytes.erase(bytes.begin()); + stream->has_kind = true; + if (stream->kind != kReliableStream && stream->kind != kLatestOnlyStream) { + FailStream(stream, "CultMesh QUIC stream kind is invalid."); + return false; + } + } + + while (bytes.size() >= sizeof(uint32_t)) { + const uint32_t length = ReadUInt32LittleEndian(bytes.data()); + if (length == 0 || length > kMaximumEncodedFrameBytes) { + FailStream(stream, "CultMesh QUIC frame length is invalid."); + return false; + } + const size_t framed_length = sizeof(uint32_t) + static_cast(length); + if (bytes.size() < framed_length) return true; + std::vector frame(length); + std::memcpy(frame.data(), bytes.data() + sizeof(uint32_t), length); + { + std::lock_guard lock(stream->owner->gate); + stream->owner->received.emplace_back(std::move(frame)); + } + bytes.erase(bytes.begin(), bytes.begin() + static_cast(framed_length)); + if (stream->kind == kLatestOnlyStream && !bytes.empty()) { + FailStream(stream, "CultMesh latest-only QUIC stream carried multiple frames."); + return false; + } + } + return true; +} + +QUIC_STATUS QUIC_API StreamCallback(HQUIC stream_handle, void* context, QUIC_STREAM_EVENT* event) { + auto* stream = static_cast(context); + switch (event->Type) { + case QUIC_STREAM_EVENT_RECEIVE: + for (uint32_t index = 0; index < event->RECEIVE.BufferCount; ++index) { + const auto& buffer = event->RECEIVE.Buffers[index]; + stream->buffered.insert(stream->buffered.end(), buffer.Buffer, buffer.Buffer + buffer.Length); + } + ConsumeFrames(stream); + break; + case QUIC_STREAM_EVENT_PEER_SEND_SHUTDOWN: + if (!stream->buffered.empty()) + FailStream(stream, "CultMesh QUIC stream ended with a truncated frame."); + break; + case QUIC_STREAM_EVENT_SHUTDOWN_COMPLETE: + if (!event->SHUTDOWN_COMPLETE.AppCloseInProgress) + stream->owner->api->StreamClose(stream_handle); + delete stream; + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; +} + +bool CertificateMatchesPin(Client* client, QUIC_CERTIFICATE* certificate) { if (certificate == nullptr) return false; const auto* encoded = reinterpret_cast(certificate); if (encoded->Buffer == nullptr || encoded->Length == 0) return false; BYTE digest[32]{}; DWORD digest_length = sizeof(digest); if (!CryptHashCertificate2( - BCRYPT_SHA256_ALGORITHM, 0, nullptr, - encoded->Buffer, encoded->Length, digest, &digest_length) || - digest_length != sizeof(digest)) + BCRYPT_SHA256_ALGORITHM, + 0, + nullptr, + encoded->Buffer, + encoded->Length, + digest, + &digest_length) || digest_length != sizeof(digest)) return false; - return std::memcmp(digest, connection->pin.data(), sizeof(digest)) == 0; + return std::memcmp(digest, client->certificate_pin.data(), sizeof(digest)) == 0; +} + +std::string Hex(uint64_t value) { + std::ostringstream text; + text << "0x" << std::hex << std::uppercase << value; + return text.str(); +} + +std::string LoadedMsQuicPath() { + const auto module = GetModuleHandleW(L"msquic.dll"); + if (module == nullptr) return "unresolved"; + std::array path{}; + const auto length = GetModuleFileNameW(module, path.data(), static_cast(path.size())); + if (length == 0 || length >= path.size()) return "unresolved"; + const auto required = WideCharToMultiByte( + CP_UTF8, 0, path.data(), static_cast(length), nullptr, 0, nullptr, nullptr); + if (required <= 0) return "unresolved"; + std::string utf8(static_cast(required), '\0'); + WideCharToMultiByte( + CP_UTF8, 0, path.data(), static_cast(length), utf8.data(), required, nullptr, nullptr); + return utf8; +} + +QUIC_STATUS QUIC_API ConnectionCallback(HQUIC connection, void* context, QUIC_CONNECTION_EVENT* event) { + auto* client = static_cast(context); + AppendTrace(client, "event=" + std::to_string(static_cast(event->Type))); + switch (event->Type) { + case QUIC_CONNECTION_EVENT_CONNECTED: + client->state.store(ClientState::Connected); + break; + case QUIC_CONNECTION_EVENT_PEER_STREAM_STARTED: { + auto* stream = new (std::nothrow) StreamContext(client); + if (stream == nullptr) return QUIC_STATUS_OUT_OF_MEMORY; + client->api->SetCallbackHandler( + event->PEER_STREAM_STARTED.Stream, + reinterpret_cast(StreamCallback), + stream); + break; + } + case QUIC_CONNECTION_EVENT_PEER_CERTIFICATE_RECEIVED: + { + AppendTrace( + client, + "certificate-status=" + + Hex(static_cast(event->PEER_CERTIFICATE_RECEIVED.DeferredStatus))); + const auto matches = CertificateMatchesPin( + client, + event->PEER_CERTIFICATE_RECEIVED.Certificate); + if (!matches) { + SetFailure(client, "CultMesh QUIC provider certificate does not match the advertised SHA-256 pin."); + } + const auto completion = client->api->ConnectionCertificateValidationComplete( + connection, + matches ? TRUE : FALSE, + matches ? QUIC_TLS_ALERT_CODE_SUCCESS : QUIC_TLS_ALERT_CODE_BAD_CERTIFICATE); + if (QUIC_FAILED(completion)) { + SetFailure( + client, + "CultMesh QUIC certificate validation completion failed (status=" + + Hex(static_cast(completion)) + ")."); + return completion; + } + return QUIC_STATUS_PENDING; + } + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_TRANSPORT: + if (!client->closing.load()) { + SetFailure( + client, + "CultMesh QUIC connection was shut down by the transport " + "(status=" + Hex(static_cast(event->SHUTDOWN_INITIATED_BY_TRANSPORT.Status)) + + ", error=" + Hex(event->SHUTDOWN_INITIATED_BY_TRANSPORT.ErrorCode) + + ", msquic=" + LoadedMsQuicPath() + + ", events=" + TraceSnapshot(client) + ")."); + } + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_INITIATED_BY_PEER: + if (!client->closing.load()) + SetFailure(client, "CultMesh QUIC connection was shut down by the provider."); + break; + case QUIC_CONNECTION_EVENT_SHUTDOWN_COMPLETE: + if (client->closing.load()) client->state.store(ClientState::Closed); + break; + default: + break; + } + return QUIC_STATUS_SUCCESS; +} + +void CloseHandles(Client* client) { + if (client->connection != nullptr) { + client->api->ConnectionClose(client->connection); + client->connection = nullptr; + } + if (client->configuration != nullptr) { + client->api->ConfigurationClose(client->configuration); + client->configuration = nullptr; + } + if (client->registration != nullptr) { + client->api->RegistrationClose(client->registration); + client->registration = nullptr; + } + if (client->api != nullptr) { + MsQuicClose(client->api); + client->api = nullptr; + } } } // namespace CULTMESH_API int32_t cultmesh_quic_open( - const char* host, uint16_t port, const char* certificate_sha256, V1Client** result) { + const char* host, + uint16_t port, + const char* certificate_sha256, + Client** result) { if (host == nullptr || *host == '\0' || port == 0 || result == nullptr) return -1; *result = nullptr; + auto client = std::make_unique(); + if (!ParsePin(certificate_sha256, client->certificate_pin)) return -2; - std::array pin{}; - if (!ParsePin(certificate_sha256, pin)) return -2; + QUIC_STATUS status = MsQuicOpenVersion( + kApiVersion, + reinterpret_cast(&client->api)); + if (QUIC_FAILED(status)) return status; - auto client = std::make_unique(); - void* runtime_handle = nullptr; - const auto opened = cultmesh_quic_runtime_open("GameCult.Mesh.Quic.Native", &runtime_handle); - if (opened != 0) return opened; - client->runtime = static_cast(runtime_handle); + const QUIC_REGISTRATION_CONFIG registration_config = { + "GameCult.Mesh.Quic.Native", + QUIC_EXECUTION_PROFILE_TYPE_REAL_TIME + }; + status = client->api->RegistrationOpen(®istration_config, &client->registration); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } - // The pin goes in before the connection starts; see OpenConnection. - const auto status = OpenConnection(client->runtime, host, port, &client->connection_id, &pin); - if (status != 0) { - cultmesh_quic_runtime_close(client->runtime); + QUIC_SETTINGS settings{}; + settings.IdleTimeoutMs = 30000; + settings.IsSet.IdleTimeoutMs = TRUE; + settings.KeepAliveIntervalMs = 5000; + settings.IsSet.KeepAliveIntervalMs = TRUE; + settings.PeerUnidiStreamCount = 1024; + settings.IsSet.PeerUnidiStreamCount = TRUE; + const QUIC_BUFFER alpn = { + static_cast(sizeof(kAlpn) - 1), + reinterpret_cast(const_cast(kAlpn)) + }; + status = client->api->ConfigurationOpen( + client->registration, + &alpn, + 1, + &settings, + sizeof(settings), + nullptr, + &client->configuration); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + QUIC_CREDENTIAL_CONFIG credentials{}; + credentials.Type = QUIC_CREDENTIAL_TYPE_NONE; + credentials.Flags = static_cast( + QUIC_CREDENTIAL_FLAG_CLIENT | + QUIC_CREDENTIAL_FLAG_INDICATE_CERTIFICATE_RECEIVED | + QUIC_CREDENTIAL_FLAG_DEFER_CERTIFICATE_VALIDATION | + QUIC_CREDENTIAL_FLAG_USE_PORTABLE_CERTIFICATES); + status = client->api->ConfigurationLoadCredential(client->configuration, &credentials); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + + status = client->api->ConnectionOpen( + client->registration, + ConnectionCallback, + client.get(), + &client->connection); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); + return status; + } + status = client->api->ConnectionStart( + client->connection, + client->configuration, + QUIC_ADDRESS_FAMILY_UNSPEC, + host, + port); + if (QUIC_FAILED(status)) { + CloseHandles(client.get()); return status; } @@ -1427,87 +392,49 @@ CULTMESH_API int32_t cultmesh_quic_open( return 0; } -CULTMESH_API int32_t cultmesh_quic_state(V1Client* client) { +CULTMESH_API int32_t cultmesh_quic_state(Client* client) { if (client == nullptr) return static_cast(ClientState::Failed); - std::lock_guard lock(client->gate); - Pump(client); - return static_cast(client->state); + return static_cast(client->state.load()); } CULTMESH_API int32_t cultmesh_quic_poll( - V1Client* client, uint8_t* destination, int32_t destination_length, int32_t* required_length) { + Client* client, + uint8_t* destination, + int32_t destination_length, + int32_t* required_length) { if (client == nullptr || required_length == nullptr || destination_length < 0) return -1; std::lock_guard lock(client->gate); - Pump(client); - if (client->frames.empty()) { + if (client->received.empty()) { *required_length = 0; - return client->state == ClientState::Failed ? -2 : 0; + return client->state.load() == ClientState::Failed ? -2 : 0; } - const auto& frame = client->frames.front(); + const auto& frame = client->received.front(); if (frame.size() > static_cast(INT32_MAX)) return -3; *required_length = static_cast(frame.size()); if (destination == nullptr || destination_length < *required_length) return 2; std::memcpy(destination, frame.data(), frame.size()); - client->frames.pop_front(); + client->received.pop_front(); return 1; } -CULTMESH_API int32_t cultmesh_quic_error(V1Client* client, char* destination, int32_t destination_length) { +CULTMESH_API int32_t cultmesh_quic_error( + Client* client, + char* destination, + int32_t destination_length) { if (client == nullptr || destination == nullptr || destination_length <= 0) return -1; std::lock_guard lock(client->gate); - Pump(client); const size_t count = (std::min)(client->error.size(), static_cast(destination_length - 1)); std::memcpy(destination, client->error.data(), count); destination[count] = '\0'; return static_cast(count); } -CULTMESH_API void cultmesh_quic_close(V1Client* client) { +CULTMESH_API void cultmesh_quic_close(Client* client) { if (client == nullptr) return; - { - std::lock_guard lock(client->gate); - client->closing = true; - } - // `runtime_close` already shuts the connection down silently with the same - // CULT close code and then closes the handle, which is what v1 did before - // it delegated. Shutting it down here as well would only race that. - cultmesh_quic_runtime_close(client->runtime); - client->state = ClientState::Closed; + client->closing.store(true); + if (client->connection != nullptr && client->api != nullptr) + client->api->ConnectionShutdown(client->connection, QUIC_CONNECTION_SHUTDOWN_FLAG_SILENT, kConnectionCloseCode); + CloseHandles(client); + client->state.store(ClientState::Closed); delete client; } - -#endif // _WIN32 - -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) -// The development seam's three exports. They exist only in a build configured -// with CULTMESH_QUIC_DEBUG_ASSERTS; a shipped library exports neither these nor -// the counters behind them. See section 7 of the header. -CULTMESH_API void cultmesh_quic_debug_hold_calls(int32_t hold) { - if (hold != 0) { - debug_peak_calls.store(0); - debug_calls_at_close.store(-1); - } - { - std::lock_guard lock(debug_hold_gate); - debug_hold_armed = hold != 0; - } - debug_hold_signal.notify_all(); -} - -CULTMESH_API int32_t cultmesh_quic_debug_peak_calls(void) { - return debug_peak_calls.load(); -} - -CULTMESH_API int32_t cultmesh_quic_debug_calls_at_close(void) { - return debug_calls_at_close.load(); -} - -CULTMESH_API int32_t cultmesh_quic_debug_last_wait_ms(void) { - return debug_wait_recorded.load() ? debug_last_wait_ms.load() : -1; -} - -CULTMESH_API void cultmesh_quic_debug_reset_last_wait_ms(void) { - debug_wait_recorded.store(false); - debug_last_wait_ms.store(-1); -} -#endif diff --git a/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h b/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h deleted file mode 100644 index 758ecb8a..00000000 --- a/native/GameCult.Mesh.Quic.Native/include/cultmesh_quic_native.h +++ /dev/null @@ -1,371 +0,0 @@ -/* GameCult.Mesh.Quic.Native — C ABI v2, the runtime-neutral CultMesh QUIC bridge. - * - * This header is the contract. It is the single place the event layout, the - * exports, the id and return-code conventions, the object lifetime rules and the - * threading rules are stated; `cultmesh_quic_native.cpp` includes it, the README - * points at it, and a host that cannot include it (koffi, P/Invoke) declares the - * same thing in its own FFI and is checked against this file by a test. - * - * It is C. It includes nothing of MsQuic, nothing of Node, nothing of Unity, and - * nothing platform-specific: a host binds these twelve queue exports plus - * `cultmesh_quic_last_status` and needs no other build input. - * - * The v1 Unity exports (`cultmesh_quic_open`, `_state`, `_poll`, `_error`, - * `_close`) are deliberately not here. They are a Windows-only shim for a - * shipped managed connector, not a contract offered to new hosts. - */ - -#ifndef CULTMESH_QUIC_NATIVE_H -#define CULTMESH_QUIC_NATIVE_H - -#include -#include - -#if defined(__cplusplus) -#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) static_assert(condition, message) -#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L -#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) _Static_assert(condition, message) -#else -#define CULTMESH_QUIC_STATIC_ASSERT(condition, message) -#endif - -/* `CULTMESH_QUIC_BUILD` is defined by the bridge's own translation unit and by - * nothing else. A consumer gets plain declarations and links by name, which is - * what every host here does: all three load the library at runtime. */ -#if defined(CULTMESH_QUIC_BUILD) -# if defined(_WIN32) -# define CULTMESH_QUIC_API __declspec(dllexport) -# else -# define CULTMESH_QUIC_API __attribute__((visibility("default"))) -# endif -#else -# define CULTMESH_QUIC_API -#endif - -#if defined(__cplusplus) -extern "C" { -#endif - -/* --------------------------------------------------------------------------- - * 1. The event - * - * Exactly 64 bytes, no implicit padding, field order below is the ABI. A host - * that reorders a field reads a different struct; the offsets are asserted here - * so a compiler that pads differently fails the build rather than the wire. - * --------------------------------------------------------------------------- */ - -struct cultmesh_quic_event { - uint32_t type; /* offset 0 — one of the cultmesh_quic_event_type values */ - uint32_t stream_kind; /* offset 4 — 1 reliable, 2 latest-only; 0 when not a stream event */ - uint64_t listener_id; /* offset 8 — 0 for an outbound connection */ - uint64_t connection_id; /* offset 16 */ - uint64_t stream_id; /* offset 24 */ - uint64_t code; /* offset 32 — meaning depends on `type`; see below */ - int32_t status; /* offset 40 — the raw platform QUIC status, diagnostics only */ - int32_t payload_length; /* offset 44 — bytes written into the caller's payload buffer */ - uint8_t reserved[16]; /* offset 48 — zero today; do not read meaning into it */ -}; - -CULTMESH_QUIC_STATIC_ASSERT(sizeof(struct cultmesh_quic_event) == 64, - "the event struct is a 64-byte ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, type) == 0, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, stream_kind) == 4, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, listener_id) == 8, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, connection_id) == 16, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, stream_id) == 24, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, code) == 32, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, status) == 40, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, payload_length) == 44, - "event field offsets are an ABI contract"); -CULTMESH_QUIC_STATIC_ASSERT(offsetof(struct cultmesh_quic_event, reserved) == 48, - "event field offsets are an ABI contract"); - -enum cultmesh_quic_event_type { - /* A listener accepted an inbound connection. `listener_id`, `connection_id`. */ - CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION = 1, - /* The handshake completed. `connection_id`. */ - CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED = 2, - /* Payload: the peer's DER certificate. The handshake is blocked until the - * host answers with `cultmesh_quic_connection_certificate_complete`. */ - CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED = 3, - /* The connection is gone, whoever ended it, and its id no longer resolves. - * Payload: a UTF-8 reason. `code` is the QUIC application error code — the - * peer's when the peer ended it, and the code the host passed to - * `cultmesh_quic_connection_shutdown` when the host did. Emitted exactly - * once per connection id, including for the initiator of a local shutdown: - * every connection id's end is observable from the host that owns it. */ - CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN = 4, - /* An inbound stream's first byte named its kind. `stream_id`, `stream_kind`. */ - CULTMESH_QUIC_EVENT_STREAM_STARTED = 5, - /* Payload: one whole encoded frame, the 4-byte length prefix stripped. */ - CULTMESH_QUIC_EVENT_STREAM_FRAME = 6, - /* One per `cultmesh_quic_stream_send_frame` call that returned 0, and never - * for the bridge's own kind byte. `code` is a cultmesh_quic_send_result. */ - CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE = 7, - /* The stream is gone. `stream_id`, `stream_kind`. */ - CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN = 8, - /* The listener stopped. `listener_id`. */ - CULTMESH_QUIC_EVENT_LISTENER_STOPPED = 9 -}; - -/* The `code` of event 7. A canceled send is a send MsQuic released without - * putting on the wire — the stream was aborted or the connection ended under it. - * It is reported, not silently dropped. */ -enum cultmesh_quic_send_result { - CULTMESH_QUIC_SEND_COMPLETED = 0, - CULTMESH_QUIC_SEND_CANCELED = 1 -}; - -/* The first byte of every CultMesh QUIC stream. */ -enum cultmesh_quic_stream_kind { - CULTMESH_QUIC_STREAM_RELIABLE = 1, - CULTMESH_QUIC_STREAM_LATEST_ONLY = 2 -}; - -/* --------------------------------------------------------------------------- - * 2. Ids - * - * Listeners, connections and streams are named by `uint64_t` ids drawn from one - * per-runtime counter that starts at 1 and never repeats. 0 is never a valid id - * and is what an out parameter holds when a call fails. An id is the only thing - * a host ever holds: there is no host-visible pointer to a connection or stream, - * so a host cannot outlive an object it is naming. An id whose object is gone is - * a bad call (-1), not undefined behaviour. - * --------------------------------------------------------------------------- */ - -/* --------------------------------------------------------------------------- - * 3. Return codes — negative on a bad call, on every platform - * - * Every int32_t-returning export returns: - * - * 0 the call succeeded - * 1, 2 `cultmesh_quic_next_event` only; see its comment - * -1 a bad call: a null handle, a null out parameter, - * or an id whose object is gone - * -2 an argument the bridge rejects on its own terms: - * an unknown stream kind, an oversized frame, a - * listener host that is not an IP literal - * <= -1000 MsQuic refused. The bridge code is - * -(1000 + (raw_status & 0xffff)); the raw - * platform status is available from - * `cultmesh_quic_last_status` and a sentence about - * it from `cultmesh_quic_last_error`. - * - * This mapping exists because `QUIC_STATUS` itself is not portable: it is a - * negative HRESULT on Windows and a positive errno on POSIX, so a host that - * checked for a negative value saw every Linux failure as success. - * - * What the mapping makes portable is the sign, and only the sign. A failure is - * negative everywhere, and that is what a host may branch on. The magnitude of a - * <= -1000 code is diagnostic and platform-specific: the same MsQuic failure - * carries different numbers on different platforms (a TLS error is -12032 on - * win32-x64 and -1126 on linux-x64; an ALPN already in use is -1009 against - * -1091), and the 16-bit mask can give two distinct statuses the same code - * (`QUIC_STATUS_USER_CANCELED` and `QUIC_STATUS_FILE_NOT_FOUND` are both -1002 - * on Windows). Switching on a particular code is therefore not portable and not - * a reliable identification of the failure. Report it, log it, and read - * `cultmesh_quic_last_status` and `cultmesh_quic_last_error` beside it. - * --------------------------------------------------------------------------- */ - -#define CULTMESH_QUIC_RESULT_BAD_CALL (-1) -#define CULTMESH_QUIC_RESULT_BAD_ARGUMENT (-2) -#define CULTMESH_QUIC_RESULT_MSQUIC_BASE (-1000) - -/* --------------------------------------------------------------------------- - * 4. Object lifetime — resolved from an id, kept alive across the call - * - * The bridge's objects are shared-owned. The runtime's maps hold one reference - * each; an export that names an id takes its own reference under the lock and - * holds it for the whole MsQuic call, so an object cannot be freed under a host - * thread that is using it. MsQuic's worker, on shutdown-complete, removes the - * map entry and drops the map's reference only — it never frees an object a host - * call is inside. - * - * The MsQuic handle follows the object: it is closed exactly once, by whichever - * holder drops the last reference (or by `cultmesh_quic_runtime_close`), under a - * one-shot exchange guard. A handle is therefore valid for as long as any holder - * has a reference to its object, which is the property the host thread needs. A - * stream holds a reference to its connection, so stream handles are always - * closed before the connection handle they belong to. - * - * `cultmesh_quic_runtime_close` quiesces before it tears anything down. It marks - * the runtime closing, wakes every blocked `cultmesh_quic_next_event`, and waits - * for every in-flight export to return before it closes a handle or frees a - * byte. No host call may begin after `cultmesh_quic_runtime_close` starts. That - * ordering is the host's, and it is a prohibition rather than a race the bridge - * arbitrates: once the close returns, the handle names freed memory, and a call - * made with it has nothing left to refuse it. A call that races the start of the - * close, while it is still running, is refused with -1 and touches nothing; that - * narrows one mis-ordering and does not make the prohibition optional. - * --------------------------------------------------------------------------- */ - -/* --------------------------------------------------------------------------- - * 5. Threading - * - * There are no host callbacks. Every crossing is the host calling in. - * - * - MsQuic's worker threads run inside this library only. They push events onto - * the runtime's queue and wake the one blocking wait. No host code is ever - * called from a thread the host does not own. - * - `cultmesh_quic_next_event` is called from host threads, one or many. It is - * the only blocking export. Concurrent callers each get a distinct event. - * - Every other export may be called from any host thread at any time, except - * during or after `cultmesh_quic_runtime_close`. - * - `cultmesh_quic_runtime_close` is called once, from one thread, and - * quiesces as described above. - * --------------------------------------------------------------------------- */ - -/* --------------------------------------------------------------------------- - * 6. The exports - * --------------------------------------------------------------------------- */ - -/* Opens a runtime: one MsQuic registration, one event queue. `app_name` may be - * null. `*out_runtime` is the opaque handle every other export takes. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_runtime_open( - const char* app_name, void** out_runtime); - -/* Quiesces, tears down and frees the runtime. Safe to call with null. After it - * returns, the handle is dead; see section 4 for the ordering rule. */ -CULTMESH_QUIC_API void cultmesh_quic_runtime_close(void* runtime); - -/* Opens a provider listener on `host`:`port` with a PKCS12 credential. `host` - * may be null or empty for any address, otherwise it must be an IP literal. - * `port` 0 binds an ephemeral port, which `*out_bound_port` reports. A failure - * leaves no listener behind and no id allocated to the host. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_listener_open( - void* runtime, const char* host, uint16_t port, - const uint8_t* pkcs12, int32_t pkcs12_length, const char* password, - uint64_t* out_listener_id, uint16_t* out_bound_port); - -/* Stops and closes a listener. Its accepted connections are not affected. - * Emits event 9. Unknown ids are ignored. */ -CULTMESH_QUIC_API void cultmesh_quic_listener_close( - void* runtime, uint64_t listener_id); - -/* Starts an outbound connection. Certificate validation is deferred: the host - * gets event 3 and must answer. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_connection_open( - void* runtime, const char* host, uint16_t port, uint64_t* out_connection_id); - -/* Answers event 3. `accept` non-zero continues the handshake; zero fails it with - * a bad-certificate alert. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_connection_certificate_complete( - void* runtime, uint64_t connection_id, int32_t accept); - -/* Ends a connection with a QUIC application error code. The initiator gets its - * own event 4 carrying `code`, as the peer does. Unknown ids are ignored. */ -CULTMESH_QUIC_API void cultmesh_quic_connection_shutdown( - void* runtime, uint64_t connection_id, uint64_t code); - -/* Opens an outbound unidirectional stream and puts `kind` on the wire as its - * first byte. That byte is the bridge's own and never produces an event 7. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_stream_open( - void* runtime, uint64_t connection_id, uint8_t kind, uint64_t* out_stream_id); - -/* Queues one whole encoded frame, length-prefixed by the bridge. A call that - * returns 0 produces exactly one event 7 later, completed or canceled. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_stream_send_frame( - void* runtime, uint64_t stream_id, - const uint8_t* encoded_frame, int32_t length, int32_t fin); - -/* Aborts a stream with a QUIC application error code. Unknown ids are ignored. */ -CULTMESH_QUIC_API void cultmesh_quic_stream_shutdown( - void* runtime, uint64_t stream_id, uint64_t code); - -/* The one blocking crossing. - * - * 0 nothing within `timeout_ms`. A poller already blocked here when - * `cultmesh_quic_runtime_close` starts is woken and returns 0. A poll that - * begins after the close starts is forbidden by section 4, and is not a - * case this return code covers. - * 1 `*out_event` filled and up to `payload_capacity` payload bytes copied; - * `*out_required` is the payload size - * 2 `payload_capacity` is too small. Nothing is consumed and nothing is - * written to `*out_event`; `*out_required` is the size to allocate. Ask - * again with a big enough buffer and the same event is delivered. - * -1 a bad call, including a null `payload` with a non-zero - * `payload_capacity`, which is a lie about the buffer and is refused - * rather than treated as a discard. - * - * `payload` may be null when `payload_capacity` is 0: that is the two-phase - * poll's first ask. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_next_event( - void* runtime, int32_t timeout_ms, struct cultmesh_quic_event* out_event, - uint8_t* payload, int32_t payload_capacity, int32_t* out_required); - -/* The last error sentence, NUL-terminated, truncated to `capacity`. Returns the - * number of bytes written, or -1 on a bad call. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_last_error( - void* runtime, char* destination, int32_t capacity); - -/* The raw platform `QUIC_STATUS` behind the last <= -1000 return, as its own - * bits: a negative HRESULT on Windows, a positive errno on POSIX. Diagnostics - * only; what is portable about the return code is its sign, not its value. 0 - * when nothing has failed. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_last_status(void* runtime); - -/* --------------------------------------------------------------------------- - * 7. The development seam - * - * Present only in a library configured with `CULTMESH_QUIC_DEBUG_ASSERTS`, which - * a shipped build never is. It exists for the runtime-lifetime scenarios: the - * quiesce is a promise about host calls that are inside the library, and the - * wait scenarios need the same kind of promise about the exact duration the - * bridge hands its own condition wait. A scenario that can only sleep and hope - * cannot tell a bridge that counts and waits correctly from one that does not. - * - * It is process-wide, not per-runtime, because the numbers outlive the runtime - * they describe: `cultmesh_quic_runtime_close` frees the runtime, and the count - * it waited on is read afterwards. The scenarios open one runtime at a time. - * --------------------------------------------------------------------------- */ -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - -/* Non-zero arms the hold and resets both counters: every call that reaches the - * blocking wait in `cultmesh_quic_next_event` parks inside the library instead - * of returning. Zero releases every held call and disarms it. A held call - * returns 0 and touches the runtime no further. */ -CULTMESH_QUIC_API void cultmesh_quic_debug_hold_calls(int32_t hold); - -/* The high-water mark of host calls counted inside the library since the hold - * was armed. Zero means no call was ever counted. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_debug_peak_calls(void); - -/* How many host calls were counted inside the library when the last - * `cultmesh_quic_runtime_close` began its wait, or -1 if no close has begun one - * since the hold was armed. This is the number the wait exists for. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_debug_calls_at_close(void); - -/* The whole duration, in milliseconds, most recently handed to the condition - * wait inside `cultmesh_quic_next_event`, recorded at the point the wait is - * entered rather than observed from outside. A poll whose queue is not empty, - * or whose `timeout_ms` is not greater than zero, never enters that wait and - * leaves this unchanged. -1 until the first such wait, or after - * `cultmesh_quic_debug_reset_last_wait_ms` — tracked by a separate internal - * flag, not by -1 being stored here, so a wait that ever recorded exactly -1 - * itself could not be confused with nothing having been recorded. - * - * This exists because a wall-clock probe cannot tell the host's timeout from - * any function of it that is the identity at the probe's own value — a floor, - * a round, a clamp, a later-poll fault, there is always another one that - * matches. This reads what the bridge actually handed its wait instead of - * inferring it from how long a call took. */ -CULTMESH_QUIC_API int32_t cultmesh_quic_debug_last_wait_ms(void); - -/* Resets the value `cultmesh_quic_debug_last_wait_ms` reports to -1, without - * touching the hold or the two call counters above. */ -CULTMESH_QUIC_API void cultmesh_quic_debug_reset_last_wait_ms(void); - -#endif /* CULTMESH_QUIC_DEBUG_ASSERTS */ - -#if defined(__cplusplus) -} -#endif - -#endif /* CULTMESH_QUIC_NATIVE_H */ diff --git a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp b/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp deleted file mode 100644 index e42b6d25..00000000 --- a/native/GameCult.Mesh.Quic.Native/tests/cultmesh_quic_native_tests.cpp +++ /dev/null @@ -1,1305 +0,0 @@ -// Scenario runner for the bridge's runtime lifetime rules. -// -// This is the one part of the bridge a host cannot check by reading: whether -// `cultmesh_quic_runtime_close` is safe against host threads that are inside the -// library when it starts. Everything here drives that path and nothing else. It -// opens a runtime, parks pollers in `cultmesh_quic_next_event`, closes the -// runtime underneath them, and asserts what the header promises: every parked -// poller returns, and none of them touches the runtime after it is gone. -// -// It consumes the bridge through include/cultmesh_quic_native.h — the same -// contract a host has — and nothing else. No MsQuic header, no internals. -// -// The failures it exists to catch are not visible in the return values, because -// a use-after-free is not an error code. Two things make them visible instead: -// a sanitizer build (ThreadSanitizer for the notify/destroy race, AddressSanitizer -// for the freed-runtime dereference), and `CULTMESH_QUIC_DEBUG_ASSERTS`, which -// makes the bridge state the quiesce invariant itself rather than leaving it to -// be caught by an unlucky thread schedule. -// -// cultmesh_quic_native_tests closerace [iterations] [pollers] -// cultmesh_quic_native_tests holdclose [iterations] [pollers] -// cultmesh_quic_native_tests polltimeout [iterations] -// cultmesh_quic_native_tests pollbusy [iterations] -// cultmesh_quic_native_tests latecall [iterations] -// cultmesh_quic_native_tests holdtimeout [iterations] -// cultmesh_quic_native_tests waitseam [iterations] -// cultmesh_quic_native_tests pollhammer [iterations] -// cultmesh_quic_native_tests zerotimeout [iterations] -// cultmesh_quic_native_tests payloadfit [iterations] -// -// `closerace` hunts the race: many pollers, many iterations, and whatever the -// schedule gives. `holdclose` takes the guessing out of it — the bridge's -// development seam (section 7 of the header) holds the pollers inside the -// library until this scenario releases them, and reports how many host calls the -// bridge actually counted, so the scenario asserts the quiesce instead of -// assuming its own fixture worked. It needs CULTMESH_QUIC_DEBUG_ASSERTS. -// -// In both of those the wait is ended by the close, so the host's timeout governs -// nothing either of them can see, and a bridge waiting on a constant of its own -// passes both. `polltimeout` is the one that lets the timeout govern the return -// and measures it, and `pollbusy` measures it again with the host's other thread -// calling in throughout, which wakes the wait. `latecall` covers the other half -// of section 4: a call that races the start of a close is refused rather than -// counted behind its wait. -// `holdtimeout` covers the seam's own rule, which nothing else is in a position -// to see: the hold parks a call the wait woke and not one whose own timeout -// expired, so the quiesce the hold scenarios assert is the bridge's doing and -// not the fixture's. -// -// Self's ruling of 2026-09-22, after seven Soul passes against `polltimeout`, -// `pollbusy` and `holdtimeout` alone: a wall-clock probe cannot tell the host's -// timeout from any function of it that is the identity at the probe's own -// value, because there is always another function that matches. `waitseam` -// puts the observation where the rule is decided instead of where its effect -// eventually shows: it reads `cultmesh_quic_debug_last_wait_ms`, the bridge's -// own account of what it handed its condition wait, and asserts equality -// against the host's argument, deterministically, with no timing tolerance. -// `polltimeout`, `pollbusy` and `holdtimeout` stay only as generous-margin -// proof that the recorded wait is really waited and not merely recorded. -// `pollhammer` covers the wait's predicate the same way `pollbusy` used to try -// to: one thread hammers every gate-touching entry point that cannot itself -// queue an event, in a yield loop, while another holds a single idle poll, and -// the poll must still stay for its timeout. -// -// Exit code 0 means every assertion held. Anything else, including a sanitizer -// abort or the bridge's own assertion, is a failure. Under ThreadSanitizer run -// it with `TSAN_OPTIONS=halt_on_error=1`, or a reported race leaves the exit -// code to the last thing that set it. - -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -// `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. -constexpr int32_t kPayloadCapacity = 4096; - -int Fail(const std::string& message) { - std::fprintf(stderr, "FAILED: %s\n", message.c_str()); - return 1; -} - -// One iteration: `pollers` threads parked in `next_event`, then a close under -// them. Returns an empty string when every promise held. -// -// Each poller makes exactly one blocking call and does not call again. Section 5 -// of the header puts the ordering on the host — no call may *begin* during a -// close — so a scenario that kept re-entering would be testing a shape the -// bridge declines to support, and its report would be the harness's own fault. -// A call already inside the library is the case the quiesce exists for, and that -// is the one run here. -std::string CloseRaceOnce(int pollers) { - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - - // Counts threads about to enter the blocking call. The close waits for all of - // them, so the race is run with the pollers actually inside rather than with - // threads that have not started yet. - std::atomic entering{0}; - std::atomic returned{0}; - std::atomic undefined_return{0}; - std::atomic timed_out{0}; - // Set just before the close, and read by each poller as it returns. A poller - // that is already out by then was never in the race, and a scenario full of - // those passes without testing anything: this is what catches a bridge whose - // blocking crossing has stopped blocking. - std::atomic closing_started{false}; - std::atomic left_before_the_close{0}; - -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - // Reset before any poller thread exists, so nothing here can race a - // `CallScope` that has already been counted: `cultmesh_quic_next_event` - // raises `debug_peak_calls` the moment its `CallScope` is constructed, at - // the very top of the call, and a reset performed even microseconds after - // that construction would zero a count no further call is coming to raise - // again, stranding the settle loop below at "not yet" until its deadline. - cultmesh_quic_debug_hold_calls(1); - cultmesh_quic_debug_hold_calls(0); -#endif - - std::vector threads; - threads.reserve(static_cast(pollers)); - for (int index = 0; index < pollers; ++index) { - threads.emplace_back([runtime, &entering, &returned, &undefined_return, &timed_out, - &closing_started, &left_before_the_close] { - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - - entering.fetch_add(1); - const auto started = std::chrono::steady_clock::now(); - // Parked here when the close arrives. `closing` wakes the wait; the - // call scope this thread holds is what the close must count out - // before it frees anything. - const int32_t result = cultmesh_quic_next_event( - runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required); - const auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - started).count(); - - if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1); - // The close is what ends this wait. Sitting out the whole timeout - // instead means the wake never reached it. - if (elapsed >= kPollTimeoutMs / 2) timed_out.fetch_add(1); - if (!closing_started.load()) left_before_the_close.fetch_add(1); - returned.fetch_add(1); - }); - } - - while (entering.load() < pollers) std::this_thread::yield(); -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - // A fixed settle races CPU starvation: under enough contending threads a - // poller that has only incremented `entering` can sit preempted for far - // longer than any sleep this scenario could afford, so the close would - // begin before it ever reached the blocking wait — not the race this - // scenario exists to run, just a fixture that gave up too soon. Where the - // 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::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 - // statements later. Counted-inside is the expensive part to wait for under - // 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(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(kCloseRaceNoSeamSettleMs)); -#endif - - closing_started.store(true); - cultmesh_quic_runtime_close(runtime); - for (auto& thread : threads) thread.join(); - - if (left_before_the_close.load() != 0) - return std::to_string(left_before_the_close.load()) + " of " + std::to_string(pollers) + - " pollers returned before the close began, so they were never in the race"; - if (undefined_return.load() != 0) - return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define"; - if (timed_out.load() != 0) - return std::to_string(timed_out.load()) + " of " + std::to_string(pollers) + - " pollers sat out the timeout instead of being woken by the close"; - if (returned.load() != pollers) - return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned"; - return {}; -} - -// The three timeouts `polltimeout` asks for: a spread, not a hunt for any -// particular derivation. `waitseam` is what proves the bridge hands its -// condition wait the host's own argument, exactly, at every value that matters -// — these three exist only to prove that value is then really waited on, so -// the spread just needs to be a short one, an ordinary one and a long one. -constexpr int32_t kShortestPollMs = 15; -constexpr int32_t kShortPollMs = 200; -constexpr int32_t kLongPollMs = 7300; - -// How much sooner than the timeout a poll may return. A wait may be late; it may -// not be early, because returning early is exactly what waiting on a shorter -// duration looks like. This is scheduler granularity, not slack. -constexpr int kEarlyToleranceMs = 20; - -// How much later than the timeout a poll may return. Generous on purpose: these -// scenarios are no longer where a clamp, a floor, a round or a scale is caught -// — `waitseam` catches those by equality, with no clock involved — so this only -// has to catch a wait that is wrong by a lot: a constant of the bridge's own, a -// wait that never happens, a hold that parks a call its own timeout already -// ended, or the W1-W3 family Self's ruling of 2026-09-22 added, which apply -// their arithmetic to the duration `wait_for` actually receives *after* the -// seam has already recorded it and so cannot be caught by `waitseam`'s -// equality check at all. 400 ms sits above the worst overshoot a 16-burner -// stress pass measured on a loaded win32 host (180 ms; 27 ms on linux) and -// below every one of W1-W3's smallest offsets (485 ms), so one flat margin -// still serves every probe this file times without going blind to the family -// it was widened for. -constexpr int kGenerousLateToleranceMs = 400; - -struct TimedPoll { - int32_t result; - long long elapsed_ms; -}; - -TimedPoll PollFor(void* runtime, int32_t timeout_ms) { - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - const auto started = std::chrono::steady_clock::now(); - const int32_t result = cultmesh_quic_next_event( - runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required); - const auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - started).count(); - return {result, elapsed}; -} - -// The host's timeout, and the only thing it is: how long a poll with nothing to -// deliver stays inside the library. Nothing else here checks it — `closerace` -// and `holdclose` both end their waits with a close, so in both of them the -// bridge could wait on any duration at all and every assertion would still hold. -// A runtime with no listener and no connection has nothing to deliver, so the -// timeout is what ends this poll, and the elapsed time is what it waited on. -// The bands every timed poll is held to, and the worst overshoot seen, which is -// printed so a tolerance can be checked against a machine under load rather than -// trusted. -struct Overshoot { - long long worst_ms = LLONG_MIN; - void Record(const TimedPoll& poll, int32_t timeout_ms) { - worst_ms = (std::max)(worst_ms, poll.elapsed_ms - timeout_ms); - } -}; - -std::string CheckTimedPoll(const TimedPoll& poll, int32_t timeout_ms, int late_tolerance_ms) { - const std::string asked = "a poll asking for " + std::to_string(timeout_ms) + " ms "; - if (poll.result != 0) - return asked + "returned " + std::to_string(poll.result) + ", not the 0 an idle runtime owes it"; - if (poll.elapsed_ms < timeout_ms - kEarlyToleranceMs) - return asked + "returned after " + std::to_string(poll.elapsed_ms) + - " ms: the wait ended on some shorter duration than the one it was given"; - if (poll.elapsed_ms > static_cast(timeout_ms) + late_tolerance_ms) - return asked + "returned after " + std::to_string(poll.elapsed_ms) + - " ms: the wait ended on some longer duration than the one it was given"; - return {}; -} - -constexpr std::array kTimedProbesMs{kShortestPollMs, kShortPollMs, kLongPollMs}; - -std::string PollTimeoutOnce(std::array& overshoot) { - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - - std::string failure; - for (size_t probe = 0; probe < kTimedProbesMs.size() && failure.empty(); ++probe) { - const TimedPoll poll = PollFor(runtime, kTimedProbesMs[probe]); - overshoot[probe].Record(poll, kTimedProbesMs[probe]); - failure = CheckTimedPoll(poll, kTimedProbesMs[probe], kGenerousLateToleranceMs); - } - - cultmesh_quic_runtime_close(runtime); - return failure; -} - -int PollTimeout(int iterations) { - std::array overshoot{}; - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = PollTimeoutOnce(overshoot); - if (!failure.empty()) - return Fail("polltimeout iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("polltimeout %dx: ok (worst overshoot %lld ms at %d, %lld ms at %d, %lld ms at %d)\n", - iterations, overshoot[0].worst_ms, kTimedProbesMs[0], overshoot[1].worst_ms, kTimedProbesMs[1], - overshoot[2].worst_ms, kTimedProbesMs[2]); - return 0; -} - -// How long `pollbusy`'s poll asks for, and how often the host's other thread -// calls into the library while it waits. -constexpr int32_t kBusyPollMs = 1000; -constexpr int kBusyCallEveryMs = 20; - -// The same rule as `polltimeout` — a poll with nothing to deliver stays for its -// timeout — for a host with more than one thread, which is every real host: one -// thread polls, another sends, shuts streams down, reads the last error. -// -// Every host call leaves through a scope that wakes every waiter on the -// runtime's condition variable, because that is how the close learns a call has -// left. So a poll is woken each time any other thread makes any call, and what -// keeps it inside is the wait's predicate: nothing queued and no close means -// wait again, for the rest of the time the host asked for. With one host thread -// nothing else ever calls during the wait, and the predicate could be deleted -// with every other scenario still green. -// -// The other thread keeps calling for twice the poll's timeout and then stops, -// so a wait that restarts its whole timeout on every wake is seen as late -// rather than hanging the scenario. -std::string PollBusyOnce(Overshoot& overshoot) { - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - - std::atomic calls{0}; - std::atomic refused{0}; - std::thread other([runtime, &calls, &refused] { - const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(2 * kBusyPollMs); - std::array message{}; - while (std::chrono::steady_clock::now() < until) { - if (cultmesh_quic_last_error(runtime, message.data(), static_cast(message.size())) < 0) - refused.fetch_add(1); - calls.fetch_add(1); - std::this_thread::sleep_for(std::chrono::milliseconds(kBusyCallEveryMs)); - } - }); - - const TimedPoll poll = PollFor(runtime, kBusyPollMs); - const int calls_during = calls.load(); - other.join(); - cultmesh_quic_runtime_close(runtime); - - overshoot.Record(poll, kBusyPollMs); - if (refused.load() != 0) - return std::to_string(refused.load()) + " call(s) from the host's other thread were refused " - "by a runtime that was not closing"; - // The timing first: a poll that left on the first wake returns before the - // other thread has had time to make many calls, and that is the bridge - // failing, not the fixture. - const std::string failure = CheckTimedPoll(poll, kBusyPollMs, kGenerousLateToleranceMs); - if (!failure.empty()) return failure + ", while the host's other thread was calling in"; - // A poll that passed with fewer calls than this was never woken, and passed - // because the fixture did nothing rather than because the bridge held. - if (calls_during < 3) - return "the host's other thread made " + std::to_string(calls_during) + - " call(s) during the poll, so nothing woke it"; - return {}; -} - -int PollBusy(int iterations) { - Overshoot overshoot{}; - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = PollBusyOnce(overshoot); - if (!failure.empty()) - return Fail("pollbusy iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("pollbusy %dx: ok (worst overshoot %lld ms at %d)\n", iterations, overshoot.worst_ms, - kBusyPollMs); - return 0; -} - -// Nothing listens on the discard port, so a connection to it fails and the -// bridge says why. That reason is the payload this scenario needs, and reaching -// it takes no listener, no credential and no established connection. -constexpr uint16_t kClosedPort = 9; - -// How long the failure is given to arrive. The loopback refusal is immediate; -// if the refusal is swallowed instead, MsQuic's own handshake timeout ends the -// connection at 10 seconds and event 4 still arrives, so this only bounds a -// bridge that reports nothing at all. -constexpr int kEventDeadlineMs = 20000; - -// The bridge writes its own reason sentences and every one of them starts here. -// Checking the bytes, and not only the length, is what separates a payload -// copied out of the queued event from one copied out of an event that was popped -// first: the second reads freed memory, which does not spell this. -constexpr char kReasonPrefix[] = "CultMesh QUIC connection"; - -// The two-phase poll, which is the shape every host uses to size its buffer: -// ask with nothing, be told what it needs, ask again. What makes it work is that -// the refusal consumes nothing — the event is still queued, payload and all, -// after a return of 2 — and that the copy happens before the event is popped. -// Both are section 6 of the header, and nothing committed reached either. -std::string PayloadFitOnce() { - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - - std::string failure; - uint64_t connection_id = 0; - const int32_t connecting = - cultmesh_quic_connection_open(runtime, "127.0.0.1", kClosedPort, &connection_id); - if (connecting != 0) { - cultmesh_quic_runtime_close(runtime); - return "cultmesh_quic_connection_open returned " + std::to_string(connecting); - } - - // An out parameter the bridge must not write on a refusal, filled with - // something the bridge never writes so that a write is visible. - cultmesh_quic_event poisoned{}; - std::memset(&poisoned, 0xab, sizeof(poisoned)); - cultmesh_quic_event event = poisoned; - int32_t required = 0; - int32_t result = 0; - - // Ask with no room at all until the event carrying the failure reason - // arrives. An event with no payload fits in nothing, so it is delivered and - // polled past rather than mistaken for this one. - const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kEventDeadlineMs); - for (;;) { - event = poisoned; - required = 0; - result = cultmesh_quic_next_event(runtime, 250, &event, nullptr, 0, &required); - if (result == 2 && required > 0) break; - if (result < 0) { - failure = "cultmesh_quic_next_event returned " + std::to_string(result); - break; - } - if (std::chrono::steady_clock::now() >= deadline) { - failure = "no event carrying a payload arrived within " + - std::to_string(kEventDeadlineMs) + " ms of a connection to a closed port"; - break; - } - } - - if (failure.empty() && std::memcmp(&event, &poisoned, sizeof(event)) != 0) - failure = "the refusal wrote to *out_event, which the header says it leaves alone"; - - // Again, with the same empty buffer: a refusal that consumed the event - // leaves nothing to find the second time. - if (failure.empty()) { - cultmesh_quic_event again = poisoned; - int32_t required_again = 0; - const int32_t second = cultmesh_quic_next_event(runtime, 0, &again, nullptr, 0, &required_again); - if (second != 2 || required_again != required) - failure = "the second ask returned " + std::to_string(second) + " needing " + - std::to_string(required_again) + ", not the 2 needing " + std::to_string(required) + - " the first one did: the refusal consumed the event"; - } - - // And with room, which is where the copy happens. The tail beyond the - // payload is written too, so a copy that ran past what it was told to write - // is visible. - if (failure.empty()) { - constexpr uint8_t kTail = 0x5a; - std::vector payload(static_cast(required) + 16, kTail); - cultmesh_quic_event delivered = poisoned; - int32_t required_delivered = 0; - const int32_t third = cultmesh_quic_next_event( - runtime, 0, &delivered, payload.data(), static_cast(payload.size()), - &required_delivered); - const std::string reason(reinterpret_cast(payload.data()), - static_cast(required)); - if (third != 1 || required_delivered != required) - failure = "the ask with room returned " + std::to_string(third) + " needing " + - std::to_string(required_delivered) + ", not the 1 needing " + - std::to_string(required) + " the refusal promised"; - else if (delivered.type != CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN || - delivered.connection_id != connection_id || - delivered.payload_length != required) - failure = "the delivered event was type " + std::to_string(delivered.type) + - " on connection " + std::to_string(delivered.connection_id) + " carrying " + - std::to_string(delivered.payload_length) + " byte(s), not the failed connection's own"; - else if (reason.rfind(kReasonPrefix, 0) != 0) - failure = "the payload delivered was '" + reason + "', not a reason this bridge writes"; - else if (payload[static_cast(required)] != kTail) - failure = "the copy wrote past the " + std::to_string(required) + " bytes it reported"; - } - - // Consumed now, and only now. - if (failure.empty()) { - cultmesh_quic_event emptied = poisoned; - int32_t required_emptied = 0; - const int32_t fourth = - cultmesh_quic_next_event(runtime, 0, &emptied, nullptr, 0, &required_emptied); - if (fourth != 0) - failure = "the event was still queued after being delivered: a further ask returned " + - std::to_string(fourth); - } - - cultmesh_quic_runtime_close(runtime); - return failure; -} - -int PayloadFit(int iterations) { - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = PayloadFitOnce(); - if (!failure.empty()) - return Fail("payloadfit iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("payloadfit %dx: ok\n", iterations); - return 0; -} - -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - -// How long a poll that does not block is given to prove it by returning, after -// the calls have been counted and before the close begins. A bridge whose -// blocking wait no longer blocks has emptied itself of host calls by the time -// the close starts, and `calls_at_close` then says 0. -constexpr int kSettleMs = 100; - -// How long the close is required to stay inside `cultmesh_quic_runtime_close` -// while the pollers are held. The held calls are what it is waiting for, and -// nothing releases them until this elapses, so a close that returns first gave -// up on a call that was still inside. This bounds what the scenario can see: a -// close that waits for longer than this and then gives up is not distinguished -// from one that waits properly, which is the honest limit of the check. -constexpr int kHeldWindowMs = 500; - -// How long the pollers are given to be counted inside the library before the -// close begins. Reaching `pollers` is the normal path; the deadline only stops -// a bridge that never counts them from hanging the scenario. -constexpr int kCountedDeadlineMs = 2000; - -// One iteration of the quiesce itself, with the guesswork taken out: the pollers -// are held inside the library by the bridge's own development seam until this -// scenario lets them go, and the bridge says how many calls it counted rather -// than the scenario assuming the schedule put them there. -// -// Three promises, and a mutation that breaks any one of them is seen here: -// - the calls are counted (`peak`), so a blocking crossing outside a call scope -// is not merely uncounted-but-lucky, it reads as zero; -// - they were still counted when the close began its wait (`calls_at_close`), -// so a poll that stopped blocking cannot leave the scenario testing an empty -// library; -// - the close did not return while they were held, so a wait that gives up -// after any interval shorter than the hold is not a wait. -std::string HoldCloseOnce(int pollers) { - cultmesh_quic_debug_hold_calls(1); - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) { - cultmesh_quic_debug_hold_calls(0); - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - } - - std::atomic returned{0}; - std::atomic undefined_return{0}; - std::vector threads; - threads.reserve(static_cast(pollers)); - for (int index = 0; index < pollers; ++index) { - threads.emplace_back([runtime, &returned, &undefined_return] { - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - const int32_t result = cultmesh_quic_next_event( - runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required); - if (result != 0 && result != 1 && result != 2) undefined_return.fetch_add(1); - returned.fetch_add(1); - }); - } - - const auto counted_deadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs); - while (cultmesh_quic_debug_peak_calls() < pollers && - std::chrono::steady_clock::now() < counted_deadline) - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs)); - - std::atomic closed{false}; - std::thread closer([runtime, &closed] { - cultmesh_quic_runtime_close(runtime); - closed.store(true); - }); - const auto held_until = - std::chrono::steady_clock::now() + std::chrono::milliseconds(kHeldWindowMs); - while (!closed.load() && std::chrono::steady_clock::now() < held_until) - std::this_thread::sleep_for(std::chrono::milliseconds(5)); - - const bool closed_while_held = closed.load(); - const int32_t peak = cultmesh_quic_debug_peak_calls(); - const int32_t at_close = cultmesh_quic_debug_calls_at_close(); - - cultmesh_quic_debug_hold_calls(0); - closer.join(); - for (auto& thread : threads) thread.join(); - - if (peak != pollers) - return "the library's peak count of host calls inside it was " + std::to_string(peak) + ", not " + - std::to_string(pollers) + ": either the blocking crossing is not inside a call scope, or it " + - "did not block long enough for the pollers to be inside it together"; - if (at_close != pollers) - return "the close began its wait with " + std::to_string(at_close) + - " host call(s) counted inside, not " + std::to_string(pollers) + - "; the pollers were not inside the library when the close started"; - if (closed_while_held) - return "the close returned while " + std::to_string(pollers) + - " host call(s) were still held inside the library"; - if (undefined_return.load() != 0) - return std::to_string(undefined_return.load()) + " poller(s) saw a return the header does not define"; - if (returned.load() != pollers) - return std::to_string(returned.load()) + " of " + std::to_string(pollers) + " pollers returned"; - return {}; -} - -// One iteration of the other half of section 4: a call made while a close is -// running is refused, and is not counted behind the close's wait. -// -// The prohibition is the host's — no call may begin after the close starts — and -// the bridge does not make it optional. What it does do is refuse the call that -// races the start, and that refusal is load-bearing rather than courteous: a -// call admitted after the closer has begun increments the in-flight count behind -// a wait that has already read it, and the wait can then be left waiting on a -// count that reaches zero only when the late caller happens to leave. -// -// The seam is what makes the window wide enough to aim at: a held poller keeps -// the close inside its wait for as long as this scenario wants it there. -std::string LateCallOnce() { - cultmesh_quic_debug_hold_calls(1); - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) { - cultmesh_quic_debug_hold_calls(0); - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - } - - std::thread poller([runtime] { - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - cultmesh_quic_next_event( - runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required); - }); - - const auto counted_deadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs); - while (cultmesh_quic_debug_peak_calls() < 1 && - std::chrono::steady_clock::now() < counted_deadline) - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs)); - - std::thread closer([runtime] { cultmesh_quic_runtime_close(runtime); }); - // The close has begun its wait once it has read the count, and the held - // poller is what keeps it there. - const auto started_deadline = - std::chrono::steady_clock::now() + std::chrono::milliseconds(kCountedDeadlineMs); - while (cultmesh_quic_debug_calls_at_close() < 0 && - std::chrono::steady_clock::now() < started_deadline) - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - const int32_t at_close = cultmesh_quic_debug_calls_at_close(); - - // Two calls, from a thread of their own so that a bridge which admits them - // cannot park this one: one that does not block, and then the blocking - // crossing. That order, because a bridge that admits the blocking one parks - // it inside the library until the hold is released, and a call made after - // that would be racing the teardown rather than the wait. - std::atomic late_poll{0}; - std::atomic late_error{0}; - std::thread late([runtime, &late_poll, &late_error] { - std::array message{}; - late_error.store(cultmesh_quic_last_error(runtime, message.data(), - static_cast(message.size()))); - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - late_poll.store(cultmesh_quic_next_event( - runtime, kPollTimeoutMs, &event, payload.data(), kPayloadCapacity, &required)); - }); - - std::this_thread::sleep_for(std::chrono::milliseconds(kSettleMs)); - cultmesh_quic_debug_hold_calls(0); - late.join(); - closer.join(); - poller.join(); - - if (at_close < 1) - return "the close did not begin its wait with the poller counted inside, so nothing held it " - "open for a call to race"; - if (late_poll.load() != -1) - return "cultmesh_quic_next_event returned " + std::to_string(late_poll.load()) + - " to a call made during the close, not the -1 that refuses it"; - if (late_error.load() != -1) - return "cultmesh_quic_last_error returned " + std::to_string(late_error.load()) + - " to a call made during the close, not the -1 that refuses it"; - return {}; -} - -// How long `holdtimeout`'s two polls ask for, and how long the hold stays armed -// under them. The gap between each poll and the hold is the whole measurement: -// a poll the hold parks leaves when the hold is released, so its elapsed time -// lands at the last number instead of its own. -// -// Two polls, a short one and a long one, because a guard that tests the timeout -// instead of the wake — park it unless it was long — is identity on any single -// value on one side of its threshold. The long one sits above 1500, the stated -// ceiling on how long the hold guard may treat a long wait as surely worth -// parking; the hold stays armed well past it so a guard that parks anyway is -// held for the rest of the arming instead of leaving on its own timeout, which -// is the overshoot this scenario's late tolerance is wide enough to see. -constexpr int32_t kHeldTimeoutPollMs = 150; -constexpr int32_t kHeldLongTimeoutPollMs = 1600; -constexpr int kHoldArmedMs = 3000; - -// The seam's own rule, and the only scenario that can see it: the hold parks a -// call the wait woke, and not one whose own timeout expired. -// -// Everything else that arms the hold parks a woken call, so the guard governs -// nothing either of them observes — the timeout scenario never arms the hold, -// and the hold scenarios never let a timeout expire, so nothing was ever in -// both states at once and the guard could be deleted with every scenario still -// green. This puts calls in both states: the hold is armed, and the runtime is -// idle, so each wait ends on its timeout and the call is on its way out. -// -// It matters because a scenario that parked such a call would then report the -// bridge keeping a host call inside the library when what kept it was the -// fixture — the quiesce numbers `holdclose` asserts would be the seam's, not -// the bridge's. -std::string HoldTimeoutOnce(std::array& overshoot) { - cultmesh_quic_debug_hold_calls(1); - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) { - cultmesh_quic_debug_hold_calls(0); - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - } - - // Each on a thread of its own, because a parked call does not come back until - // the release below and this thread is what releases it. - constexpr std::array timeouts{kHeldTimeoutPollMs, kHeldLongTimeoutPollMs}; - std::array polls{}; - std::array pollers; - for (size_t index = 0; index < timeouts.size(); ++index) - pollers[index] = std::thread([runtime, &polls, &timeouts, index] { - polls[index] = PollFor(runtime, timeouts[index]); - }); - - std::this_thread::sleep_for(std::chrono::milliseconds(kHoldArmedMs)); - cultmesh_quic_debug_hold_calls(0); - for (auto& poller : pollers) poller.join(); - cultmesh_quic_runtime_close(runtime); - - for (size_t index = 0; index < timeouts.size(); ++index) { - const TimedPoll& poll = polls[index]; - const int32_t timeout_ms = timeouts[index]; - overshoot[index].Record(poll, timeout_ms); - if (poll.result != 0) - return "a poll ended by its own timeout returned " + std::to_string(poll.result) + - ", not the 0 an idle runtime owes it"; - if (poll.elapsed_ms < timeout_ms - kEarlyToleranceMs) - return "a poll asking for " + std::to_string(timeout_ms) + " ms returned after " + - std::to_string(poll.elapsed_ms) + " ms, so the fixture never let its timeout run out"; - if (poll.elapsed_ms > timeout_ms + kGenerousLateToleranceMs) - return "a poll asking for " + std::to_string(timeout_ms) + " ms returned after " + - std::to_string(poll.elapsed_ms) + " ms: the hold parked a call its own timeout had " - "already ended, and it left when the hold did"; - } - return {}; -} - -int HoldTimeout(int iterations) { - std::array overshoot{}; - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = HoldTimeoutOnce(overshoot); - if (!failure.empty()) - return Fail("holdtimeout iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("holdtimeout %dx: ok (worst overshoot %lld ms at %d, %lld ms at %d)\n", iterations, - overshoot[0].worst_ms, kHeldTimeoutPollMs, overshoot[1].worst_ms, kHeldLongTimeoutPollMs); - return 0; -} - -int LateCall(int iterations) { - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = LateCallOnce(); - if (!failure.empty()) - return Fail("latecall iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("latecall %dx: ok\n", iterations); - return 0; -} - -int HoldClose(int iterations, int pollers) { - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = HoldCloseOnce(pollers); - if (!failure.empty()) - return Fail("holdclose iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("holdclose %dx%d: ok\n", iterations, pollers); - return 0; -} - -// The values `waitseam` asks for: the smallest, the seam's own sentinel-versus- -// zero boundary, one on either side of a 16 ms quantum and of a 40 ms one, the -// ordinary `pollbusy` timeout and its neighbours, `polltimeout`'s long probe, -// and INT32_MAX. Nothing in section 6 of the header states a maximum on -// `timeout_ms` narrower than what `int32_t` itself holds, so INT32_MAX is the -// ceiling this checks and nothing wider is claimed. -constexpr std::array kWaitSeamProbesMs{ - 0, 1, 15, 16, 39, 40, 999, 1000, 1001, 7300, INT32_MAX}; - -// How long a probe's own poller thread is given to reach the wait before this -// gives up on it and fails with the probe named, rather than hanging. -constexpr int kWaitSeamDeadlineMs = 2000; - -// One probe: ask a fresh, otherwise-idle runtime for `timeout_ms` and read -// `cultmesh_quic_debug_last_wait_ms` — the bridge's own account of what it -// handed its condition wait — instead of measuring how long the call took. -// Every clamp, floor, round, scale, offset and later-poll mapping Soul's seven -// passes found is the identity at some wall-clock probe; none of them is the -// identity here unless it is also the identity at every value below, because -// this reads the argument itself rather than inferring it from elapsed time. -// -// `timeout_ms <= 0` never reaches the wait at all — `cultmesh_quic_next_event` -// returns at once when the queue is empty — so there is nothing for the seam to -// record, and the honest check is the converse: it must still say nothing, -// which is exactly what a seam that recorded the raw argument on every call, -// instead of only the value it actually handed its wait, would get wrong. -std::string WaitSeamProbeOnce(int32_t timeout_ms) { - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - cultmesh_quic_debug_reset_last_wait_ms(); - - std::string failure; - if (timeout_ms <= 0) { - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - const int32_t result = cultmesh_quic_next_event( - runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required); - const int32_t recorded = cultmesh_quic_debug_last_wait_ms(); - if (result != 0) - failure = "a poll asking for " + std::to_string(timeout_ms) + " ms returned " + - std::to_string(result) + ", not the 0 an idle runtime owes it"; - else if (recorded != -1) - failure = "a poll asking for " + std::to_string(timeout_ms) + " ms recorded " + - std::to_string(recorded) + " ms on the wait seam, though the bridge's own guard " - "never enters a wait for a timeout that is not greater than zero"; - cultmesh_quic_runtime_close(runtime); - return failure; - } - - // A real wait, on a thread of its own: a probe at INT32_MAX has to be - // released by the close, not waited out. - std::thread poller([runtime, timeout_ms] { - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - cultmesh_quic_next_event(runtime, timeout_ms, &event, payload.data(), kPayloadCapacity, &required); - }); - - const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kWaitSeamDeadlineMs); - int32_t recorded = cultmesh_quic_debug_last_wait_ms(); - while (recorded == -1 && std::chrono::steady_clock::now() < deadline) { - std::this_thread::sleep_for(std::chrono::milliseconds(1)); - recorded = cultmesh_quic_debug_last_wait_ms(); - } - - cultmesh_quic_runtime_close(runtime); - poller.join(); - - if (recorded != timeout_ms) - failure = "a poll asking for " + std::to_string(timeout_ms) + " ms recorded " + - std::to_string(recorded) + " ms on the wait seam: the bridge handed its condition " - "wait something other than the host's own argument"; - return failure; -} - -// Past the twelfth poll on one runtime: no scenario before this one ever asked -// a runtime for a thirteenth, so a wait that only changes shape after twelve -// had nowhere to be seen. Forty short waits on a runtime that stays open for -// all of them, each checked by equality. -constexpr int32_t kWaitSeamRepeatedMs = 5; -constexpr int kWaitSeamRepeatedPolls = 40; - -std::string WaitSeamRepeatedOnce() { - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - - std::string failure; - for (int poll = 0; poll < kWaitSeamRepeatedPolls && failure.empty(); ++poll) { - cultmesh_quic_debug_reset_last_wait_ms(); - std::vector payload(static_cast(kPayloadCapacity)); - cultmesh_quic_event event{}; - int32_t required = 0; - cultmesh_quic_next_event( - runtime, kWaitSeamRepeatedMs, &event, payload.data(), kPayloadCapacity, &required); - const int32_t recorded = cultmesh_quic_debug_last_wait_ms(); - if (recorded != kWaitSeamRepeatedMs) - failure = "poll " + std::to_string(poll + 1) + " of " + - std::to_string(kWaitSeamRepeatedPolls) + " on one runtime recorded " + - std::to_string(recorded) + " ms, not " + std::to_string(kWaitSeamRepeatedMs); - } - cultmesh_quic_runtime_close(runtime); - return failure; -} - -int WaitSeam(int iterations) { - for (int iteration = 0; iteration < iterations; ++iteration) { - for (int32_t probe : kWaitSeamProbesMs) { - const std::string failure = WaitSeamProbeOnce(probe); - if (!failure.empty()) - return Fail("waitseam iteration " + std::to_string(iteration) + " probe " + - std::to_string(probe) + ": " + failure); - } - const std::string repeated = WaitSeamRepeatedOnce(); - if (!repeated.empty()) - return Fail("waitseam iteration " + std::to_string(iteration) + ": " + repeated); - } - std::printf("waitseam %dx: ok (%zu probes, %d repeated polls on one runtime)\n", - iterations, kWaitSeamProbesMs.size(), kWaitSeamRepeatedPolls); - return 0; -} - -#endif // CULTMESH_QUIC_DEBUG_ASSERTS - -// How long `pollhammer`'s idle poll asks for. One host thread hammers every -// gate-touching entry point that cannot itself queue an event, in a yield -// loop, while another holds this one poll idle; each hammered call's -// `CallScope` destructor notifies every waiter on the runtime's condition -// variable (cultmesh_quic_native.cpp:286), so the idle poll is woken many times -// over for every timeout it is given here. That is exactly the shape a wake -// count or an error-state predicate exploits, and exactly what a revert that -// returns on any wake at all fails immediately. -// -// `cultmesh_quic_connection_open`, `cultmesh_quic_listener_open` and -// `cultmesh_quic_stream_open` touch the same gate but can each queue a real -// event on this runtime, and a genuine event is allowed to end the idle poll -// early — hammering with those would make an early return ambiguous between -// the fault this scenario hunts and a real delivery, so they are left out. The -// refused `listener_open` below is a setup step run once, not part of the -// loop, and it leaves no listener behind. -constexpr int32_t kHammerPollMs = 1000; - -// A self-signed ECDSA P-256 PKCS12, generated once with OpenSSL and embedded so -// this scenario needs no crypto library of its own and no network fetch: -// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \ -// -keyout key.pem -out cert.pem -days 3650 -nodes -subj "/CN=cultmesh-quic-native-tests" -// openssl pkcs12 -export -out test.p12 -inkey key.pem -in cert.pem -passout pass: -// Used only to give pollhammer's live-objects run a listener that can actually -// load a credential; nothing ever connects to it, so it never accepts and never -// queues an event this scenario would have to tell apart from a bug. -constexpr std::array kSelfSignedPkcs12{{ - 0x30, 0x82, 0x04, 0x3c, 0x02, 0x01, 0x03, 0x30, 0x82, 0x03, 0xf2, 0x06, - 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01, 0xa0, 0x82, - 0x03, 0xe3, 0x04, 0x82, 0x03, 0xdf, 0x30, 0x82, 0x03, 0xdb, 0x30, 0x82, - 0x02, 0x8a, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, - 0x06, 0xa0, 0x82, 0x02, 0x7b, 0x30, 0x82, 0x02, 0x77, 0x02, 0x01, 0x00, - 0x30, 0x82, 0x02, 0x70, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, - 0x01, 0x07, 0x01, 0x30, 0x5f, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, - 0x0d, 0x01, 0x05, 0x0d, 0x30, 0x52, 0x30, 0x31, 0x06, 0x09, 0x2a, 0x86, - 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0c, 0x30, 0x24, 0x04, 0x10, 0x48, - 0xdf, 0x04, 0x5a, 0x31, 0x34, 0x6d, 0xe0, 0x7c, 0xde, 0x3c, 0xa2, 0xde, - 0xf2, 0x8d, 0x20, 0x02, 0x02, 0x08, 0x00, 0x30, 0x0c, 0x06, 0x08, 0x2a, - 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x09, 0x05, 0x00, 0x30, 0x1d, 0x06, - 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a, 0x04, 0x10, - 0x44, 0x04, 0xd9, 0x9b, 0x99, 0x3a, 0xcb, 0x89, 0xc0, 0xcb, 0x53, 0xe3, - 0xe3, 0x5b, 0xb4, 0x48, 0x80, 0x82, 0x02, 0x00, 0x92, 0xcc, 0x04, 0xcf, - 0x7d, 0x58, 0xec, 0x68, 0x1a, 0x1b, 0xd5, 0x3b, 0x17, 0x57, 0x22, 0xd5, - 0x45, 0x00, 0xff, 0x05, 0x0f, 0x0d, 0x18, 0x8b, 0x72, 0x5b, 0xcc, 0xd9, - 0x87, 0xf4, 0x99, 0x44, 0x7c, 0xd6, 0xc3, 0x02, 0x2a, 0xaf, 0xd9, 0x10, - 0x14, 0xd3, 0x9f, 0x1a, 0x2c, 0x7b, 0x4f, 0xe6, 0x0b, 0x1d, 0x7f, 0xb9, - 0xf4, 0x01, 0x5c, 0x4b, 0xd5, 0xa1, 0x57, 0xb0, 0x3d, 0xe0, 0xbc, 0x70, - 0x72, 0xe0, 0xe9, 0xb8, 0x9a, 0xde, 0x68, 0xfe, 0x7c, 0xc7, 0xca, 0xcc, - 0x61, 0x32, 0x88, 0x72, 0xdd, 0x97, 0x28, 0x7b, 0x5b, 0x0d, 0x30, 0xf5, - 0xa2, 0xd1, 0xe8, 0x89, 0xd4, 0x1d, 0x88, 0x28, 0xd2, 0xc3, 0x02, 0x93, - 0xa1, 0x27, 0xfb, 0x4a, 0xe7, 0xdc, 0xac, 0xf3, 0x0d, 0xa9, 0xd3, 0x14, - 0x37, 0x71, 0xb7, 0xe5, 0xaf, 0xdb, 0xb6, 0xda, 0x42, 0xb7, 0x24, 0x7a, - 0x94, 0xad, 0x73, 0x79, 0xde, 0x21, 0xb9, 0x13, 0x63, 0x98, 0x33, 0x62, - 0x4a, 0x36, 0xb8, 0x6d, 0x14, 0x8b, 0x84, 0xf0, 0x8e, 0x55, 0xcc, 0x95, - 0x14, 0x54, 0xcc, 0x68, 0x4b, 0x09, 0x24, 0xb4, 0x79, 0x49, 0x05, 0xc7, - 0x0e, 0xfe, 0xed, 0x23, 0x8c, 0x87, 0xc6, 0xc3, 0x76, 0x25, 0xd9, 0xd6, - 0xae, 0x5a, 0xf4, 0x86, 0x74, 0xb8, 0x44, 0x22, 0xe8, 0xfc, 0xa5, 0xc2, - 0x25, 0xbd, 0x84, 0xfd, 0x92, 0x96, 0xe2, 0x66, 0xa9, 0xb0, 0x1f, 0xd3, - 0x8d, 0xf7, 0x1c, 0xce, 0xed, 0x09, 0x11, 0x61, 0xec, 0x2d, 0xa4, 0x9d, - 0xb9, 0xdb, 0xd4, 0x2e, 0xa4, 0xff, 0xe6, 0x09, 0x8e, 0x9a, 0x0b, 0xdf, - 0x30, 0xf4, 0xea, 0xe6, 0x09, 0x2d, 0x16, 0xec, 0x5d, 0xb6, 0x39, 0xda, - 0xd4, 0x43, 0x41, 0x95, 0xc8, 0x32, 0xd2, 0xac, 0x17, 0x58, 0x05, 0x7e, - 0x10, 0x24, 0xae, 0xd6, 0x58, 0x80, 0x0a, 0x29, 0xcb, 0x4b, 0x78, 0x62, - 0xbe, 0xf6, 0x6c, 0x7d, 0x44, 0x07, 0x7b, 0xfb, 0xd9, 0xb6, 0x74, 0x75, - 0x9f, 0xe6, 0xb4, 0xc3, 0x54, 0x53, 0x46, 0xdb, 0xf2, 0xa1, 0x47, 0xb8, - 0xf1, 0xff, 0x01, 0xba, 0xde, 0xf9, 0xca, 0xd6, 0xc0, 0x38, 0x40, 0xd0, - 0x86, 0x72, 0xc8, 0xcf, 0xdd, 0xd6, 0xf6, 0x36, 0xe0, 0xf3, 0x2e, 0x95, - 0x23, 0xdb, 0x99, 0x72, 0x1c, 0xa6, 0xa0, 0xf7, 0x17, 0x93, 0x1e, 0x20, - 0x6c, 0x75, 0x25, 0x0e, 0x1c, 0xab, 0xa4, 0xce, 0x35, 0xd4, 0xf4, 0xc6, - 0x63, 0x89, 0x0d, 0xa9, 0x91, 0x0b, 0x7e, 0xce, 0x7d, 0x4d, 0x5e, 0xbf, - 0xfd, 0xa9, 0xbd, 0xb6, 0x1e, 0x2b, 0x08, 0x5d, 0x7a, 0xdd, 0x61, 0xdf, - 0x24, 0xe0, 0xff, 0x4b, 0xeb, 0xa2, 0xeb, 0xb6, 0x2f, 0x2c, 0xbf, 0x9b, - 0x5f, 0x6a, 0x1a, 0x33, 0xae, 0x02, 0x6d, 0xbf, 0x8e, 0x6f, 0xeb, 0xda, - 0x17, 0x88, 0x79, 0xf8, 0xe0, 0x1c, 0x1d, 0x64, 0x7c, 0xc3, 0xc2, 0xc1, - 0xce, 0x12, 0x67, 0x74, 0xbc, 0xef, 0x9f, 0x2f, 0x35, 0xef, 0x40, 0x24, - 0xc1, 0x5f, 0x82, 0xc9, 0x00, 0x2c, 0xbf, 0x2b, 0xbf, 0x64, 0x39, 0x63, - 0x0c, 0xcd, 0x6f, 0xe5, 0x18, 0xfa, 0xeb, 0x02, 0x61, 0xc3, 0x22, 0xb0, - 0xfd, 0xfb, 0x9d, 0x32, 0xbb, 0x2f, 0xd8, 0x9d, 0x63, 0x8d, 0x9e, 0xb1, - 0x5a, 0x7d, 0xc3, 0xe5, 0xcc, 0xae, 0xe4, 0x71, 0x0d, 0x7b, 0xa9, 0x80, - 0xbd, 0xef, 0x86, 0x12, 0x13, 0x42, 0xf8, 0x14, 0xcb, 0xa9, 0xfd, 0x86, - 0xea, 0xd5, 0x81, 0x54, 0x87, 0xbc, 0x66, 0x24, 0xfd, 0x12, 0xf5, 0x6b, - 0xf4, 0x45, 0x7e, 0x41, 0x8e, 0x00, 0xf3, 0x63, 0xc7, 0x24, 0xc5, 0xa1, - 0xfd, 0x70, 0xbc, 0xfa, 0x26, 0xa0, 0x07, 0xc2, 0x97, 0xb1, 0xe0, 0x42, - 0x5a, 0x2b, 0x65, 0xec, 0xda, 0x45, 0x79, 0xe5, 0x18, 0xf2, 0xce, 0x8e, - 0xc3, 0xe3, 0x74, 0x6d, 0x30, 0x82, 0x01, 0x49, 0x06, 0x09, 0x2a, 0x86, - 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x07, 0x01, 0xa0, 0x82, 0x01, 0x3a, 0x04, - 0x82, 0x01, 0x36, 0x30, 0x82, 0x01, 0x32, 0x30, 0x82, 0x01, 0x2e, 0x06, - 0x0b, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02, - 0xa0, 0x81, 0xf7, 0x30, 0x81, 0xf4, 0x30, 0x5f, 0x06, 0x09, 0x2a, 0x86, - 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0d, 0x30, 0x52, 0x30, 0x31, 0x06, - 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x05, 0x0c, 0x30, 0x24, - 0x04, 0x10, 0xd8, 0x91, 0xb9, 0x92, 0x3a, 0x1e, 0x6b, 0xa2, 0x57, 0x44, - 0x16, 0x3a, 0xea, 0x36, 0x8d, 0x60, 0x02, 0x02, 0x08, 0x00, 0x30, 0x0c, - 0x06, 0x08, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x02, 0x09, 0x05, 0x00, - 0x30, 0x1d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, - 0x2a, 0x04, 0x10, 0x35, 0x48, 0x03, 0x23, 0x6a, 0x3d, 0x5f, 0x96, 0xb7, - 0xfb, 0xb1, 0xcb, 0xdc, 0x0c, 0x1f, 0x79, 0x04, 0x81, 0x90, 0x4d, 0x36, - 0x5b, 0x1e, 0xc1, 0xc1, 0xa3, 0xfa, 0x85, 0xe6, 0x58, 0x89, 0x5d, 0xbe, - 0x17, 0xb4, 0x90, 0x96, 0x2d, 0x3d, 0x32, 0xe2, 0x73, 0x6c, 0x2c, 0xfd, - 0x7b, 0xe4, 0x9c, 0x82, 0x3c, 0xe4, 0x7a, 0x58, 0x45, 0xdd, 0xcd, 0x00, - 0xa2, 0x52, 0xf9, 0x98, 0xd4, 0x0f, 0x64, 0x0b, 0x58, 0x27, 0xfe, 0xfc, - 0xfd, 0x60, 0x3f, 0x7e, 0x6f, 0x73, 0x73, 0xda, 0x16, 0x34, 0x83, 0x38, - 0x89, 0x3d, 0xbe, 0xde, 0x0a, 0x47, 0x7b, 0xcd, 0xc1, 0x88, 0x72, 0x38, - 0x95, 0xb5, 0x54, 0xfa, 0x2a, 0x84, 0x2b, 0xcd, 0x98, 0xe3, 0x20, 0xd2, - 0xac, 0xd8, 0xf5, 0xa8, 0xd2, 0xef, 0x1f, 0x99, 0x88, 0x16, 0x6f, 0xf2, - 0x08, 0xe9, 0x28, 0xc8, 0x7a, 0x02, 0x57, 0x5c, 0x1c, 0x94, 0x0f, 0x57, - 0xfc, 0x0e, 0x85, 0x63, 0xc5, 0x44, 0x45, 0x4f, 0xc6, 0x35, 0x52, 0x3d, - 0xd4, 0xe2, 0x22, 0x6f, 0x72, 0xde, 0x55, 0xad, 0x20, 0x84, 0x91, 0x14, - 0x7e, 0x5f, 0x98, 0x2a, 0x5e, 0x2b, 0xf4, 0xc5, 0x10, 0x1b, 0x31, 0x25, - 0x30, 0x23, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x09, - 0x15, 0x31, 0x16, 0x04, 0x14, 0xbd, 0x8a, 0x08, 0x3c, 0x66, 0xf7, 0x9d, - 0xa0, 0x25, 0x8b, 0x33, 0xac, 0xc2, 0x5e, 0xdc, 0x76, 0xd4, 0x33, 0x1a, - 0xd6, 0x30, 0x41, 0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, - 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, 0x00, 0x04, 0x20, 0x4e, 0x6f, - 0x75, 0x90, 0x57, 0xb4, 0x39, 0x1e, 0xc3, 0x97, 0x50, 0x5e, 0x4d, 0xf2, - 0xa8, 0xf9, 0x0e, 0xcd, 0x7f, 0x57, 0x31, 0xae, 0xf2, 0x87, 0x0a, 0x55, - 0xb0, 0xbc, 0xd6, 0x11, 0x83, 0xc3, 0x04, 0x08, 0x52, 0x79, 0xb1, 0x83, - 0xe6, 0xe4, 0x6e, 0x4d, 0x02, 0x02, 0x08, 0x00, -}}; - -std::string PollHammerOnce(bool with_recorded_error, bool with_live_objects) { - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - - if (with_recorded_error) { - // Not a certificate at all: PKCS12 parsing fails before any network - // I/O, so this is refused synchronously and leaves no listener behind, - // and `runtime->error` is non-empty for the rest of this runtime's life. - const uint8_t junk[] = {0x00, 0x01, 0x02, 0x03}; - uint64_t listener_id = 0; - uint16_t bound_port = 0; - const int32_t result = cultmesh_quic_listener_open( - runtime, nullptr, 0, junk, static_cast(sizeof(junk)), "", &listener_id, &bound_port); - if (result >= 0) { - cultmesh_quic_runtime_close(runtime); - return "cultmesh_quic_listener_open accepted a junk PKCS12 credential"; - } - } - - // Self's ruling of 2026-09-22, F3: the predicate covered by the hammer set - // above is only ever exercised on a runtime with nothing live in it. An - // unroutable address (RFC 5737 TEST-NET-1) gets a connection into - // `runtime->connections` at once — `cultmesh_quic_connection_open` inserts - // it before the handshake starts — and that handshake then never completes - // inside this scenario's poll window, so it queues no event of its own. A - // best-effort stream rides the same connection. The listener is real (a - // credential MsQuic actually loads) but nothing ever connects to it, so it - // never accepts and never queues an event either: every object here is - // live in the runtime's maps without ever waking the idle poll on its own. - uint64_t live_connection_id = 0; - uint64_t live_stream_id = 0; - uint64_t live_listener_id = 0; - if (with_live_objects) { - const int32_t connecting = - cultmesh_quic_connection_open(runtime, "192.0.2.1", 9, &live_connection_id); - if (connecting != 0) { - cultmesh_quic_runtime_close(runtime); - return "cultmesh_quic_connection_open (live-objects setup) returned " + - std::to_string(connecting); - } - // Best-effort: whether this succeeds is not the point of the - // scenario, and P1's predicate names `connections`, not `streams`. - cultmesh_quic_stream_open( - runtime, live_connection_id, CULTMESH_QUIC_STREAM_RELIABLE, &live_stream_id); - uint16_t bound_port = 0; - cultmesh_quic_listener_open(runtime, "127.0.0.1", 0, kSelfSignedPkcs12.data(), - static_cast(kSelfSignedPkcs12.size()), "", &live_listener_id, &bound_port); - } - - std::atomic hammering{true}; - std::thread hammer([runtime, &hammering] { - constexpr uint64_t kBogusId = 0xffffffffffffffffull; - const uint8_t frame[] = {0x00}; - std::array message{}; - while (hammering.load(std::memory_order_relaxed)) { - cultmesh_quic_last_error(runtime, message.data(), static_cast(message.size())); - cultmesh_quic_last_status(runtime); - cultmesh_quic_connection_shutdown(runtime, kBogusId, 0); - cultmesh_quic_stream_shutdown(runtime, kBogusId, 0); - cultmesh_quic_stream_send_frame(runtime, kBogusId, frame, 1, 0); - cultmesh_quic_connection_certificate_complete(runtime, kBogusId, 0); - cultmesh_quic_listener_close(runtime, kBogusId); - std::this_thread::yield(); - } - }); - - const TimedPoll poll = PollFor(runtime, kHammerPollMs); - hammering.store(false, std::memory_order_relaxed); - hammer.join(); - if (live_listener_id != 0) cultmesh_quic_listener_close(runtime, live_listener_id); - if (live_connection_id != 0) cultmesh_quic_connection_shutdown(runtime, live_connection_id, 0); - (void)live_stream_id; - cultmesh_quic_runtime_close(runtime); - - if (poll.result != 0) - return "the idle poll returned " + std::to_string(poll.result) + - ", not the 0 an idle runtime owes it"; - if (poll.elapsed_ms < kHammerPollMs - kEarlyToleranceMs) - return "the idle poll returned after " + std::to_string(poll.elapsed_ms) + - " ms while another thread hammered the gate, ending its wait early"; - if (poll.elapsed_ms > static_cast(kHammerPollMs) + kGenerousLateToleranceMs) - return "the idle poll returned after " + std::to_string(poll.elapsed_ms) + - " ms: well past its own timeout"; - return {}; -} - -int PollHammer(int iterations) { - for (int iteration = 0; iteration < iterations; ++iteration) { - std::string failure = PollHammerOnce(false, false); - if (!failure.empty()) - return Fail("pollhammer iteration " + std::to_string(iteration) + " (clean runtime): " + failure); - failure = PollHammerOnce(true, false); - if (!failure.empty()) - return Fail("pollhammer iteration " + std::to_string(iteration) + - " (after a refused call recorded an error): " + failure); - failure = PollHammerOnce(false, true); - if (!failure.empty()) - return Fail("pollhammer iteration " + std::to_string(iteration) + - " (with a live connection, stream and listener): " + failure); - } - std::printf("pollhammer %dx: ok\n", iterations); - return 0; -} - -// Self's ruling of 2026-09-22, F4: any `timeout_ms <= 0` has to return within a -// small, generously-margined bound, and a negative timeout has to be probed -// too — the bridge's own guard (`timeout_ms > 0`) already treats zero and -// negative alike, so 0, -1 and INT32_MIN all take the same fast path and none -// of them may collide with the seam's own "nothing recorded" sentinel. -constexpr std::array kZeroTimeoutProbesMs{0, -1, INT32_MIN}; - -// Generous next to `kEarlyToleranceMs`/`kGenerousLateToleranceMs` because a -// non-positive timeout never enters the condition wait at all, so this is -// bounding a runtime lock acquisition and an empty-queue check, not a -// scheduler wakeup — the margin only has to be well clear of a mistake that -// makes this path sleep or spin, not of ordinary jitter. -constexpr int kZeroTimeoutBoundMs = 50; - -std::string ZeroTimeoutOnce(int32_t timeout_ms) { - void* runtime = nullptr; - const int32_t opened = cultmesh_quic_runtime_open("cultmesh-quic-native-tests", &runtime); - if (opened != 0 || runtime == nullptr) - return "cultmesh_quic_runtime_open returned " + std::to_string(opened); - - const TimedPoll poll = PollFor(runtime, timeout_ms); - cultmesh_quic_runtime_close(runtime); - - if (poll.result != 0) - return "a poll asking for " + std::to_string(timeout_ms) + " ms returned " + - std::to_string(poll.result) + ", not the 0 an idle runtime owes it"; - if (poll.elapsed_ms > kZeroTimeoutBoundMs) - return "a poll asking for " + std::to_string(timeout_ms) + " ms returned after " + - std::to_string(poll.elapsed_ms) + " ms: a non-positive timeout must return within " + - std::to_string(kZeroTimeoutBoundMs) + " ms, not stall or spin"; - return {}; -} - -int ZeroTimeout(int iterations) { - for (int iteration = 0; iteration < iterations; ++iteration) { - for (int32_t probe : kZeroTimeoutProbesMs) { - const std::string failure = ZeroTimeoutOnce(probe); - if (!failure.empty()) - return Fail("zerotimeout iteration " + std::to_string(iteration) + " probe " + - std::to_string(probe) + ": " + failure); - } - } - std::printf("zerotimeout %dx: ok (%zu probes)\n", iterations, kZeroTimeoutProbesMs.size()); - return 0; -} - -int CloseRace(int iterations, int pollers) { - for (int iteration = 0; iteration < iterations; ++iteration) { - const std::string failure = CloseRaceOnce(pollers); - if (!failure.empty()) - return Fail("closerace iteration " + std::to_string(iteration) + ": " + failure); - } - std::printf("closerace %dx%d: ok\n", iterations, pollers); - return 0; -} - -} // namespace - -int main(int argc, char** argv) { - if (argc < 2) { - std::fprintf(stderr, - "usage: %s closerace|holdclose|holdtimeout|polltimeout|pollbusy|pollhammer|zerotimeout|" - "payloadfit|latecall|waitseam [iterations] [pollers]\n", - argv[0]); - return 2; - } - const std::string scenario = argv[1]; - const int iterations = argc > 2 ? std::atoi(argv[2]) : 20; - const int pollers = argc > 3 ? std::atoi(argv[3]) : 256; - if (iterations <= 0 || pollers <= 0) { - std::fprintf(stderr, "%s needs a positive iteration and poller count\n", scenario.c_str()); - return 2; - } - if (scenario == "closerace") return CloseRace(iterations, pollers); - if (scenario == "polltimeout") return PollTimeout(iterations); - if (scenario == "pollbusy") return PollBusy(iterations); - if (scenario == "pollhammer") return PollHammer(iterations); - if (scenario == "zerotimeout") return ZeroTimeout(iterations); - if (scenario == "payloadfit") return PayloadFit(iterations); - if (scenario == "holdclose" || scenario == "latecall" || scenario == "holdtimeout" || - scenario == "waitseam") { -#if defined(CULTMESH_QUIC_DEBUG_ASSERTS) - if (scenario == "latecall") return LateCall(iterations); - if (scenario == "holdtimeout") return HoldTimeout(iterations); - if (scenario == "waitseam") return WaitSeam(iterations); - return HoldClose(iterations, pollers); -#else - std::fprintf(stderr, - "%s needs the development seam: configure with " - "-DCULTMESH_QUIC_DEBUG_ASSERTS=ON\n", scenario.c_str()); - return 2; -#endif - } - std::fprintf(stderr, "unknown scenario '%s'\n", scenario.c_str()); - return 2; -} diff --git a/package-lock.json b/package-lock.json index 5c0a5591..f027ecd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -489,326 +489,6 @@ "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", @@ -1035,38 +715,6 @@ "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", @@ -1219,8 +867,7 @@ "dependencies": { "@gamecult/cultcache-ts": "^0.14.0", "@msgpack/msgpack": "^3.1.2", - "cultnet-ts": "^0.2.0", - "koffi": "3.3.0" + "cultnet-ts": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultmath/README.md b/packages/cultmath/README.md index 13f17973..0be46738 100644 --- a/packages/cultmath/README.md +++ b/packages/cultmath/README.md @@ -26,13 +26,13 @@ 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`, `erf`, - `erfinv` (scalar only), exponential + `cross`, `length`, `distance`, `normalize`, `reflect`, `csum`, 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 parity. HLSL already owns `float2`, `float3`, and `float4`; the include - exposes `cultmath_*` functions for shared semantics HLSL does not provide. + exposes `cultmath_*` functions for shared semantics, including CultMath's + safe `normalize` contract. - `Voronoi.SampleTones`, a C# batch surface that calls the Rust `cultmath-core` native kernel when `cultmath_core` is available and falls back to the managed parity path otherwise. @@ -59,7 +59,7 @@ float grain = math.value_noise_bicubic(math.float2(12.0f, 3.5f)); ```hlsl #include "CultMath.hlsl" -float3 normal = normalize(float3(0.25, 1.0, -0.1)); +float3 normal = cultmath_normalize(float3(0.25, 1.0, -0.1)); float rim = cultmath_saturate(1.0 - dot(normal, float3(0.0, 0.0, -1.0))); float glow = cultmath_smoothstep(0.2, 1.0, rim); float grain = cultmath_value_noise_bicubic(float2(12.0, 3.5)); diff --git a/packages/cultmath/docs/design.md b/packages/cultmath/docs/design.md index f5f5d5a5..de09ee26 100644 --- a/packages/cultmath/docs/design.md +++ b/packages/cultmath/docs/design.md @@ -52,26 +52,7 @@ That makes these HLSL rules, not options: operand returns the other (so `saturate(NaN)` is 0), `clamp(x, a, b)` is `min(max(x, a), b)`, and `saturate(x)` is `min(1, max(0, x))`, which maps -0 to +0; `any`/`all` accept numeric vectors (a component is true when it is - not 0); `min`, `max`, and `abs` on int vectors return int vectors; int - `clamp` is `IMin(IMax(x, a), b)`, so inverted bounds return `b`. -- Same-size vector conversions (`intN(floatN)`, `floatN(intN)`, `boolN(floatN)`, - `boolN(intN)`, `floatN(boolN)`, `intN(boolN)`) exist as constructors and - `math` functions and follow dxc: float to int is `fptosi`, truncating toward - zero; numeric to bool is `!= 0` (`fcmp une`, so NaN is true); bool to numeric - is 0 or 1. DXIL leaves float-to-int undefined for NaN and out-of-range values, - and so does C#; CultMath does not pin them. -- `normalize(x)` is `x * Rsqrt(Dot(x, x))`, which is how dxc lowers it, and - DXIL.rst defines `Rsqrt` as `1 / sqrt(src)`. A zero vector therefore - normalizes to NaN (`0 * inf`), and a NaN component makes every component NaN. - (The special-value table under `Rsqrt` in DXIL.rst is a copy of the `Round` - table and contradicts that definition, so CultMath follows the definition.) - `length` and `distance` are `Sqrt` of the summed squares, so a zero vector - gives 0. Callers that can meet zero-length vectors guard them before calling - `normalize`. -- dxc demotes double `frac`, `exp`, `lerp`, and `floor` to float. CultMath's - double overloads keep double precision instead: `frac(double)` is - `x - floor(x)` in double. That is a deliberate divergence for CPU simulation - time, which shaders do not carry. + not 0); `min`, `max`, and `abs` on int vectors return int vectors. dxc marks float arithmetic and compares `fast` (no NaNs) unless a value is `precise`, so drivers may optimize NaN handling away and NaN results on real @@ -83,7 +64,9 @@ GPU. Because `min`, `max`, and `saturate` return the non-NaN operand, some functions turn NaN or infinity into numbers: `smoothstep(a, a, a)` is 0; infinite inputs to `smoothstep` and the intercept helpers give numbers; the bezier, `smoothstep01`, and `smootherstep` functions return finite values for -NaN input. Callers that must detect bad data should check `isnan` before calling these +NaN input; and `normalize((NaN, 2.41))` is `(NaN, 2.41e20)`, because the NaN +length is clamped to the epsilon and the other component is divided by it. +Callers that must detect bad data should check `isnan` before calling these functions. Deferred until a consumer needs them: `float4x4`, `transpose`, `determinant`, @@ -120,20 +103,9 @@ compiled under `using static CultMath.math;`, so its intrinsics are C# `math` itself: the test proves the composition in the text, not the intrinsic rules, which only `HlslSemanticsTests` pins. -Integer hashing uses the PCG hashes from Jarzynski and Olano, "Hash Functions -for GPU Rendering" (JCGT 9(3), 2020): `pcg(uint)` is O'Neill's RXS-M-XS 32/32 -permutation over one LCG step, and `pcg3d`/`pcg4d` are the paper's (3 → 3) and -(4 → 4) hashes. They use only uint multiply, add, xor, and shifts. dxc wraps -uint multiply and add modulo 2^32 exactly as C# unchecked arithmetic does, so -they are integer-exact in HLSL and on the CPU, GPU included, unlike the -`sin`-based `hash`. CultMath has no uint vectors, so `pcg3d(int3)` and -`pcg4d(int4)` carry uint bit patterns in int components. The `float2`/`float3`/`float4` -overloads hash the IEEE-754 bits (`asuint`), so -0 and 0 hash differently. -`pcg3d(float2)` holds z at 0, which is the paper's route for (2 → N) inputs. -Seeds and other scalar uses take one output component. - Where HLSL is silent, CultMath keeps its own decisions and does not defer to -Unity.Mathematics: `hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped +Unity.Mathematics: `normalize` is safe (divides by `max(length, 1e-20)`), +`hash` returns float, and `Random` is CultMath's own xorshift32. Engine-shaped helpers that HLSL lacks (`float2x2.Rotate`, `float3x3.Euler`, `quaternion`, `snoise`) enter only when a consumer needs them, with their semantics stated in code. diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 index 39e55813..b64e9446 100644 --- a/packages/cultmath/scripts/build-unity-package.ps1 +++ b/packages/cultmath/scripts/build-unity-package.ps1 @@ -27,25 +27,6 @@ if (Test-Path -LiteralPath $outputRoot) { Remove-Item -LiteralPath $outputRoot -Recurse -Force } -# docs/semver-policy.md (CultLib root, two levels up from this script): -# refuse to build a release whose CHANGELOG.md entry claims a breaking change -# under too small a bump, or whose version skips or reverses the previous -# published tag. Runs before the build below so a bad version number fails -# fast. cultmath is pre-1.0 (0.y.z): a breaking change must be at least a -# minor bump. $repoRoot above is this package's own root (packages\cultmath), -# not CultLib's; the shared checker script lives one level above that. -$cultLibRoot = Split-Path -Parent (Split-Path -Parent $repoRoot) -$cultmathVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version -& node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") ` - --package "org.gamecult.cultmath" ` - --changelog (Join-Path $templateRoot "CHANGELOG.md") ` - --version $cultmathVersion ` - --tag-prefix "cultmath-unity" ` - --cwd $cultLibRoot -if ($LASTEXITCODE -ne 0) { - throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)." -} - # The tracked DLL and pdb are committed beside their source, so neither may name a commit: Source Link # writes the commit SHA into the pdb, and the DLL carries that pdb's content id. dotnet build $projectPath -c $Configuration -f netstandard2.1 -o $buildRoot --no-incremental ` diff --git a/packages/cultmath/shaders/CultMath.hlsl b/packages/cultmath/shaders/CultMath.hlsl index 6179d45a..365705f5 100644 --- a/packages/cultmath/shaders/CultMath.hlsl +++ b/packages/cultmath/shaders/CultMath.hlsl @@ -11,6 +11,7 @@ static const float CULTMATH_PI = 3.14159265358979323846; static const float CULTMATH_TAU = 6.28318530717958647692; static const float CULTMATH_HALF_PI = 1.57079632679489661923; +static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20; float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } @@ -170,6 +171,10 @@ float cultmath_distance(float2 left, float2 right) { return length(left - right) float cultmath_distance(float3 left, float3 right) { return length(left - right); } float cultmath_distance(float4 left, float4 right) { return length(left - right); } +float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } + float2 cultmath_reflect(float2 incident, float2 normal_value) { return incident - 2.0 * dot(normal_value, incident) * normal_value; @@ -281,38 +286,6 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453) float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); } float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); } -// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits. -uint cultmath_pcg(uint value) -{ - uint state = value * 747796405u + 2891336453u; - uint word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u; - return (word >> 22) ^ word; -} - -int3 cultmath_pcg3d(int3 value) -{ - uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u; - x += y * z; y += z * x; z += x * y; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; - x += y * z; y += z * x; z += x * y; - return int3((int)x, (int)y, (int)z); -} - -int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); } -int3 cultmath_pcg3d(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); } - -int4 cultmath_pcg4d(int4 value) -{ - uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u; - uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u; - x += y * w; y += z * x; z += x * y; w += y * z; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; - x += y * w; y += z * x; z += x * y; w += y * z; - return int4((int)x, (int)y, (int)z, (int)w); -} - -int4 cultmath_pcg4d(float4 value) { return cultmath_pcg4d(int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); } - float cultmath_value_noise(float2 position) { float2 cell = floor(position); diff --git a/packages/cultmath/src/CultMath/CultMath.csproj b/packages/cultmath/src/CultMath/CultMath.csproj index 8474fc05..7c567105 100644 --- a/packages/cultmath/src/CultMath/CultMath.csproj +++ b/packages/cultmath/src/CultMath/CultMath.csproj @@ -8,7 +8,7 @@ CultMath CultMath GameCult.Math - 0.2.3 + 0.2.0 Clean-room C# math shaped for HLSL-style renderer code. GameCult README.md diff --git a/packages/cultmath/src/CultMath/Swizzles.g.cs b/packages/cultmath/src/CultMath/Swizzles.g.cs index db1c56bd..ce84bbc4 100644 --- a/packages/cultmath/src/CultMath/Swizzles.g.cs +++ b/packages/cultmath/src/CultMath/Swizzles.g.cs @@ -7,8 +7,6 @@ namespace CultMath; public partial struct float2 { public float2(float2 xy) : this(xy.x, xy.y) { } - public float2(int2 value) : this(value.x, value.y) { } - public float2(bool2 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f) { } public float r { readonly get => x; set => x = value; } public float g { readonly get => y; set => y = value; } public readonly float2 xx => new(x, x); @@ -74,8 +72,6 @@ public partial struct float3 public float3(float x, float2 yz) : this(x, yz.x, yz.y) { } public float3(float2 xy, float z) : this(xy.x, xy.y, z) { } public float3(float3 xyz) : this(xyz.x, xyz.y, xyz.z) { } - public float3(int3 value) : this(value.x, value.y, value.z) { } - public float3(bool3 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f, value.z ? 1.0f : 0.0f) { } public float r { readonly get => x; set => x = value; } public float g { readonly get => y; set => y = value; } public float b { readonly get => z; set => z = value; } @@ -324,8 +320,6 @@ public partial struct float4 public float4(float2 xy, float2 zw) : this(xy.x, xy.y, zw.x, zw.y) { } public float4(float3 xyz, float w) : this(xyz.x, xyz.y, xyz.z, w) { } public float4(float4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { } - public float4(int4 value) : this(value.x, value.y, value.z, value.w) { } - public float4(bool4 value) : this(value.x ? 1.0f : 0.0f, value.y ? 1.0f : 0.0f, value.z ? 1.0f : 0.0f, value.w ? 1.0f : 0.0f) { } public float r { readonly get => x; set => x = value; } public float g { readonly get => y; set => y = value; } public float b { readonly get => z; set => z = value; } @@ -1007,8 +1001,6 @@ public partial struct float4 public partial struct int2 { public int2(int2 xy) : this(xy.x, xy.y) { } - public int2(float2 value) : this((int)value.x, (int)value.y) { } - public int2(bool2 value) : this(value.x ? 1 : 0, value.y ? 1 : 0) { } public int r { readonly get => x; set => x = value; } public int g { readonly get => y; set => y = value; } public readonly int2 xx => new(x, x); @@ -1074,8 +1066,6 @@ public partial struct int3 public int3(int x, int2 yz) : this(x, yz.x, yz.y) { } public int3(int2 xy, int z) : this(xy.x, xy.y, z) { } public int3(int3 xyz) : this(xyz.x, xyz.y, xyz.z) { } - public int3(float3 value) : this((int)value.x, (int)value.y, (int)value.z) { } - public int3(bool3 value) : this(value.x ? 1 : 0, value.y ? 1 : 0, value.z ? 1 : 0) { } public int r { readonly get => x; set => x = value; } public int g { readonly get => y; set => y = value; } public int b { readonly get => z; set => z = value; } @@ -1324,8 +1314,6 @@ public partial struct int4 public int4(int2 xy, int2 zw) : this(xy.x, xy.y, zw.x, zw.y) { } public int4(int3 xyz, int w) : this(xyz.x, xyz.y, xyz.z, w) { } public int4(int4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { } - public int4(float4 value) : this((int)value.x, (int)value.y, (int)value.z, (int)value.w) { } - public int4(bool4 value) : this(value.x ? 1 : 0, value.y ? 1 : 0, value.z ? 1 : 0, value.w ? 1 : 0) { } public int r { readonly get => x; set => x = value; } public int g { readonly get => y; set => y = value; } public int b { readonly get => z; set => z = value; } @@ -2007,8 +1995,6 @@ public partial struct int4 public partial struct bool2 { public bool2(bool2 xy) : this(xy.x, xy.y) { } - public bool2(float2 value) : this(value.x != 0.0f, value.y != 0.0f) { } - public bool2(int2 value) : this(value.x != 0, value.y != 0) { } public bool r { readonly get => x; set => x = value; } public bool g { readonly get => y; set => y = value; } public readonly bool2 xx => new(x, x); @@ -2074,8 +2060,6 @@ public partial struct bool3 public bool3(bool x, bool2 yz) : this(x, yz.x, yz.y) { } public bool3(bool2 xy, bool z) : this(xy.x, xy.y, z) { } public bool3(bool3 xyz) : this(xyz.x, xyz.y, xyz.z) { } - public bool3(float3 value) : this(value.x != 0.0f, value.y != 0.0f, value.z != 0.0f) { } - public bool3(int3 value) : this(value.x != 0, value.y != 0, value.z != 0) { } public bool r { readonly get => x; set => x = value; } public bool g { readonly get => y; set => y = value; } public bool b { readonly get => z; set => z = value; } @@ -2324,8 +2308,6 @@ public partial struct bool4 public bool4(bool2 xy, bool2 zw) : this(xy.x, xy.y, zw.x, zw.y) { } public bool4(bool3 xyz, bool w) : this(xyz.x, xyz.y, xyz.z, w) { } public bool4(bool4 xyzw) : this(xyzw.x, xyzw.y, xyzw.z, xyzw.w) { } - public bool4(float4 value) : this(value.x != 0.0f, value.y != 0.0f, value.z != 0.0f, value.w != 0.0f) { } - public bool4(int4 value) : this(value.x != 0, value.y != 0, value.z != 0, value.w != 0) { } public bool r { readonly get => x; set => x = value; } public bool g { readonly get => y; set => y = value; } public bool b { readonly get => z; set => z = value; } @@ -3120,15 +3102,9 @@ public partial struct double3 public static partial class math { public static float2 float2(float2 xy) => new(xy); - public static float2 float2(int2 value) => new(value); - public static float2 float2(bool2 value) => new(value); - public static float2 float2(int value) => new(value, value); public static float3 float3(float x, float2 yz) => new(x, yz); public static float3 float3(float2 xy, float z) => new(xy, z); public static float3 float3(float3 xyz) => new(xyz); - public static float3 float3(int3 value) => new(value); - public static float3 float3(bool3 value) => new(value); - public static float3 float3(int value) => new(value, value, value); public static float4 float4(float x, float y, float2 zw) => new(x, y, zw); public static float4 float4(float x, float2 yz, float w) => new(x, yz, w); public static float4 float4(float x, float3 yzw) => new(x, yzw); @@ -3136,17 +3112,10 @@ public static partial class math public static float4 float4(float2 xy, float2 zw) => new(xy, zw); public static float4 float4(float3 xyz, float w) => new(xyz, w); public static float4 float4(float4 xyzw) => new(xyzw); - public static float4 float4(int4 value) => new(value); - public static float4 float4(bool4 value) => new(value); - public static float4 float4(int value) => new(value, value, value, value); public static int2 int2(int2 xy) => new(xy); - public static int2 int2(float2 value) => new(value); - public static int2 int2(bool2 value) => new(value); public static int3 int3(int x, int2 yz) => new(x, yz); public static int3 int3(int2 xy, int z) => new(xy, z); public static int3 int3(int3 xyz) => new(xyz); - public static int3 int3(float3 value) => new(value); - public static int3 int3(bool3 value) => new(value); public static int4 int4(int x, int y, int2 zw) => new(x, y, zw); public static int4 int4(int x, int2 yz, int w) => new(x, yz, w); public static int4 int4(int x, int3 yzw) => new(x, yzw); @@ -3154,16 +3123,10 @@ public static partial class math public static int4 int4(int2 xy, int2 zw) => new(xy, zw); public static int4 int4(int3 xyz, int w) => new(xyz, w); public static int4 int4(int4 xyzw) => new(xyzw); - public static int4 int4(float4 value) => new(value); - public static int4 int4(bool4 value) => new(value); public static bool2 bool2(bool2 xy) => new(xy); - public static bool2 bool2(float2 value) => new(value); - public static bool2 bool2(int2 value) => new(value); public static bool3 bool3(bool x, bool2 yz) => new(x, yz); public static bool3 bool3(bool2 xy, bool z) => new(xy, z); public static bool3 bool3(bool3 xyz) => new(xyz); - public static bool3 bool3(float3 value) => new(value); - public static bool3 bool3(int3 value) => new(value); public static bool4 bool4(bool x, bool y, bool2 zw) => new(x, y, zw); public static bool4 bool4(bool x, bool2 yz, bool w) => new(x, yz, w); public static bool4 bool4(bool x, bool3 yzw) => new(x, yzw); @@ -3171,6 +3134,4 @@ public static partial class math public static bool4 bool4(bool2 xy, bool2 zw) => new(xy, zw); public static bool4 bool4(bool3 xyz, bool w) => new(xyz, w); public static bool4 bool4(bool4 xyzw) => new(xyzw); - public static bool4 bool4(float4 value) => new(value); - public static bool4 bool4(int4 value) => new(value); } diff --git a/packages/cultmath/src/CultMath/math.cs b/packages/cultmath/src/CultMath/math.cs index b5d5339c..ca74857f 100644 --- a/packages/cultmath/src/CultMath/math.cs +++ b/packages/cultmath/src/CultMath/math.cs @@ -105,9 +105,6 @@ public static partial class math public static float2 frac(float2 value) => value - floor(value); public static float3 frac(float3 value) => value - floor(value); public static float4 frac(float4 value) => value - floor(value); - // dxc has no double Frc: it demotes to float, as it does for exp and lerp. CultMath's double - // overloads keep double precision instead, so frac(double) is x - floor(x) in double. - public static double frac(double value) => value - Math.Floor(value); // dxc lowers float min/max/clamp to DXIL FMin/FMax (saturate to Saturate, defined as FMin(1, FMax(0, x))). // DXIL.rst: FMin(a, b) is a < b ? a : b, FMax(a, b) is a >= b ? a : b, and a NaN operand returns the other. @@ -133,11 +130,6 @@ public static partial class math public static float2 clamp(float2 value, float2 minimum, float2 maximum) => min(max(value, minimum), maximum); public static float3 clamp(float3 value, float3 minimum, float3 maximum) => min(max(value, minimum), maximum); public static float4 clamp(float4 value, float4 minimum, float4 maximum) => min(max(value, minimum), maximum); - // dxc lowers int clamp to IMin(IMax(x, a), b): inverted bounds return b rather than throwing like Math.Clamp. - public static int clamp(int value, int minimum, int maximum) => min(max(value, minimum), maximum); - public static int2 clamp(int2 value, int2 minimum, int2 maximum) => min(max(value, minimum), maximum); - public static int3 clamp(int3 value, int3 minimum, int3 maximum) => min(max(value, minimum), maximum); - public static int4 clamp(int4 value, int4 minimum, int4 maximum) => min(max(value, minimum), maximum); // DXIL Saturate is FMin(1, FMax(0, x)); that operand order, unlike clamp(x, 0, 1), maps -0 to +0. public static float saturate(float value) => min(1.0f, max(0.0f, value)); @@ -213,10 +205,9 @@ public static partial class math public static float distance(float3 left, float3 right) => length(left - right); public static float distance(float4 left, float4 right) => length(left - right); - // dxc lowers normalize(x) to x * Rsqrt(Dot(x, x)), and DXIL Rsqrt is 1 / sqrt(src): a zero vector is 0 * inf = NaN. - public static float2 normalize(float2 value) => value * (1.0f / MathF.Sqrt(dot(value, value))); - public static float3 normalize(float3 value) => value * (1.0f / MathF.Sqrt(dot(value, value))); - public static float4 normalize(float4 value) => value * (1.0f / MathF.Sqrt(dot(value, value))); + public static float2 normalize(float2 value) => value / max(length(value), 1.0e-20f); + public static float3 normalize(float3 value) => value / max(length(value), 1.0e-20f); + public static float4 normalize(float4 value) => value / max(length(value), 1.0e-20f); public static quaternion normalize(quaternion value) { var length = MathF.Sqrt(value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w); @@ -382,43 +373,6 @@ public static partial class math public static float hash(float2 value) => hash(dot(value, new float2(127.1f, 311.7f))); public static float hash(float3 value) => hash(dot(value, new float3(127.1f, 311.7f, 74.7f))); - public static uint asuint(float value) => (uint)BitConverter.SingleToInt32Bits(value); - - // PCG hashes from Jarzynski and Olano, "Hash Functions for GPU Rendering" (JCGT 9(3), 2020). Integer-only, so - // dxc and C# agree bit for bit. pcg3d/pcg4d carry uint bit patterns in int3/int4; float overloads hash IEEE bits. - public static uint pcg(uint value) - { - var state = value * 747796405u + 2891336453u; - var word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u; - return (word >> 22) ^ word; - } - - public static int3 pcg3d(int3 value) - { - uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u; - x += y * z; y += z * x; z += x * y; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; - x += y * z; y += z * x; z += x * y; - return new int3((int)x, (int)y, (int)z); - } - - // The paper hashes 2 -> N inputs through pcg3d with the unused component held constant. - public static int3 pcg3d(float2 value) => pcg3d(new float3(value, 0.0f)); - public static int3 pcg3d(float3 value) => pcg3d(new int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); - - public static int4 pcg4d(int4 value) - { - uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u; - uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u; - x += y * w; y += z * x; z += x * y; w += y * z; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; - x += y * w; y += z * x; z += x * y; w += y * z; - return new int4((int)x, (int)y, (int)z, (int)w); - } - - public static int4 pcg4d(float4 value) => - pcg4d(new int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); - public static float log(float value) => MathF.Log(value); public static float2 log(float2 value) => new(log(value.x), log(value.y)); public static float3 log(float3 value) => new(log(value.x), log(value.y), log(value.z)); @@ -521,74 +475,6 @@ public static partial class math a._m20 * b._m01 + a._m21 * b._m11 + a._m22 * b._m21, a._m20 * b._m02 + a._m21 * b._m12 + a._m22 * b._m22); - // Abramowitz & Stegun 7.1.26, a single-precision rational/exponential fit with a stated - // max absolute error of 1.5e-7. erf is odd, so only x >= 0 is fit and the sign is restored. - // Measured against an independent reference: worst absolute error 6.621e-7, inside the - // 2e-6 bar this package's tests pin. - public static float erf(float value) - { - const float p = 0.3275911f; - const float a1 = 0.254829592f; - const float a2 = -0.284496736f; - const float a3 = 1.421413741f; - const float a4 = -1.453152027f; - const float a5 = 1.061405429f; - - var s = sign(value); - var x = abs(value); - var t = 1.0f / (1.0f + p * x); - var poly = ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t; - var result = 1.0f - poly * exp(-x * x); - return s * result; - } - - // Giles, "Approximating the erfinv function" (GPU Computing Gems, 2010): a single-precision - // minimax polynomial in two ranges of w = -ln((1-x)(1+x)), central and tail. - // Domain is [-1, 1]. Outside it, including +-infinity, the result is NaN (there is no real - // inverse). At the edges the mathematical limit is infinite: erfinv(1) = +infinity and - // erfinv(-1) = -infinity. The unguarded polynomial hits log(0) = -infinity at those edges and - // the tail branch's leading coefficient is negative, so it delivers the wrong sign; guard the - // edges explicitly instead of trusting the fit through the singularity. - // Measured against an independent reference: worst absolute error 5.066e-7, and the - // erf(erfinv(y)) round trip 6.109e-7 worst case, both inside the 2e-6 bar this package's - // tests pin. - public static float erfinv(float value) - { - if (value >= 1.0f) return value > 1.0f ? float.NaN : float.PositiveInfinity; - if (value <= -1.0f) return value < -1.0f ? float.NaN : float.NegativeInfinity; - - var w = -log((1.0f - value) * (1.0f + value)); - float p; - if (w < 5.0f) - { - w -= 2.5f; - p = 2.81022636e-08f; - p = 3.43273939e-07f + p * w; - p = -3.5233877e-06f + p * w; - p = -4.39150654e-06f + p * w; - p = 0.00021858087f + p * w; - p = -0.00125372503f + p * w; - p = -0.00417768164f + p * w; - p = 0.246640727f + p * w; - p = 1.50140941f + p * w; - } - else - { - w = sqrt(w) - 3.0f; - p = -0.000200214257f; - p = 0.000100950558f + p * w; - p = 0.00134934322f + p * w; - p = -0.00367342844f + p * w; - p = 0.00573950773f + p * w; - p = -0.0076224613f + p * w; - p = 0.00943887047f + p * w; - p = 1.00167406f + p * w; - p = 2.83297682f + p * w; - } - - return p * value; - } - // Ashima Arts / Ian McEwan 3D simplex noise (MIT), mirrored by cultmath_snoise(float3) // in shaders/CultMath.hlsl with the same float32 evaluation order. public static float snoise(float3 value) diff --git a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs index d73c6d7b..445ad255 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslMirrorTests.cs @@ -31,6 +31,7 @@ public sealed class HlslMirrorTests "cultmath_smootherstep", "cultmath_lengthsq", "cultmath_distance", + "cultmath_normalize", "cultmath_reflect", "cultmath_rotate", "cultmath_csum", @@ -40,9 +41,6 @@ public sealed class HlslMirrorTests "cultmath_quadratic_bezier", "cultmath_cubic_bezier", "cultmath_hash", - "cultmath_pcg(uint", - "cultmath_pcg3d(int3", - "cultmath_pcg4d(int4", "cultmath_value_noise", "cultmath_snoise(float2", "cultmath_snoise(float3", @@ -56,8 +54,8 @@ public sealed class HlslMirrorTests Assert.Contains(symbol, include); } - // HLSL's normalize intrinsic is the contract; a mirror copy would only restate it. - Assert.DoesNotContain("cultmath_normalize", include); + Assert.Contains("CULTMATH_NORMALIZE_EPSILON", include); + Assert.Contains("max(length(value), CULTMATH_NORMALIZE_EPSILON)", include); } private static string GetCultMathRoot() diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs index 954c4375..7b845ea7 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslSemanticsTests.cs @@ -187,33 +187,6 @@ public sealed class HlslSemanticsTests Assert.Equal(DoubleBits(-0.0), DoubleBits(math.max(-0.0, 0.0))); Assert.Equal(DoubleBits(0.0), DoubleBits(math.min(-0.0, 0.0))); - // dxc: normalize(x) is x * Rsqrt(Dot(x, x)); length and distance are Sqrt of the summed squares. - static bool AllNaN(float4 value) => - float.IsNaN(value.x) && float.IsNaN(value.y) && float.IsNaN(value.z) && float.IsNaN(value.w); - var n2 = math.normalize(float2.zero); - Assert.True(float.IsNaN(n2.x) && float.IsNaN(n2.y)); - var n3 = math.normalize(float3.zero); - Assert.True(float.IsNaN(n3.x) && float.IsNaN(n3.y) && float.IsNaN(n3.z)); - Assert.True(AllNaN(math.normalize(float4.zero))); - var nanX = math.normalize(new float2(float.NaN, 2.41f)); - Assert.True(float.IsNaN(nanX.x) && float.IsNaN(nanX.y)); - Assert.True(AllNaN(math.normalize(new float4(1.0f, float.NaN, 2.41f, 1.0f)))); - var skew = new float3(0.25f, 1.0f, -0.1f); - var inverseLength = 1.0f / MathF.Sqrt(skew.x * skew.x + skew.y * skew.y + skew.z * skew.z); - var normalized = math.normalize(skew); - Assert.Equal(Bits(skew.x * inverseLength), Bits(normalized.x)); - Assert.Equal(Bits(skew.y * inverseLength), Bits(normalized.y)); - Assert.Equal(Bits(skew.z * inverseLength), Bits(normalized.z)); - Assert.Equal(Bits(0.0f), Bits(math.length(float2.zero))); - Assert.Equal(Bits(0.0f), Bits(math.length(float3.zero))); - Assert.Equal(Bits(0.0f), Bits(math.length(float4.zero))); - Assert.Equal(Bits(0.0f), Bits(math.distance(new float3(1.0f, 2.0f, 3.0f), new float3(1.0f, 2.0f, 3.0f)))); - - // asuint(float) reinterprets the IEEE-754 bits. - Assert.Equal(0x80000000u, math.asuint(-0.0f)); - Assert.Equal(0x3FC00000u, math.asuint(1.5f)); - Assert.Equal(0x7FC00000u, math.asuint(BitConverter.Int32BitsToSingle(0x7FC00000))); - Assert.Equal(0, math.sign(float.NaN)); Assert.Equal(-1, math.sign(-0.5f)); Assert.Equal(new int3(-1, 0, 1), math.sign(new float3(-2.0f, 0.0f, 4.0f))); @@ -338,43 +311,6 @@ public sealed class HlslSemanticsTests Assert.Equal(new int4(5, 5, 5, 5), math.int4(5)); } - [Fact] - public void VectorConversionsFollowDxc() - { - // fptosi truncates toward zero; NaN and out-of-range inputs are undefined in DXIL and not pinned. - Assert.Equal(new int2(-1, 2), math.int2(new float2(-1.7f, 2.9f))); - Assert.Equal(new int3(0, 0, 3), new int3(new float3(-0.5f, 0.5f, 3.99f))); - Assert.Equal(new float4(1.0f, -2.0f, 3.0f, 0.0f), math.float4(new int4(1, -2, 3, 0))); - // Numeric to bool is `!= 0` (fcmp une for float, so NaN is true); bool to numeric is 0 or 1. - Assert.Equal(new bool2(true, false), math.bool2(new float2(float.NaN, -0.0f))); - Assert.Equal(new bool3(false, true, true), math.bool3(new int3(0, 5, -1))); - Assert.Equal(new float2(1.0f, 0.0f), math.float2(new bool2(true, false))); - Assert.Equal(new int4(0, 1, 1, 0), math.int4(new bool4(false, true, true, false))); - // intN(intN) is exact; it must not promote through float and lose 2^24 + 1. - Assert.Equal(new int2(16777217, -16777217), math.int2(new int2(16777217, -16777217))); - float2 splat = math.float2(0); - Assert.Equal(float2.zero, splat); - } - - [Fact] - public void FracAndIntegerClampFollowHlsl() - { - // frac(double) keeps double precision; a float frac of 1e9 + 0.25 would return 0. - Assert.Equal(0.25, math.frac(1.0e9 + 0.25)); - Assert.Equal(0.75, math.frac(-0.25)); - Assert.Equal(0.5f, math.frac(2.5f)); - - // IMin(IMax(x, a), b): the result stays int, and inverted bounds return b. - int clamped = math.clamp(7, -1, 3); - Assert.Equal(3, clamped); - Assert.Equal(-1, math.clamp(-5, -1, 3)); - Assert.Equal(0, math.clamp(1, 3, 0)); - Assert.Equal(new int2(0, 3), math.clamp(new int2(-4, 9), 0, 3)); - Assert.Equal(new int3(1, 2, 3), math.clamp(new int3(1, 2, 3), new int3(0, 0, 0), new int3(5, 5, 5))); - Assert.Equal(new int4(2, 2, 4, 4), math.clamp(new int4(1, 2, 5, 4), new int4(2), new int4(4))); - Assert.Equal(1.0f, math.clamp(2.0f, -1, 1)); - } - [Fact] public void MatrixMulFollowsHlslRowMajorConvention() { diff --git a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs index 9426e4c8..ca4584c7 100644 --- a/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/HlslSourceCompatibilityTests.cs @@ -78,33 +78,26 @@ public sealed class HlslSourceCompatibilityTests foreach (var input in cases) { var slot = 0; - var args = types.Select(t => Scalars.Contains(t) ? Scalar(t, input(slot++)) - : Activator.CreateInstance(t, Components(t).Select(f => Scalar(f.FieldType, input(slot++))).ToArray())!).ToArray(); - var expected = Values(counterpart.Invoke(null, args)!); - var actual = Values(mirror.Invoke(shader, args)!); - if (expected.Length != actual.Length || expected.Zip(actual).Any(p => p.First is float a && p.Second is float b - ? !(float.IsNaN(a) && float.IsNaN(b)) && BitConverter.SingleToInt32Bits(a) != BitConverter.SingleToInt32Bits(b) - : !p.First.Equals(p.Second))) + var args = types.Select(t => t == typeof(float) ? (object)input(slot++) + : Activator.CreateInstance(t, Enumerable.Range(0, Components(t)).Select(_ => (object)input(slot++)).ToArray())!).ToArray(); + var expected = Floats(counterpart.Invoke(null, args)!); + var actual = Floats(mirror.Invoke(shader, args)!); + if (expected.Length != actual.Length || expected.Zip(actual).Any(p => !(float.IsNaN(p.First) && float.IsNaN(p.Second)) + && BitConverter.SingleToInt32Bits(p.First) != BitConverter.SingleToInt32Bits(p.Second))) { - mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Values(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}"); + mismatches.Add($"{mirror.Name}({string.Join(", ", args.Select(a => string.Join(" ", Floats(a))))}): C# {string.Join(" ", expected)}, mirror {string.Join(" ", actual)}"); } } } Assert.True(mismatches.Count == 0, $"{mismatches.Count} mismatches:{Environment.NewLine}{string.Join(Environment.NewLine, mismatches.Take(12))}"); - Assert.Equal(28, compared.Count); + Assert.Equal(26, compared.Count); } - // Integer arguments take the bit pattern of the float input, so specials become 0, 0x80000000, NaN bits, ... - private static readonly Type[] Scalars = { typeof(float), typeof(int), typeof(uint) }; + private static int Components(Type t) => t.GetFields().Count(f => f.FieldType == typeof(float) && !f.IsStatic); - private static object Scalar(Type t, float f) => t == typeof(float) ? f - : t == typeof(int) ? BitConverter.SingleToInt32Bits(f) : (object)(uint)BitConverter.SingleToInt32Bits(f); - - private static FieldInfo[] Components(Type t) => t.GetFields().Where(f => !f.IsStatic && Scalars.Contains(f.FieldType)).ToArray(); - - private static object[] Values(object value) => Scalars.Contains(value.GetType()) ? new[] { value } - : Components(value.GetType()).Select(field => field.GetValue(value)!).ToArray(); + private static float[] Floats(object value) => value is float f ? new[] { f } + : value.GetType().GetFields().Where(field => field.FieldType == typeof(float) && !field.IsStatic).Select(field => (float)field.GetValue(value)!).ToArray(); /// The documented HLSL-to-C# transformations, and nothing else. internal static string TransformHlslToCSharp(string hlsl) diff --git a/packages/cultmath/tests/CultMath.Tests/MathTests.cs b/packages/cultmath/tests/CultMath.Tests/MathTests.cs index b47a389e..de1bf996 100644 --- a/packages/cultmath/tests/CultMath.Tests/MathTests.cs +++ b/packages/cultmath/tests/CultMath.Tests/MathTests.cs @@ -12,41 +12,6 @@ public sealed class MathTests Assert.Equal(0.75f, math.frac(-1.25f)); } - // Expected values come from a Python transcription of the paper's pcg3d/pcg4d (JCGT 9(3) 2020, p. 32) and - // O'Neill's pcg_output_rxs_m_xs_32_32 with PCG_DEFAULT_MULTIPLIER_32/INCREMENT_32, independent of this code. - [Fact] - public void PcgHashesMatchReferenceTranscription() - { - static int3 U3(uint x, uint y, uint z) => new((int)x, (int)y, (int)z); - static int4 U4(uint x, uint y, uint z, uint w) => new((int)x, (int)y, (int)z, (int)w); - - Assert.Equal(0x07BB2FE2u, math.pcg(0u)); - Assert.Equal(0xA8BEEA3Cu, math.pcg(1u)); - Assert.Equal(0x995312E1u, math.pcg(0x12345678u)); - Assert.Equal(0xE62A4902u, math.pcg(0xFFFFFFFFu)); - Assert.Equal(U3(0x9BAFD7C6u, 0xA8E88A6Bu, 0x3F15482Cu), math.pcg3d(new int3(0, 0, 0))); - Assert.Equal(U3(0xFA9F79A6u, 0x48F2F44Cu, 0x596F5AB1u), math.pcg3d(new int3(1, 2, 3))); - Assert.Equal(U3(0x67DCF282u, 0x49E87E17u, 0x10018917u), math.pcg3d(U3(0xFFFFFFFFu, 0x80000000u, 0x7FFFFFFFu))); - Assert.Equal(U4(0x0F02F829u, 0x2D568769u, 0x32B0C43Bu, 0xD32548EAu), math.pcg4d(new int4(0, 0, 0, 0))); - Assert.Equal(U4(0x3622CD16u, 0xF11471D8u, 0xE1109B3Fu, 0x02B94C2Fu), math.pcg4d(new int4(1, 2, 3, 4))); - Assert.Equal(U4(0x72180037u, 0x81D493E6u, 0xD9BA7226u, 0x14C9ABA7u), math.pcg4d(U4(0xFFFFFFFFu, 0x80000000u, 0x7FFFFFFFu, 0xDEADBEEFu))); - Assert.Equal(U3(0xA7B40FEEu, 0x9B70DFC0u, 0x3106C2C5u), math.pcg3d(new float3(1.5f, -2.25f, 1e7f))); - Assert.Equal(U3(0x5A09E5C6u, 0x53EC0DABu, 0x4BE4A1ECu), math.pcg3d(new float2(3.0f, 4.0f))); - Assert.Equal(U4(0x1849E380u, 0xF199DEE0u, 0x6C6B4934u, 0x34AF16DFu), - math.pcg4d(new float4(-0.0f, BitConverter.Int32BitsToSingle(0x7FC00000), 0.5f, 1e-7f))); - } - - [Fact] - public void Pcg3dSeparatesAPositionGrid() - { - // A single 32-bit component is subject to birthday collisions (x has one here); the full output is distinct. - var hashes = new HashSet(); - for (var i = 0; i < 100; i++) - for (var j = 0; j < 100; j++) - hashes.Add(math.pcg3d(new float2(i * 0.5f - 25.0f, j * 0.5f - 25.0f))); - Assert.Equal(10000, hashes.Count); - } - [Fact] public void VectorOperationsAreComponentWise() { @@ -367,160 +332,4 @@ public sealed class MathTests Assert.Equal(0.0f, px[1], precision: 5); Assert.Equal(0.0f, py[1], precision: 5); } - - // Tabulated to 16 significant figures (Abramowitz & Stegun table 7.1 lineage / standard erf tables). - [Theory] - [InlineData(0.0f, 0.0)] - [InlineData(0.5f, 0.5204998778130465)] - [InlineData(-0.5f, -0.5204998778130465)] - [InlineData(1.0f, 0.8427007929497149)] - [InlineData(-1.0f, -0.8427007929497149)] - [InlineData(2.0f, 0.9953222650189527)] - [InlineData(-2.0f, -0.9953222650189527)] - [InlineData(3.0f, 0.9999779095030014)] - [InlineData(-3.0f, -0.9999779095030014)] - public void ErfMatchesReferenceValues(float x, double expected) - { - Assert.True( - Math.Abs(math.erf(x) - expected) <= 2e-6, - $"erf({x}) = {math.erf(x)}, expected {expected}"); - } - - [Fact] - public void ErfIsOdd() - { - for (var i = -30; i <= 30; i++) - { - var x = i / 10.0f; - Assert.Equal(-math.erf(x), math.erf(-x)); - } - } - - [Fact] - public void ErfApproachesItsLimitsOfPlusMinusOne() - { - Assert.Equal(1.0f, math.erf(6.0f)); - Assert.Equal(1.0f, math.erf(20.0f)); - Assert.Equal(-1.0f, math.erf(-6.0f)); - Assert.Equal(-1.0f, math.erf(-20.0f)); - } - - [Fact] - public void ErfinvInvertsErf() - { - for (var i = -999; i <= 999; i++) - { - var y = i / 1000.0f; - var x = math.erfinv(y); - var roundTrip = math.erf(x); - Assert.True( - MathF.Abs(roundTrip - y) < 1e-5f, - $"erf(erfinv({y})) = {roundTrip}"); - } - } - - // Reference values from bisecting the platform's double-precision erf (Python's math.erf, - // glibc-backed), not this package's own erf, to 200 iterations over [-6, 6] -- effectively - // exact at float precision. This is what ErfinvInvertsErf cannot be: a check against this - // package's own erf only proves the two functions agree with each other, not that either is - // numerically correct. Soul measured the round-trip test alone to be blind to a coefficient - // change that degraded erfinv's worst error 300-400x (5.0e-7 to 2.0e-4). - [Theory] - // Expected values are computed for the actual float32-rounded input (e.g. the literal - // 0.999f is really 0.9990000128746033 as a double), not the decimal literal, because - // erfinv's derivative diverges near +-1: at y = 0.999f that rounding alone moves the - // true answer by ~2.6e-6, larger than the test's own error bar, if ignored. - [InlineData(0.0f, 0.0)] - [InlineData(0.1f, 0.08885599182530649)] - [InlineData(-0.1f, -0.08885599182530649)] - [InlineData(0.3f, 0.2724627261055238)] - [InlineData(-0.3f, -0.2724627261055238)] - [InlineData(0.5f, 0.4769362762044699)] - [InlineData(-0.5f, -0.4769362762044699)] - [InlineData(0.7f, 0.7328690598827587)] - [InlineData(-0.7f, -0.732869059882759)] - [InlineData(0.9f, 1.1630870719457724)] - [InlineData(-0.9f, -1.1630870719457729)] - [InlineData(0.95f, 1.3859037522360764)] - [InlineData(-0.95f, -1.3859037522360773)] - [InlineData(0.99f, 1.8213866009002766)] - [InlineData(-0.99f, -1.8213866009002793)] - [InlineData(0.999f, 2.3267563267961435)] - [InlineData(-0.999f, -2.3267563267961657)] - [InlineData(0.9999f, 2.751035437903192)] - [InlineData(-0.9999f, -2.751035437903383)] - public void ErfinvMatchesReferenceValues(float y, double expected) - { - Assert.True( - Math.Abs(math.erfinv(y) - expected) <= 2e-6, - $"erfinv({y}) = {math.erfinv(y)}, expected {expected}"); - } - - [Fact] - public void ErfinvAtDomainEdgesAndBeyond() - { - // Domain is [-1, 1]. The edges are the correctly-signed infinite limits; anything - // outside, including +-infinity itself, has no real inverse and is NaN. - Assert.Equal(float.PositiveInfinity, math.erfinv(1.0f)); - Assert.Equal(float.NegativeInfinity, math.erfinv(-1.0f)); - Assert.True(float.IsNaN(math.erfinv(1.0001f))); - Assert.True(float.IsNaN(math.erfinv(-1.0001f))); - Assert.True(float.IsNaN(math.erfinv(2.0f))); - Assert.True(float.IsNaN(math.erfinv(-2.0f))); - Assert.True(float.IsNaN(math.erfinv(float.PositiveInfinity))); - Assert.True(float.IsNaN(math.erfinv(float.NegativeInfinity))); - Assert.True(float.IsNaN(math.erfinv(float.NaN))); - } - - // The deleted ErfinvIsMonotonicallyIncreasing asserted a property erfinv does not have: - // Soul measured 9662 backward steps scanning every representable float32 in [0, 0.999999], - // first at y = 0.00022214651, worst drop 5.960464e-8 (one ULP at that scale) -- invisible - // only because that fixture stepped by 0.001. erf has the same shape: 100 backward steps - // over [0, 4.5], worst drop 1.192093e-7. Pin what is actually true: monotone to within a - // tolerance comfortably above that measured noise floor. - [Fact] - public void ErfinvIsMonotonicWithinFloatingPointTolerance() - { - const float tolerance = 1e-6f; - var previous = float.NegativeInfinity; - for (var i = -999; i <= 999; i++) - { - var y = i / 1000.0f; - var x = math.erfinv(y); - Assert.True( - x >= previous - tolerance, - $"erfinv({y}) = {x} dropped more than {tolerance} below previous value {previous}"); - previous = x; - } - } - - // One property the consumer needs, not the whole of correctness: fire control turns - // adjacent erf values into a cell's probability mass, so a backward step in erf would - // show up as a negative mass. An erf that returned a constant would pass this and is - // caught by ErfMatchesReferenceValues instead. - // Soul checked these geometries clean: 16/64/256/1024/4096 cells over - // +-4 sigma, plus a tight 1 sigma spread and a very tight 0.25 sigma spread. - [Theory] - [InlineData(16, 4f)] - [InlineData(64, 4f)] - [InlineData(256, 4f)] - [InlineData(1024, 4f)] - [InlineData(4096, 4f)] - [InlineData(256, 1f)] - [InlineData(64, 0.25f)] - public void ErfCellMassesStayNonNegativeAcrossRealisticGeometries(int cells, float sigmaRange) - { - var lo = -sigmaRange; - var hi = sigmaRange; - var step = (hi - lo) / cells; - var previous = math.erf(lo / MathF.Sqrt(2f)); - for (var i = 1; i <= cells; i++) - { - var edge = lo + step * i; - var current = math.erf(edge / MathF.Sqrt(2f)); - var mass = current - previous; - Assert.True(mass >= 0f, $"cell {i} of {cells} over +-{sigmaRange} sigma has negative mass {mass}"); - previous = current; - } - } } diff --git a/packages/cultmath/tools/generate-swizzles.ps1 b/packages/cultmath/tools/generate-swizzles.ps1 index e5f79011..9030f2be 100644 --- a/packages/cultmath/tools/generate-swizzles.ps1 +++ b/packages/cultmath/tools/generate-swizzles.ps1 @@ -21,19 +21,6 @@ $families = @( @{ Scalar = "bool"; Sizes = @(2, 3, 4); Functions = $true }, @{ Scalar = "double"; Sizes = @(2, 3); Functions = $false } ) -# Same-size vector conversions, as dxc lowers them: float to int is fptosi (toward zero; NaN -# and out-of-range are undefined in DXIL), int to float is sitofp, numeric to bool is `!= 0` -# (fcmp une, so NaN is true), bool to numeric is 0 or 1. Keyed "target<-source"; {0} is a -# source component. Identity forms (intN(intN), ...) come from the composition loop, which -# also keeps an int vector argument off the lossy intN(floatN) overload. -$conversions = [ordered]@{ - "float<-int" = "{0}" - "float<-bool" = "{0} ? 1.0f : 0.0f" - "int<-float" = "(int){0}" - "int<-bool" = "{0} ? 1 : 0" - "bool<-float" = "{0} != 0.0f" - "bool<-int" = "{0} != 0" -} $componentFields = @("x", "y", "z", "w") $namings = @(@("x", "y", "z", "w"), @("r", "g", "b", "a")) @@ -99,20 +86,6 @@ foreach ($family in $families) { } } - foreach ($source in @("float", "int", "bool")) { - $expression = $conversions["$scalar<-$source"] - if (-not $expression) { continue } - $converted = for ($i = 0; $i -lt $size; $i++) { $expression -f "value.$($componentFields[$i])" } - Line " public $type($source$size value) : this($($converted -join ', ')) { }" - if ($family.Functions) { - $functionLines.Add(" public static $type $type($source$size value) => new(value);") - } - } - if ($scalar -eq "float" -and $family.Functions) { - # floatN(int) splat: without it floatN(0) is ambiguous between floatN(float) and floatN(intN). - $functionLines.Add(" public static $type $type(int value) => new($((@('value') * $size) -join ', '));") - } - # Single-component color aliases; x/y/z/w are the fields themselves. $letters = $namings[1] for ($i = 0; $i -lt $size; $i++) { diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll index ef93b69b..bf712012 100644 Binary files a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll and b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.dll differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb index 0bc1d414..1eaadd23 100644 Binary files a/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb and b/packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins/CultMath.pdb differ diff --git a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl index 6179d45a..365705f5 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl +++ b/packages/cultmath/unity/org.gamecult.cultmath/Shaders/CultMath.hlsl @@ -11,6 +11,7 @@ static const float CULTMATH_PI = 3.14159265358979323846; static const float CULTMATH_TAU = 6.28318530717958647692; static const float CULTMATH_HALF_PI = 1.57079632679489661923; +static const float CULTMATH_NORMALIZE_EPSILON = 1.0e-20; float cultmath_radians(float degrees_value) { return degrees_value * (CULTMATH_PI / 180.0); } float cultmath_degrees(float radians_value) { return radians_value * (180.0 / CULTMATH_PI); } @@ -170,6 +171,10 @@ float cultmath_distance(float2 left, float2 right) { return length(left - right) float cultmath_distance(float3 left, float3 right) { return length(left - right); } float cultmath_distance(float4 left, float4 right) { return length(left - right); } +float2 cultmath_normalize(float2 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float3 cultmath_normalize(float3 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } +float4 cultmath_normalize(float4 value) { return value / max(length(value), CULTMATH_NORMALIZE_EPSILON); } + float2 cultmath_reflect(float2 incident, float2 normal_value) { return incident - 2.0 * dot(normal_value, incident) * normal_value; @@ -281,38 +286,6 @@ float cultmath_hash(float value) { return cultmath_frac(sin(value) * 43758.5453) float cultmath_hash(float2 value) { return cultmath_hash(dot(value, float2(127.1, 311.7))); } float cultmath_hash(float3 value) { return cultmath_hash(dot(value, float3(127.1, 311.7, 74.7))); } -// PCG hashes (Jarzynski and Olano, JCGT 2020), written over scalar uints so CultMath's int3/int4 carry the bits. -uint cultmath_pcg(uint value) -{ - uint state = value * 747796405u + 2891336453u; - uint word = ((state >> (int)((state >> 28) + 4)) ^ state) * 277803737u; - return (word >> 22) ^ word; -} - -int3 cultmath_pcg3d(int3 value) -{ - uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u, z = (uint)value.z * 1664525u + 1013904223u; - x += y * z; y += z * x; z += x * y; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; - x += y * z; y += z * x; z += x * y; - return int3((int)x, (int)y, (int)z); -} - -int3 cultmath_pcg3d(float2 value) { return cultmath_pcg3d(float3(value, 0.0)); } -int3 cultmath_pcg3d(float3 value) { return cultmath_pcg3d(int3((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z))); } - -int4 cultmath_pcg4d(int4 value) -{ - uint x = (uint)value.x * 1664525u + 1013904223u, y = (uint)value.y * 1664525u + 1013904223u; - uint z = (uint)value.z * 1664525u + 1013904223u, w = (uint)value.w * 1664525u + 1013904223u; - x += y * w; y += z * x; z += x * y; w += y * z; - x ^= x >> 16; y ^= y >> 16; z ^= z >> 16; w ^= w >> 16; - x += y * w; y += z * x; z += x * y; w += y * z; - return int4((int)x, (int)y, (int)z, (int)w); -} - -int4 cultmath_pcg4d(float4 value) { return cultmath_pcg4d(int4((int)asuint(value.x), (int)asuint(value.y), (int)asuint(value.z), (int)asuint(value.w))); } - float cultmath_value_noise(float2 position) { float2 cell = floor(position); diff --git a/packages/cultmath/unity/org.gamecult.cultmath/package.json b/packages/cultmath/unity/org.gamecult.cultmath/package.json index 6ab136b4..c09bf6bb 100644 --- a/packages/cultmath/unity/org.gamecult.cultmath/package.json +++ b/packages/cultmath/unity/org.gamecult.cultmath/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultmath", "displayName": "CultMath", - "version": "0.2.3", + "version": "0.2.0", "unity": "2021.3", "description": "Precompiled CultMath numeric primitives and matching HLSL semantics for GameCult Unity runtimes.", "license": "MPL-2.0", diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index ea37c17e..4dddfa7d 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -15,28 +15,42 @@ import { type CultMeshSessionAcceptedMessage, type CultMeshSessionOpenMessage, } from "cultnet-ts/contracts"; -import { - base64ToBytes, - bytesToBase64, - isLoopbackEndpoint, - isUnsignedCertificate, - routeCertificateView, - verifyAuthorityRoute, - verifyProviderSessionProof, - type CultMeshAuthorityIdentity, - type CultMeshAuthorityRouteCertificate, - type CultMeshAuthorityRouteView, - type CultMeshAuthorityTrustMode, - type CultMeshAuthorityTrustPolicy, - type CultMeshP256PublicKey, -} from "cultnet-ts/authority"; -export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity; -export type CultMeshBrowserRoute = CultMeshAuthorityRouteView; -export type CultMeshBrowserP256PublicKey = CultMeshP256PublicKey; -export type CultMeshBrowserRouteCertificate = CultMeshAuthorityRouteCertificate; -export type CultMeshBrowserAuthorityTrustMode = CultMeshAuthorityTrustMode; -export type CultMeshBrowserAuthorityTrustPolicy = CultMeshAuthorityTrustPolicy; +export interface CultMeshBrowserIdentity { + verseId: string; + authorityRuntimeId: string; +} + +export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity { + endpoint: string; + protocolId?: string; + protocolIds?: readonly string[]; + priority?: number; + generation: string; + certificate?: CultMeshBrowserRouteCertificate; +} + +export interface CultMeshBrowserP256PublicKey { + keyId: string; + x: string; + y: string; +} + +export interface CultMeshBrowserRouteCertificate { + providerKey: CultMeshBrowserP256PublicKey; + odinKeyId: string; + issuedAtUnixMilliseconds: number; + expiresAtUnixMilliseconds: number; + signature: string; +} + +export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development"; + +export interface CultMeshBrowserAuthorityTrustPolicy { + mode: CultMeshBrowserAuthorityTrustMode; + odinRoots?: readonly CultMeshBrowserP256PublicKey[]; + now?: () => number; +} export interface CultMeshBrowserRendezvous { resolve( @@ -780,6 +794,19 @@ export function decodeCultNetOperationPayload(response: CultNetOperationRespo return decode(base64ToBytes(response.payload)) as T; } +function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} + function requireWebSocketEndpoint(value: string, field: string): URL { let endpoint: URL; try { @@ -871,7 +898,17 @@ async function selectOdinRoute( protocolIds: route.protocolIds, priority: route.priority, generation: route.generation, - ...(route.certificate ? { certificate: routeCertificateView(route.certificate) } : {}), + ...(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, + } } : {}), }; try { await verifyAuthorityRoute(candidate, trust); @@ -904,26 +941,136 @@ async function validateSessionAcceptance( `expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`, ); } - // `CultMeshVerifiedAuthorityRoute.IsLocalDevelopment` in C#: an unsigned - // loopback route under local-development needs no provider session proof. - if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && isUnsignedCertificate(route.certificate)) { - return undefined; - } + if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined; const providerKey = route.certificate?.providerKey; if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) { return new Error("CultMesh authority did not prove possession of the Odin-certified provider key."); } - if (!await verifyProviderSessionProof(request, route.endpoint, providerKey, message.providerSignature)) { + if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) { return new Error("CultMesh provider session proof is invalid."); } return undefined; } +async function verifyAuthorityRoute( + route: CultMeshBrowserRoute, + trust: CultMeshBrowserAuthorityTrustPolicy, +): Promise { + const certificate = route.certificate; + if (!certificate) { + if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return; + throw new Error("Remote CultMesh routes require an Odin-signed authority certificate."); + } + const endpoint = new URL(route.endpoint); + if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) { + throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection."); + } + const now = trust.now?.() ?? Date.now(); + if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) { + throw new Error("The Odin route certificate is not currently valid."); + } + const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId); + if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`); + if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) { + throw new Error("The Odin route certificate signature is invalid."); + } +} + +function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array { + const certificate = route.certificate!; + return canonicalFields( + "gamecult.cultmesh.route-certificate.v1", + route.verseId, + route.authorityRuntimeId, + route.endpoint, + [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"), + String(route.priority ?? 0), + route.generation, + certificate.providerKey.keyId, + certificate.providerKey.x, + certificate.providerKey.y, + certificate.odinKeyId, + String(certificate.issuedAtUnixMilliseconds), + String(certificate.expiresAtUnixMilliseconds), + ); +} + +function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array { + return canonicalFields( + "gamecult.cultmesh.session-proof.v1", + request.clientNonce, + request.messageId, + request.sourceRuntimeId, + request.verseId, + request.authorityRuntimeId, + request.protocolId, + endpoint, + request.routeGeneration, + ); +} + +function canonicalFields(...values: string[]): Uint8Array { + const encoder = new TextEncoder(); + const encoded = values.map(value => encoder.encode(value)); + const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0); + const result = new Uint8Array(total); + const view = new DataView(result.buffer); + let offset = 0; + for (const value of encoded) { + view.setUint32(offset, value.byteLength, false); + offset += 4; + result.set(value, offset); + offset += value.byteLength; + } + return result; +} + +async function verifyP256( + key: CultMeshBrowserP256PublicKey, + payload: Uint8Array, + signatureBase64: string, +): Promise { + try { + const x = base64ToBytes(key.x); + const y = base64ToBytes(key.y); + const signature = base64ToBytes(signatureBase64); + if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false; + const raw = new Uint8Array(65); + raw[0] = 4; + raw.set(x, 1); + raw.set(y, 33); + const publicKey = await crypto.subtle.importKey( + "raw", + raw.slice().buffer as ArrayBuffer, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + return await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + signature.slice().buffer as ArrayBuffer, + payload.slice().buffer as ArrayBuffer, + ); + } catch { + return false; + } +} + function randomNonce(): string { const bytes = crypto.getRandomValues(new Uint8Array(32)); return bytesToBase64(bytes); } +function isLoopbackEndpoint(value: string): boolean { + try { + const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "::1"; + } catch { + return false; + } +} + function requireText(value: string, field: string): void { if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`); } diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index 59f9fac1..6c169d93 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -236,54 +236,6 @@ test("browser client rejects a credential-free authority impersonator", async () ); }); -test("browser client opens an unsigned-certificate loopback session under local-development without a provider proof, as in C#", async () => { - const connect = (route: CultMeshBrowserRoute, trust: { mode: "local-development" | "authenticated-remote"; odinRoots: CultMeshBrowserP256PublicKey[] }) => - CultMeshBrowserClient.connect({ - ...route, - runtimeId: "browser-unsigned-certificate-test", - rendezvous: { resolve: async () => route }, - trust, - socketFactory: () => handshakeOnlySocket(route), - }); - const blank = (route: CultMeshBrowserRoute, signature: string) => - ({ ...route, certificate: { ...route.certificate!, signature } }); - - const loopback = await createSignedRoute("ws://127.0.0.1:4501/mesh"); - const localTrust = { mode: "local-development" as const, odinRoots: [loopback.odinPublic] }; - const remote = await createSignedRoute("wss://provider.example/mesh"); - - // Empty is the easy case. The whitespace signatures are the ones that catch a - // local re-reading of "unsigned" here: an inline `signature === ""` passes - // every other test in this file and fails these, and U+0085 also separates - // `String.prototype.trim` from the C# `char.IsWhiteSpace` the shared - // `isUnsignedCertificate` uses. Both sides of the session check have to read - // "unsigned" the same way, or the route verifier accepts a session the - // acceptance check then demands a provider proof for. - for (const signature of ["", " ", "\u0085", "\u00a0\t\n"]) { - const label = JSON.stringify(signature); - const client = await connect(blank(loopback.route, signature), localTrust); - assert.equal(client.state, "connected", label); - await client.dispose(); - await assert.rejects( - connect(blank(remote.route, signature), { mode: "local-development", odinRoots: [remote.odinPublic] }), - /Odin-signed authority certificate/, - label, - ); - await assert.rejects( - connect(blank(remote.route, signature), { mode: "authenticated-remote", odinRoots: [remote.odinPublic] }), - /Odin-signed authority certificate/, - label, - ); - } - // U+FEFF is whitespace to JavaScript and not to C#, so it is a signature, not - // an unsigned route, and the loopback route is refused for its bytes. - await assert.rejects(connect(blank(loopback.route, "\ufeff"), localTrust), /not base64|not IEEE P1363/); - - // A signed loopback certificate is an authenticated route even under - // local-development; the provider must still prove its key. - await assert.rejects(connect(loopback.route, localTrust), /did not prove possession/); -}); - test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => { const fixture = await createSignedRoute("wss://provider.example/mesh"); let opened = false; diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 2117c2d8..104bd1b9 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -1359,7 +1359,7 @@ fun defineCultNetSchemaDescriptor( schemaJson = schemaJson, ) -private const val cultNetSchemaBase = "https://github.com/GameCult/CultLib/contracts/cultnet" +private const val cultNetSchemaBase = "https://github.com/GameCult/cultnet-ts/contracts" private data class CultNetBuiltInSchemaSpec( val schemaVersion: String, @@ -1377,58 +1377,58 @@ private val cultNetBuiltInSchemaSpecs = listOf( title = "CultNet Hello Message", schemaId = "$cultNetSchemaBase/cultnet.hello.schema.json", requiredFields = listOf("schemaVersion", "runtimeId", "runtimeKind"), - contentHash = "f660ef803f293e2882650b3981c6eed835bd8e595c9b880d6c6271e0dcbd79e4", + contentHash = "1d2114bf0c92a2dd5e07dd735778ceeb939402c6424550d6d281d9db423f56b3", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.document_delete.v0", title = "CultNet Document Delete Message", schemaId = "$cultNetSchemaBase/cultnet.document-delete.schema.json", requiredFields = listOf("schemaVersion", "messageId", "schemaId", "recordKey"), - contentHash = "ed7dc202e4f1ebe0d88c5d60c81c53005b44a2557698d3d3c5d2126c01b40f08", + contentHash = "037f7cf1b30756904e0bfada6bcff486269378997afbe3c4b6fc5c573fc8a4df", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.document_put_raw.v0", title = "CultNet Raw Document Put Message", schemaId = "$cultNetSchemaBase/cultnet.document-put-raw.schema.json", - contentHash = "34b61b14b49cfc887728673f08aa0f33df7ccd401e79c77d3bc324c72e547177", + contentHash = "b61e7e6cce8508653fbeccc4d956ef211c853a174d67c7e1903ce294e15985a2", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.snapshot_request.v0", title = "CultNet Snapshot Request Message", schemaId = "$cultNetSchemaBase/cultnet.snapshot-request.schema.json", - contentHash = "05267d9b5eb450d4aa8979179f74ba3aec2f3a82fead7ba4a16ab5462c7750fa", + contentHash = "9f6546f51a1aab85978554e6908d7a59e96b3329feff7b35022be210495dd721", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.snapshot_response_raw.v0", title = "CultNet Raw Snapshot Response Message", schemaId = "$cultNetSchemaBase/cultnet.snapshot-response-raw.schema.json", - contentHash = "fdefbdf5d6b0844dd0eabe2c7430d054295d78e41289f96b227dc39f4aab78b9", + contentHash = "0dbca3338a3ae5d4011aed2c973c75b9b47b6b9dfc0874459ac56da01fef4582", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.schema_catalog_request.v0", title = "CultNet Schema Catalog Request Message", schemaId = "$cultNetSchemaBase/cultnet.schema-catalog-request.schema.json", wireContracts = listOf("cultnet.schema.v0", "gamecult.networking.v0"), - contentHash = "ed6d63e29de2d492bb831f030b26ff49ba6883cd2be791ec4936c385f765c76a", + contentHash = "bdd49b44428daf24b514c521e53027ec944aecfebb39ca0d10967661c303e096", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.schema_catalog_response.v0", title = "CultNet Schema Catalog Response Message", schemaId = "$cultNetSchemaBase/cultnet.schema-catalog-response.schema.json", wireContracts = listOf("cultnet.schema.v0", "gamecult.networking.v0"), - contentHash = "10b4835d33181fe4b93a33e39b63886c5514899e93682de786e298f7e4a213e9", + contentHash = "b158de53d1ca614eacf2e05767122fb4b9e06504c037ad76c649cffdf71dd648", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.shard_catalog_request.v0", title = "CultNet Shard Catalog Request Message", - schemaId = "$cultNetSchemaBase/cultnet.shard-catalog-request.schema.json", - contentHash = "b4edecd8a24f68ebccf0aca9b429d234618ff7e8858a10123d0b328aeae7eb3f", + schemaId = "cultnet.shard_catalog_request.v0", + contentHash = "0abd131a1db12f6731010333b86e104d550160bd58f539b18413a2b61198fa38", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.shard_catalog_response.v0", title = "CultNet Shard Catalog Response Message", - schemaId = "$cultNetSchemaBase/cultnet.shard-catalog-response.schema.json", - contentHash = "d86a94c57d7820483912b8b5cef6214ac951d34cb2641d2d21dcb1f1bc0263c0", + schemaId = "cultnet.shard_catalog_response.v0", + contentHash = "93a0e90ac81bc6ab21b68925ad25e381edfac8773232333679d3275141cff37b", ), CultNetBuiltInSchemaSpec( schemaVersion = "cultnet.shard_log_request.v0", @@ -1468,7 +1468,7 @@ private val cultNetBuiltInSchemaSpecs = listOf( schemaId = "$cultNetSchemaBase/cultnet.transport-profile.schema.json", kind = "shared_contract", requiredFields = listOf("schemaVersion", "runtimeId", "transports"), - contentHash = "63dfd484e1fd332c167caadf52cb5b4fe12d5ffea4415206c3ef111c196507aa", + contentHash = "84473264f543ca3443267999e43d72b7993b61b7b2ac53c642c9cbe8b6de0217", ), ) @@ -3717,16 +3717,16 @@ private fun cultNetReconnectControllerSchedulesAttemptsAndReset() { private const val interopDocumentType = "cultnet.interop-note" private const val interopSchemaVersion = "cultnet.interop_note.v0" private const val interopMutationIntentType = "cultnet.interop-note-mutation-intent" -private const val interopMutationIntentSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-intent.schema.json" +private const val interopMutationIntentSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-intent.schema.json" private const val interopMutationIntentSchemaVersion = "cultnet.interop_note_mutation_intent.v0" private const val interopMutationReceiptType = "cultnet.interop-note-mutation-receipt" -private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-note-mutation-receipt.schema.json" +private const val interopMutationReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-note-mutation-receipt.schema.json" private const val interopMutationReceiptSchemaVersion = "cultnet.interop_note_mutation_receipt.v0" private const val interopFireCommandType = "cultnet.interop-fire-weapon-command" -private const val interopFireCommandSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-command.schema.json" +private const val interopFireCommandSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-command.schema.json" private const val interopFireCommandSchemaVersion = "cultnet.interop_fire_weapon_command.v0" private const val interopFireReceiptType = "cultnet.interop-fire-weapon-receipt" -private const val interopFireReceiptSchemaId = "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.interop-fire-weapon-receipt.schema.json" +private const val interopFireReceiptSchemaId = "https://github.com/GameCult/cultnet-ts/integration/contracts/cultnet.interop-fire-weapon-receipt.schema.json" private const val interopFireReceiptSchemaVersion = "cultnet.interop_fire_weapon_receipt.v0" private const val rudpInteropConnectionId = 0x43554c54L private const val rudpInteropMaxFragmentBytes = 1024 @@ -4627,11 +4627,11 @@ private fun cultNetBuiltInSchemaCatalogAdvertisesWireContracts() { check(schemaCatalogRequest["wireContracts"] == listOf("cultnet.schema.v0", "gamecult.networking.v0")) check(!schemaCatalogRequest.containsKey("schemaJson")) val shardCatalogRequest = schemas.single { it["schemaVersion"] == "cultnet.shard_catalog_request.v0" } - check(shardCatalogRequest["schemaId"] == "$cultNetSchemaBase/cultnet.shard-catalog-request.schema.json") + check(shardCatalogRequest["schemaId"] == "cultnet.shard_catalog_request.v0") check(shardCatalogRequest["wireContracts"] == listOf("cultnet.schema.v0")) - check(shardCatalogRequest["contentHash"] == "b4edecd8a24f68ebccf0aca9b429d234618ff7e8858a10123d0b328aeae7eb3f") + check(shardCatalogRequest["contentHash"] == "0abd131a1db12f6731010333b86e104d550160bd58f539b18413a2b61198fa38") val shardCatalogResponse = schemas.single { it["schemaVersion"] == "cultnet.shard_catalog_response.v0" } - check(shardCatalogResponse["schemaId"] == "$cultNetSchemaBase/cultnet.shard-catalog-response.schema.json") + check(shardCatalogResponse["schemaId"] == "cultnet.shard_catalog_response.v0") check(shardCatalogResponse["wireContracts"] == listOf("cultnet.schema.v0")) val inlineCatalog = cultNetBuiltInSchemaCatalog(includeSchemaJson = true) @@ -4644,7 +4644,7 @@ private fun cultNetBuiltInSchemaCatalogAdvertisesWireContracts() { ) val transportProfile = mapList(transportResponse.body["schemas"]).single() check(transportProfile["kind"] == "shared_contract") - check(transportProfile["contentHash"] == "63dfd484e1fd332c167caadf52cb5b4fe12d5ffea4415206c3ef111c196507aa") + check(transportProfile["contentHash"] == "84473264f543ca3443267999e43d72b7993b61b7b2ac53c642c9cbe8b6de0217") check(!transportProfile.containsKey("schemaJson")) val filteredTransportCatalog = CultMesh.createBuiltInSchemaCatalog( diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index 8cb2801f..b6a21bb8 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -1759,11 +1759,11 @@ class CultMeshTests(unittest.TestCase): ) self.assertEqual( builtins.list()[0].schema_id, - "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json", + "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", ) self.assertIsNone(builtins.list()[0].schema_json) peer_exchange_schema_id = ( - "https://github.com/GameCult/CultLib/contracts/cultnet/cultmesh.peer-exchange-request.schema.json" + "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.peer-exchange-request.schema.json" ) filtered_builtin = CultMesh.create_builtin_schema_catalog( include_schema_json=True, @@ -4033,7 +4033,7 @@ class CultMeshTests(unittest.TestCase): self.assertIn("cultmesh.peer_exchange_response.v0", wire_descriptors) self.assertIn("cultnet.error.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors}) self.assertIn("cultnet.document_put_raw.v0", {descriptor.schema_version for descriptor in synced_wire_descriptors}) - self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.document-put-raw.schema.json")) + self.assertIsNotNone(synced_schema_catalog.get("https://github.com/GameCult/cultnet-ts/contracts/cultnet.document-put-raw.schema.json")) self.assertEqual(snapshot_response["documents"][0]["recordKey"], "note:1") self.assertEqual(typed_snapshot_response.documents[0].record_key, "note:1") self.assertEqual(typed_snapshot_response.documents[0].schema_id, "mesh.server_note.v1") diff --git a/packages/cultmesh-ts/native/MANIFEST.txt b/packages/cultmesh-ts/native/MANIFEST.txt deleted file mode 100644 index 074975f0..00000000 --- a/packages/cultmesh-ts/native/MANIFEST.txt +++ /dev/null @@ -1,9 +0,0 @@ -msquic.version=2.5.9 -msquic.windows.source=https://www.nuget.org/api/v2/package/Microsoft.Native.Quic.MsQuic.OpenSSL/2.5.9 sha256=FEE9A664C0052DEEE66D40CED2E19BFE20BC423DE5AE5234CFC0CEBC22CA3F5E -msquic.linux.source=https://packages.microsoft.com/debian/13/prod/pool/main/libm/libmsquic/libmsquic_2.5.9_amd64.deb sha256=1baa61ade0b7b4a99f6dcb6b00d9aedb12b5566d00918a325be7425e878e51ba -bridge.commit=e5bf1c9243acb6b76de72c7864e3b79214177199 -build.workflow_run=https://github.com/GameCult/CultLib/actions/runs/35912386052 -build.release=none (TEMPORARY push-to-quic-cut6 trigger, no tag; see quic-native.yml history for the tagged release path) -build.win32-x64.host=windows-2025-vs2026, cl.exe (MSVC) 19.51.36256.0 -build.linux-x64.host=ubuntu-24.04, container debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1, g++ (Debian 14.2.0-19) 14.2.0 -date=2026-09-23T19:54:08Z diff --git a/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt b/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt deleted file mode 100644 index 63447fd8..00000000 --- a/packages/cultmesh-ts/native/MSQUIC-LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -Copyright (c) Microsoft Corporation. - -MIT License - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file diff --git a/packages/cultmesh-ts/native/SHA256SUMS b/packages/cultmesh-ts/native/SHA256SUMS deleted file mode 100644 index a1233d52..00000000 --- a/packages/cultmesh-ts/native/SHA256SUMS +++ /dev/null @@ -1,4 +0,0 @@ -9dae391a44d59f12e19baa08a44d86c7e8b4916ca5d40007a42d65913ce0896f *win32-x64/gamecult_mesh_quic_native.dll -c17c65813fe007eb486a5270d244f3659261355795b27abb99bd19ff77539263 *win32-x64/msquic.dll -0ee3d28324c79a5b0135368187e7bd75c1969ad94fc6985755e81de4ec96e430 *linux-x64/libgamecult_mesh_quic_native.so -c09a72f9b3bfd9119044b305c6064731c1e1879411e6f54276474fb58b524b68 *linux-x64/libmsquic.so.2 diff --git a/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so b/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so deleted file mode 100644 index 7363d1cb..00000000 Binary files a/packages/cultmesh-ts/native/linux-x64/libgamecult_mesh_quic_native.so and /dev/null differ diff --git a/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 b/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 deleted file mode 100644 index e14af169..00000000 Binary files a/packages/cultmesh-ts/native/linux-x64/libmsquic.so.2 and /dev/null differ diff --git a/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll b/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll deleted file mode 100644 index da7be3c0..00000000 Binary files a/packages/cultmesh-ts/native/win32-x64/gamecult_mesh_quic_native.dll and /dev/null differ diff --git a/packages/cultmesh-ts/native/win32-x64/msquic.dll b/packages/cultmesh-ts/native/win32-x64/msquic.dll deleted file mode 100644 index 71107c2c..00000000 Binary files a/packages/cultmesh-ts/native/win32-x64/msquic.dll and /dev/null differ diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index 65a32110..0ca8afb3 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -12,14 +12,13 @@ } }, "files": [ - "dist", - "native" + "dist" ], "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 --expose-gc --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 --test dist-test/test/*.test.js" }, "keywords": [ "cultmesh", @@ -45,8 +44,7 @@ "dependencies": { "@msgpack/msgpack": "^3.1.2", "@gamecult/cultcache-ts": "^0.14.0", - "cultnet-ts": "^0.2.0", - "koffi": "3.3.0" + "cultnet-ts": "^0.2.0" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index ab99639d..e9fbb8be 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -34,18 +34,6 @@ 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"; @@ -6280,28 +6268,6 @@ export class CultMesh { } return CultMesh.createRudpPeerForPeer(runtimeId, connectionId, peer, options); } - - public static parseQuicRealtimeEndpoint(endpoint: string): ReturnType { - return parseQuicRealtimeEndpoint(endpoint); - } - - public static createQuicRealtimeConnector( - options: CultMeshQuicRealtimeConnectorOptions = {}, - ): CultMeshQuicRealtimeConnector { - return new CultMeshQuicRealtimeConnector(options); - } - - public static createQuicRealtimeSessionManager( - options: CultMeshQuicRealtimeSessionManagerOptions, - ): CultMeshQuicRealtimeSessionManager { - return new CultMeshQuicRealtimeSessionManager(options); - } - - public static createQuicRealtimeProvider( - options: CultMeshQuicRealtimeProviderOptions, - ): Promise { - return CultMeshQuicRealtimeProvider.listen(options); - } } function cultMeshSchemaFromDefinition( diff --git a/packages/cultmesh-ts/src/realtime-quic-native.ts b/packages/cultmesh-ts/src/realtime-quic-native.ts deleted file mode 100644 index 52b0ce01..00000000 --- a/packages/cultmesh-ts/src/realtime-quic-native.ts +++ /dev/null @@ -1,583 +0,0 @@ -// The koffi binding for `native/GameCult.Mesh.Quic.Native`'s C ABI v2 -// (`include/cultmesh_quic_native.h`). This file is the only place `koffi` is -// imported, and it is imported lazily, inside `loadBindings`, so a consumer -// that never touches QUIC (AetheriaEve Electron, Odin, Stonks, weksa) loads no -// native module at all. -// -// No koffi callbacks. `cultmesh_quic_next_event` is the single blocking -// export; `CultMeshQuicNativeRuntime` runs it on koffi's async thread pool in -// one loop per process-wide runtime and dispatches decoded events to -// registered listener/connection handlers by id. Every other export is a -// plain synchronous call. -// -// Binary resolution: `CULTMESH_QUIC_NATIVE_DIR` when set (the development -// loop and CI point it at `artifacts/quic-native/` straight from a -// build), otherwise `path.join(__dirname, "..", "native", "-")` -// (the committed tree a later cut lands; `__dirname` is `dist/` at runtime, so -// this path holds in the repo, an `npm pack` tarball and a pnpm `file:` copy -// alike). No `node_modules` lookup, no platform package, no registry. -// -// Two koffi quirks this file works around, both probed against the pinned -// 3.3.0 build: `_Out_` does not auto-allocate storage, so every out -// parameter is `koffi.alloc(type, 1)` written by the call and read back with -// `koffi.decode(buffer, type)`; and a decoded 64-bit value (`void *`, -// `uint64_t`) comes back as a plain `number` when it fits a safe integer, -// not a `bigint`. `toBigInt64` below is the one place that coerces either -// into the `bigint` every exported type in this module promises. - -import { existsSync } from "node:fs"; -import { join } from "node:path"; - -/** One decoded native event, payload already sliced to `payload_length`. */ -export interface CultMeshQuicNativeEvent { - readonly type: number; - readonly streamKind: number; - readonly listenerId: bigint; - readonly connectionId: bigint; - readonly streamId: bigint; - readonly code: bigint; - readonly status: number; - readonly payload: Uint8Array; -} - -export const CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION = 1; -export const CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED = 2; -export const CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED = 3; -export const CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN = 4; -export const CULTMESH_QUIC_EVENT_STREAM_STARTED = 5; -export const CULTMESH_QUIC_EVENT_STREAM_FRAME = 6; -export const CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE = 7; -export const CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN = 8; -export const CULTMESH_QUIC_EVENT_LISTENER_STOPPED = 9; - -export const CULTMESH_QUIC_SEND_COMPLETED = 0; -export const CULTMESH_QUIC_SEND_CANCELED = 1; - -export const CULTMESH_QUIC_STREAM_RELIABLE = 1; -export const CULTMESH_QUIC_STREAM_LATEST_ONLY = 2; - -export const CULTMESH_QUIC_RESULT_BAD_CALL = -1; -export const CULTMESH_QUIC_RESULT_BAD_ARGUMENT = -2; - -type NativeEventListener = (event: CultMeshQuicNativeEvent) => void; -/** Invoked once when a connection is faulted: a throwing listener, a malformed - * frame, or any other error raised while dispatching an event for it. */ -type NativeFaultHandler = (error: Error) => void; - -/** koffi decodes a 64-bit value as `number` when it fits a safe integer and as - * `bigint` otherwise (probed on the pinned 3.3.0 build); this is the single - * place that coerces either into the `bigint` every exported type promises. */ -function toBigInt64(value: number | bigint): bigint { - return typeof value === "bigint" ? value : BigInt(value); -} - -/** The opaque native runtime handle: a `void *`, decoded by koffi as a `bigint`. */ -export type CultMeshQuicNativeHandle = bigint; - -interface NativeEventStruct { - type: number; - stream_kind: number; - listener_id: bigint; - connection_id: bigint; - stream_id: bigint; - code: bigint; - status: number; - payload_length: number; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type KoffiLib = any; - -/** `nextEventAsync`'s signature, typed explicitly because `promisify` cannot - * infer one from koffi's untyped (`any`) async export. */ -type NextEventAsyncFn = ( - runtime: CultMeshQuicNativeHandle, - timeoutMs: number, - outEvent: unknown, - payload: Uint8Array | null, - payloadCapacity: number, - outRequired: unknown, -) => Promise; - -/** - * The loaded native library: koffi itself, the declared struct, and the - * twelve raw v2 exports. Untyped (koffi is `any`); the runtime class methods - * below are the type surface for these calls, so wrapping them a second time - * in a typed interface would only restate what the methods already say. The - * type is inferred from `buildLibrary`'s return value instead of restated. - */ -// eslint-disable-next-line @typescript-eslint/no-use-before-define -type LoadedNativeLibrary = ReturnType; - -let libraryCache: LoadedNativeLibrary | undefined; - -function platformArchDir(): string { - return `${process.platform}-${process.arch}`; -} - -function platformFileNames(): { bridge: string; dependency: string } { - if (process.platform === "win32") { - return { bridge: "gamecult_mesh_quic_native.dll", dependency: "msquic.dll" }; - } - if (process.platform === "linux") { - return { bridge: "libgamecult_mesh_quic_native.so", dependency: "libmsquic.so.2" }; - } - throw new Error(`CultMesh QUIC native binding does not support platform '${process.platform}'.`); -} - -function resolveNativeDir(): string { - const configured = process.env.CULTMESH_QUIC_NATIVE_DIR; - if (configured && configured.length > 0) return configured; - return join(__dirname, "..", "native", platformArchDir()); -} - -/** Loads koffi, resolves the binary directory, and declares the ABI. */ -function buildLibrary() { - // Untyped on purpose: koffi's own type declarations are ESM-flavored and - // fight a CommonJS `typeof import(...)` reference under Node16 resolution. - // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-explicit-any - const koffi: any = require("koffi"); - const { bridge, dependency } = platformFileNames(); - const dir = resolveNativeDir(); - const bridgePath = join(dir, bridge); - const dependencyPath = join(dir, dependency); - if (!existsSync(bridgePath) || !existsSync(dependencyPath)) { - const envDir = process.env.CULTMESH_QUIC_NATIVE_DIR; - throw new Error( - "CultMesh QUIC native binaries were not found. Tried " + - `'${dir}' (${envDir ? "CULTMESH_QUIC_NATIVE_DIR" : "the package's committed native/ tree"}). ` + - "Set CULTMESH_QUIC_NATIVE_DIR to a directory built by scripts/build-quic-native.ps1 or " + - "scripts/build-quic-native.sh, or ship the committed native/ tree.", - ); - } - - // The dependent library must be resolvable before the bridge loads. - koffi.load(dependencyPath); - const bridgeLib = koffi.load(bridgePath); - - const event = koffi.struct("cultmesh_quic_event", { - type: "uint32_t", - stream_kind: "uint32_t", - listener_id: "uint64_t", - connection_id: "uint64_t", - stream_id: "uint64_t", - code: "uint64_t", - status: "int32_t", - payload_length: "int32_t", - reserved: koffi.array("uint8_t", 16), - }); - - const runtimeOpenFn = bridgeLib.func( - "int32_t cultmesh_quic_runtime_open(str app_name, void **out_runtime)", - ); - const runtimeCloseFn = bridgeLib.func("void cultmesh_quic_runtime_close(void *runtime)"); - const listenerOpenFn = bridgeLib.func( - "int32_t cultmesh_quic_listener_open(void *runtime, str host, uint16_t port, " + - "uint8_t *pkcs12, int32_t pkcs12_length, str password, " + - "uint64_t *out_listener_id, uint16_t *out_bound_port)", - ); - const listenerCloseFn = bridgeLib.func( - "void cultmesh_quic_listener_close(void *runtime, uint64_t listener_id)", - ); - const connectionOpenFn = bridgeLib.func( - "int32_t cultmesh_quic_connection_open(void *runtime, str host, uint16_t port, " + - "uint64_t *out_connection_id)", - ); - const connectionCertificateCompleteFn = bridgeLib.func( - "int32_t cultmesh_quic_connection_certificate_complete(void *runtime, uint64_t connection_id, int32_t accept)", - ); - const connectionShutdownFn = bridgeLib.func( - "void cultmesh_quic_connection_shutdown(void *runtime, uint64_t connection_id, uint64_t code)", - ); - const streamOpenFn = bridgeLib.func( - "int32_t cultmesh_quic_stream_open(void *runtime, uint64_t connection_id, uint8_t kind, " + - "uint64_t *out_stream_id)", - ); - const streamSendFrameFn = bridgeLib.func( - "int32_t cultmesh_quic_stream_send_frame(void *runtime, uint64_t stream_id, " + - "uint8_t *encoded_frame, int32_t length, int32_t fin)", - ); - const streamShutdownFn = bridgeLib.func( - "void cultmesh_quic_stream_shutdown(void *runtime, uint64_t stream_id, uint64_t code)", - ); - const nextEventFn = bridgeLib.func( - "int32_t cultmesh_quic_next_event(void *runtime, int32_t timeout_ms, " + - `${event.name} *out_event, uint8_t *payload, int32_t payload_capacity, ` + - "int32_t *out_required)", - ); - const lastErrorFn = bridgeLib.func( - "int32_t cultmesh_quic_last_error(void *runtime, uint8_t *destination, int32_t capacity)", - ); - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { promisify } = require("node:util") as typeof import("node:util"); - const nextEventAsync = promisify(nextEventFn.async) as NextEventAsyncFn; - - return { - koffi, - event, - runtimeOpenFn, - runtimeCloseFn, - listenerOpenFn, - listenerCloseFn, - connectionOpenFn, - connectionCertificateCompleteFn, - connectionShutdownFn, - streamOpenFn, - streamSendFrameFn, - streamShutdownFn, - nextEventAsync, - lastErrorFn, - }; -} - -/** Lazily builds and caches the loaded native library. */ -function loadBindings(): LoadedNativeLibrary { - if (libraryCache) return libraryCache; - libraryCache = buildLibrary(); - return libraryCache; -} - -function readLastError(lib: LoadedNativeLibrary, runtime: CultMeshQuicNativeHandle): string { - const buffer = Buffer.alloc(1024); - const written = lib.lastErrorFn(runtime, buffer, buffer.length); - return written > 0 ? buffer.subarray(0, written).toString("utf8") : "CultMesh native QUIC call failed."; -} - -/** - * One MsQuic registration and one event queue, shared process-wide. - * `open`/`release` are reference-counted: the underlying runtime opens on the - * first `open` and closes on the last matching `release`. `open`/`release` - * calls do not need to be paired 1:1 by the same caller as long as the counts - * balance overall. - */ -export class CultMeshQuicNativeRuntime { - private static shared: CultMeshQuicNativeRuntime | undefined; - private static refCountValue = 0; - - /** Outstanding `open()` references not yet matched by `release()`. Test-visible: the UAF and eviction regression tests assert it returns to its baseline. */ - static get refCount(): number { - return CultMeshQuicNativeRuntime.refCountValue; - } - - private readonly handle: CultMeshQuicNativeHandle; - private readonly lib: LoadedNativeLibrary; - private readonly listenerListeners = new Map(); - private readonly connectionListeners = new Map< - bigint, - { listener: NativeEventListener; onFault?: NativeFaultHandler } - >(); - private closed = false; - private pumpLoop: Promise; - - /** - * `nextEvent`'s out-parameter buffers, allocated once and reused for every - * call instead of per call: the pump is single-threaded with at most one - * `nextEvent` in flight (`nextEventAsync` is only ever awaited, never - * fired concurrently), so decode-then-reuse is safe. Freed once in - * `release()` after the pump loop has exited. - */ - private readonly outEventBuffer: unknown; - private readonly outRequiredBuffer: unknown; - - private constructor(handle: CultMeshQuicNativeHandle, lib: LoadedNativeLibrary) { - this.handle = handle; - this.lib = lib; - this.outEventBuffer = lib.koffi.alloc(lib.event, 1); - this.outRequiredBuffer = lib.koffi.alloc("int32_t", 1); - this.pumpLoop = this.pump(); - } - - static async open(): Promise { - if (!CultMeshQuicNativeRuntime.shared) { - const lib = loadBindings(); - const out = lib.koffi.alloc("void *", 1); - const status = lib.runtimeOpenFn(null, out); - if (status !== 0) { - lib.koffi.free(out); - throw new Error(`cultmesh_quic_runtime_open failed with status ${status}.`); - } - const handle = toBigInt64(lib.koffi.decode(out, "void *")); - lib.koffi.free(out); - CultMeshQuicNativeRuntime.shared = new CultMeshQuicNativeRuntime(handle, lib); - } - CultMeshQuicNativeRuntime.refCountValue += 1; - return CultMeshQuicNativeRuntime.shared; - } - - /** - * Takes one more reference on this already-open runtime, synchronously — - * unlike `open()`, this never awaits a runtime-open call, so a caller - * already holding a live instance (a provider handing a reference to a - * newly accepted peer) can attach it in the same synchronous turn instead - * of crossing a microtask boundary that would leave the event a caller is - * mid-dispatching unowned until the next tick. - */ - retain(): CultMeshQuicNativeRuntime { - CultMeshQuicNativeRuntime.refCountValue += 1; - return this; - } - - /** - * Drops one reference; closes the shared runtime once none remain. Refuses - * a release when no reference is outstanding. The bridge header (section 4) - * forbids any host call beginning once `cultmesh_quic_runtime_close` has - * started, so this marks the runtime closed and waits for the pump to exit - * (it wakes within its 250 ms `nextEvent` timeout) before making that call: - * a queued, not-yet-started `nextEvent` call must never begin after close - * and run against freed memory. - */ - async release(): Promise { - if (CultMeshQuicNativeRuntime.refCountValue === 0) { - throw new Error("CultMeshQuicNativeRuntime.release called with no outstanding reference."); - } - CultMeshQuicNativeRuntime.refCountValue -= 1; - if (CultMeshQuicNativeRuntime.refCountValue > 0) return; - this.closed = true; - CultMeshQuicNativeRuntime.shared = undefined; - await this.pumpLoop; - this.lib.runtimeCloseFn(this.handle); - this.lib.koffi.free(this.outEventBuffer); - this.lib.koffi.free(this.outRequiredBuffer); - } - - onListenerEvent(listenerId: bigint, listener: NativeEventListener): void { - this.listenerListeners.set(listenerId, listener); - } - - offListenerEvent(listenerId: bigint): void { - this.listenerListeners.delete(listenerId); - } - - /** - * Registers the event listener for one connection. `onFault`, if given, is - * invoked exactly once if `listener` (or anything it calls synchronously, - * such as a caller-supplied certificate validator) throws while dispatching - * an event for this connection: the pump isolates the fault to this - * connection, shuts it down at the native level, removes the listener, and - * keeps running for every other connection. - */ - onConnectionEvent(connectionId: bigint, listener: NativeEventListener, onFault?: NativeFaultHandler): void { - this.connectionListeners.set(connectionId, { listener, onFault }); - } - - offConnectionEvent(connectionId: bigint): void { - this.connectionListeners.delete(connectionId); - } - - listenerOpen( - host: string | null, - port: number, - pkcs12: Uint8Array, - password: string | null, - ): { listenerId: bigint; boundPort: number } { - const outListenerId = this.lib.koffi.alloc("uint64_t", 1); - const outBoundPort = this.lib.koffi.alloc("uint16_t", 1); - try { - const status = this.lib.listenerOpenFn( - this.handle, - host, - port, - pkcs12, - pkcs12.length, - password, - outListenerId, - outBoundPort, - ); - if (status !== 0) { - throw new Error(`cultmesh_quic_listener_open failed: ${readLastError(this.lib, this.handle)}`); - } - return { - listenerId: toBigInt64(this.lib.koffi.decode(outListenerId, "uint64_t")), - boundPort: this.lib.koffi.decode(outBoundPort, "uint16_t") as number, - }; - } finally { - this.lib.koffi.free(outListenerId); - this.lib.koffi.free(outBoundPort); - } - } - - listenerClose(listenerId: bigint): void { - this.lib.listenerCloseFn(this.handle, listenerId); - this.offListenerEvent(listenerId); - } - - connectionOpen(host: string, port: number): bigint { - const out = this.lib.koffi.alloc("uint64_t", 1); - try { - const status = this.lib.connectionOpenFn(this.handle, host, port, out); - if (status !== 0) { - throw new Error(`cultmesh_quic_connection_open failed: ${readLastError(this.lib, this.handle)}`); - } - return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); - } finally { - this.lib.koffi.free(out); - } - } - - connectionCertificateComplete(connectionId: bigint, accept: boolean): void { - const status = this.lib.connectionCertificateCompleteFn(this.handle, connectionId, accept ? 1 : 0); - if (status !== 0) { - throw new Error( - `cultmesh_quic_connection_certificate_complete failed: ${readLastError(this.lib, this.handle)}`, - ); - } - } - - connectionShutdown(connectionId: bigint, code: bigint): void { - this.lib.connectionShutdownFn(this.handle, connectionId, code); - } - - streamOpen(connectionId: bigint, kind: number): bigint { - const out = this.lib.koffi.alloc("uint64_t", 1); - try { - const status = this.lib.streamOpenFn(this.handle, connectionId, kind, out); - if (status !== 0) { - throw new Error(`cultmesh_quic_stream_open failed: ${readLastError(this.lib, this.handle)}`); - } - return toBigInt64(this.lib.koffi.decode(out, "uint64_t")); - } finally { - this.lib.koffi.free(out); - } - } - - streamSendFrame(streamId: bigint, encodedFrame: Uint8Array, fin: boolean): void { - const status = this.lib.streamSendFrameFn(this.handle, streamId, encodedFrame, encodedFrame.length, fin ? 1 : 0); - if (status !== 0) { - throw new Error(`cultmesh_quic_stream_send_frame failed: ${readLastError(this.lib, this.handle)}`); - } - } - - streamShutdown(streamId: bigint, code: bigint): void { - this.lib.streamShutdownFn(this.handle, streamId, code); - } - - /** - * Isolates the faulting connection so one bad event never brings down the - * pump: a malformed frame, a stream-kind/delivery mismatch, or a throwing - * caller-supplied listener (certificate validator, waiter) faults only its - * own connection. The listener is removed, the connection is shut down at - * the native level, and `onFault` (if registered) is told, so pending and - * future work on that connection can reject with the error. Every other - * connection, and the pump itself, keep running. - */ - private dispatch(event: NativeEventStruct, payload: Uint8Array): void { - const decoded: CultMeshQuicNativeEvent = { - type: event.type, - streamKind: event.stream_kind, - listenerId: event.listener_id, - connectionId: event.connection_id, - streamId: event.stream_id, - code: event.code, - status: event.status, - payload, - }; - if (decoded.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION || decoded.type === CULTMESH_QUIC_EVENT_LISTENER_STOPPED) { - try { - this.listenerListeners.get(decoded.listenerId)?.(decoded); - } catch (error) { - // No fault contract for listener-level events today; do not let a - // throwing handler crash the pump. - this.reportUnhandledDispatchError(error); - } - return; - } - const entry = this.connectionListeners.get(decoded.connectionId); - if (!entry) return; - try { - entry.listener(decoded); - } catch (error) { - this.faultConnection(decoded.connectionId, error instanceof Error ? error : new Error(String(error)), entry.onFault); - } - } - - private faultConnection(connectionId: bigint, error: Error, onFault: NativeFaultHandler | undefined): void { - this.connectionListeners.delete(connectionId); - try { - this.lib.connectionShutdownFn(this.handle, connectionId, 0n); - } catch { - // Best-effort: the connection may already be gone. - } - if (onFault) { - try { - onFault(error); - } catch (handlerError) { - // The fault handler itself is caller-supplied code; a throw here - // must not escape into the pump any more than the original fault did. - this.reportUnhandledDispatchError(handlerError); - } - } else { - this.reportUnhandledDispatchError(error); - } - } - - private reportUnhandledDispatchError(error: unknown): void { - // eslint-disable-next-line no-console - console.error("CultMesh QUIC native event dispatch failed with no fault handler registered:", error); - } - - private async nextEvent( - timeoutMs: number, - payload: Uint8Array | null, - payloadCapacity: number, - ): Promise<{ status: number; event: NativeEventStruct | null; required: number }> { - // Reuses the buffers allocated once in the constructor instead of - // allocating fresh ones per call: the pump awaits each `nextEventAsync` - // before issuing the next one, so at most one call is ever in flight - // against them, and both are fully decoded here before the buffers can - // be reused by the pump's next call. - const status = await this.lib.nextEventAsync( - this.handle, - timeoutMs, - this.outEventBuffer, - payload, - payloadCapacity, - this.outRequiredBuffer, - ); - const decoded = - status === 1 ? (this.lib.koffi.decode(this.outEventBuffer, this.lib.event) as NativeEventStruct) : null; - return { - status, - event: decoded && { - ...decoded, - listener_id: toBigInt64(decoded.listener_id), - connection_id: toBigInt64(decoded.connection_id), - stream_id: toBigInt64(decoded.stream_id), - code: toBigInt64(decoded.code), - }, - required: this.lib.koffi.decode(this.outRequiredBuffer, "int32_t") as number, - }; - } - - private async pump(): Promise { - while (!this.closed) { - let first: Awaited>; - try { - first = await this.nextEvent(250, null, 0); - } catch { - if (this.closed) return; - continue; - } - if (this.closed) return; - if (first.status === 0 || first.status === CULTMESH_QUIC_RESULT_BAD_CALL) continue; - if (first.status === 1 && first.event) { - this.dispatch(first.event, new Uint8Array(0)); - continue; - } - if (first.status === 2 && first.required > 0) { - const buffer = Buffer.alloc(first.required); - let second: Awaited>; - try { - second = await this.nextEvent(0, buffer, buffer.length); - } catch { - if (this.closed) return; - continue; - } - if (second.status === 1 && second.event) { - this.dispatch(second.event, buffer.subarray(0, second.event.payload_length)); - } - continue; - } - } - } -} diff --git a/packages/cultmesh-ts/src/realtime-quic.ts b/packages/cultmesh-ts/src/realtime-quic.ts deleted file mode 100644 index c368c96f..00000000 --- a/packages/cultmesh-ts/src/realtime-quic.ts +++ /dev/null @@ -1,1329 +0,0 @@ -// The Node QUIC realtime plane: a consumer transport and connector over the -// native v2 bridge, and the session manager that binds an advertised route to -// a connected, authority-verified transport. -// -// Provider and consumer semantics (which stream kind carries which delivery -// mode, the generation filter, the certificate pin decision) are pinned rule -// for rule against the C# reference, `src/GameCult.Mesh.Quic/CultMeshQuicRealtimeTransport.cs` -// (the connector) and `src/GameCult.Mesh/CultMeshSessions.cs` (the session -// manager's binding, ordering and racing). -// -// This module never touches `koffi` directly; every native call goes through -// `CultMeshQuicNativeRuntime` in `realtime-quic-native.ts`, which loads it -// lazily. No Odin WebSocket rendezvous lives here (Q7, ruled A): a lookup -// source is a port this module takes, not a transport it owns. - -import { createHash } from "node:crypto"; -import { createSecureContext } from "node:tls"; - -import { - routeCertificateView, - trimCSharp, - verifyAuthorityRoute, - type CultMeshAuthorityTrustPolicy, -} from "cultnet-ts"; -import type { CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; - -import { - decodeRealtimeFrame, - encodeRealtimeFrame, - CULTMESH_REALTIME_CONNECTION_CLOSE_CODE, - type CultMeshRealtimeFrame, -} from "./realtime-wire"; -import { - CultMeshQuicNativeRuntime, - CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, - CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED, - CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN, - CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, - CULTMESH_QUIC_EVENT_STREAM_FRAME, - CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE, - CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN, - CULTMESH_QUIC_EVENT_STREAM_STARTED, - CULTMESH_QUIC_SEND_CANCELED, - CULTMESH_QUIC_STREAM_LATEST_ONLY, - CULTMESH_QUIC_STREAM_RELIABLE, - type CultMeshQuicNativeEvent, -} from "./realtime-quic-native"; - -/** `CultMeshProtocols.RealtimeState.Value` in the C# reference. */ -export const CULTMESH_REALTIME_STATE_PROTOCOL_ID = "cultmesh.realtime_state.v1"; - -/** The QUIC realtime connector's discovery scheme; `CultMeshQuicRealtimeTransportConnector.Scheme`. */ -export const CULTMESH_QUIC_REALTIME_SCHEME = "cultmesh-state+quic"; - -/** Stable provider identity inside one Verse; `CultMeshSessionTarget` in the C# reference. */ -export interface CultMeshRealtimeTarget { - readonly verseId: string; - readonly authorityRuntimeId: string; -} - -/** A physical route candidate; the TS analogue of `CultMeshTransportCandidate`. */ -export interface CultMeshRealtimeCandidate { - readonly endpoint: string; - readonly authorityRuntimeId: string; - readonly priority: number; - readonly generation: string; -} - -/** Transport-neutral realtime plane; the TS analogue of `ICultMeshRealtimeTransport`. */ -export interface CultMeshRealtimeTransport { - readonly transportId: string; - readonly endpoint: string; - sendFrame(frame: CultMeshRealtimeFrame): Promise; - receiveFrame(signal?: AbortSignal): Promise; - dispose(): void; - isVerifiedFor( - verseId: string, - authorityRuntimeId: string, - protocolId: string, - routeGeneration: string, - ): boolean; - /** - * Registers a handler invoked once this transport becomes unusable for any - * reason (explicit `dispose()`, a peer-initiated shutdown, or an internal - * fault). A session manager uses this to evict a dead cached session so the - * next `connect()` redials, mirroring `CultMeshSessions.InvalidateRealtimeSession`. - */ - onDisposed?(handler: () => void): void; -} - -/** Creates realtime transports; the TS analogue of `ICultMeshRealtimeTransportConnector`. */ -export interface CultMeshRealtimeTransportConnector { - readonly connectorId: string; - readonly priority: number; - canConnect(candidate: CultMeshRealtimeCandidate): boolean; - connect( - candidate: CultMeshRealtimeCandidate, - target: CultMeshRealtimeTarget, - signal?: AbortSignal, - ): Promise; -} - -/** A route source the session manager resolves against; Q7's lookup-source port. */ -export interface ICultMeshRealtimeLookupSource { - resolve(target: CultMeshRealtimeTarget): Promise; -} - -/** `CultMeshQuicRealtimeTransportConnector.TryParseEndpoint` (`:98-109`). */ -export function parseQuicRealtimeEndpoint( - endpoint: string, -): { host: string; port: number; certificateSha256?: string } { - let parsed: URL; - try { - parsed = new URL(endpoint); - } catch { - throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); - } - if (parsed.protocol.toLowerCase() !== `${CULTMESH_QUIC_REALTIME_SCHEME}:`) { - throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); - } - if (!parsed.hostname) { - throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); - } - const port = parsed.port ? Number.parseInt(parsed.port, 10) : Number.NaN; - if (!Number.isInteger(port) || port <= 0) { - throw new Error(`QUIC realtime connector does not support '${endpoint}'.`); - } - const pin = parsed.searchParams.get("cert-sha256") ?? undefined; - return { host: parsed.hostname, port, certificateSha256: pin ?? undefined }; -} - -function certificateSha256Hex(der: Uint8Array): string { - return createHash("sha256").update(der).digest("hex"); -} - -/** Shared by `CultMeshQuicRealtimeTransport.sendFrame` and `CultMeshQuicRealtimeProvider.broadcast`: one message, not two copies that can drift. */ -const CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE = - "The native MsQuic connector exposes streams but not QUIC datagrams; unreliable delivery is not supported."; - -interface PendingSend { - resolve(): void; - reject(error: Error): void; -} - -interface LatestGeneration { - producerEpoch: bigint; - sequence: bigint; -} - -function compareGeneration(a: LatestGeneration, b: LatestGeneration): number { - if (a.producerEpoch !== b.producerEpoch) return a.producerEpoch < b.producerEpoch ? -1 : 1; - if (a.sequence === b.sequence) return 0; - return a.sequence < b.sequence ? -1 : 1; -} - -/** A direct entry, or a coalesced `key` to resolve against `pending` at delivery time. */ -type InboxToken = { readonly value: T } | { readonly key: string }; - -/** - * Mirrors the C# reference's `CultMeshRealtimeInbox` - * (`CultMeshQuicRealtimeTransport.cs:598-644`): publishing under `key` - * coalesces to at most one pending entry per key, keeping that key's - * first-arrival queue position while a later publish overwrites its value; - * publishing with no `key` always queues, unbounded. `receive` drains - * whatever is queued before a `complete(error)` rejects further calls. - * Shared by the transport's inbound queue, the provider's fan-in, and (via - * a pump reading it instead of `receive` callers) each peer's outbox. - */ -class CultMeshRealtimeInbox { - private readonly pending = new Map(); - private readonly ready: InboxToken[] = []; - private readonly waiters: { resolve(value: T): void; reject(error: Error): void }[] = []; - private completed = false; - private terminalError: Error | undefined; - - /** Entries queued but not yet delivered: one per ready key, or one per unkeyed entry. Test-visible. */ - get size(): number { - return this.ready.length; - } - - /** Publishes `value`, coalescing on `key` when given. Returns `false` once `complete()` has run. */ - publish(value: T, key?: string): boolean { - if (this.completed) return false; - if (key === undefined) { - this.deliver({ value }); - return true; - } - const alreadyPending = this.pending.has(key); - this.pending.set(key, value); - if (!alreadyPending) this.deliver({ key }); - return true; - } - - /** Resolves the oldest ready entry, or waits for one; rejects once `complete(error)` has drained the backlog. */ - async receive(signal?: AbortSignal): Promise { - if (this.ready.length > 0) return this.take(this.ready.shift()!); - if (this.completed) throw this.terminalError ?? new Error("CultMesh realtime inbox is completed."); - if (signal?.aborted) throw new Error("CultMesh realtime inbox receive aborted."); - return await new Promise((resolve, reject) => { - const onAbort = (): void => { - const index = this.waiters.indexOf(waiter); - if (index >= 0) this.waiters.splice(index, 1); - reject(new Error("CultMesh realtime inbox receive aborted.")); - }; - const waiter = { - resolve: (value: T) => { - signal?.removeEventListener("abort", onAbort); - resolve(value); - }, - reject: (error: Error) => { - signal?.removeEventListener("abort", onAbort); - reject(error); - }, - }; - signal?.addEventListener("abort", onAbort, { once: true }); - this.waiters.push(waiter); - }); - } - - /** Completes the inbox: entries already queued still drain via `receive`; every call after that rejects with `error`. */ - complete(error?: Error): void { - if (this.completed) return; - this.completed = true; - this.terminalError = error; - for (const waiter of this.waiters.splice(0, this.waiters.length)) { - waiter.reject(error ?? new Error("CultMesh realtime inbox is completed.")); - } - } - - private deliver(token: InboxToken): void { - const waiter = this.waiters.shift(); - if (waiter) { - waiter.resolve(this.take(token)); - return; - } - this.ready.push(token); - } - - private take(token: InboxToken): T { - if ("value" in token) return token.value; - const value = this.pending.get(token.key)!; - this.pending.delete(token.key); - return value; - } -} - -/** Configures certificate acceptance for an outbound QUIC realtime connection. */ -export interface CultMeshQuicRealtimeConnectorOptions { - readonly handshakeTimeoutMs?: number; - readonly validateProviderCertificate?: ( - target: CultMeshRealtimeTarget, - der: Uint8Array, - ) => boolean; -} - -/** Creates Node-native MsQuic realtime connections. `ConnectorId` is `"msquic-realtime"`. */ -export class CultMeshQuicRealtimeConnector implements CultMeshRealtimeTransportConnector { - private readonly options: CultMeshQuicRealtimeConnectorOptions; - - constructor(options: CultMeshQuicRealtimeConnectorOptions = {}) { - this.options = options; - } - - readonly connectorId = "msquic-realtime"; - readonly priority = 0; - - canConnect(candidate: CultMeshRealtimeCandidate): boolean { - try { - parseQuicRealtimeEndpoint(candidate.endpoint); - return true; - } catch { - return false; - } - } - - async connect( - candidate: CultMeshRealtimeCandidate, - target: CultMeshRealtimeTarget, - signal?: AbortSignal, - ): Promise { - if (signal?.aborted) { - throw new Error("CultMesh QUIC connect aborted."); - } - const { host, port, certificateSha256 } = parseQuicRealtimeEndpoint(candidate.endpoint); - const runtime = await CultMeshQuicNativeRuntime.open(); - let connectionId: bigint; - try { - connectionId = runtime.connectionOpen(host, port); - } catch (error) { - await runtime.release(); - throw error; - } - - const handshakeTimeoutMs = this.options.handshakeTimeoutMs ?? 10_000; - const validator = this.options.validateProviderCertificate; - - return await new Promise((resolve, reject) => { - let settled = false; - - // Every failure path below — an already-aborted signal, the handshake - // timeout, a later abort, and any handshake-time error — settles here - // exactly once: shut the native connection down, drop the runtime - // reference, and reject. - const fail = (error: Error): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - runtime.offConnectionEvent(connectionId); - runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); - void runtime.release(); - reject(error); - }; - - const succeed = (transport: CultMeshRealtimeTransport): void => { - if (settled) return; - settled = true; - clearTimeout(timer); - signal?.removeEventListener("abort", onAbort); - resolve(transport); - }; - - // Honour a signal already aborted by the time we reach here (it fired - // before `addEventListener` could observe it, so it never would). - if (signal?.aborted) { - fail(new Error("CultMesh QUIC connect aborted.")); - return; - } - - const timer = setTimeout( - () => fail(new Error(`CultMesh QUIC handshake with '${candidate.endpoint}' timed out.`)), - handshakeTimeoutMs, - ); - const onAbort = (): void => fail(new Error("CultMesh QUIC connect aborted.")); - signal?.addEventListener("abort", onAbort, { once: true }); - - // Told by the runtime when this connection faults mid-dispatch (a - // throwing validator during the handshake, or a malformed frame / - // stream-kind mismatch afterwards): reject the still-pending connect, - // or hand the fault to the connected transport. - const onFault = (error: Error): void => { - if (!settled) { - fail(error); - return; - } - transport.fault(error); - }; - - const transport = new CultMeshQuicRealtimeTransport( - candidate.endpoint, - runtime, - connectionId, - target, - candidate.generation, - ); - - runtime.onConnectionEvent( - connectionId, - (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - const der = event.payload; - const accepted = validator - ? validator(target, der) - : certificateSha256 !== undefined && - certificateSha256Hex(der).toLowerCase() === certificateSha256.toLowerCase(); - try { - runtime.connectionCertificateComplete(connectionId, accepted); - } catch (error) { - fail(error instanceof Error ? error : new Error(String(error))); - return; - } - if (!accepted) { - fail(new Error(`CultMesh QUIC provider certificate for '${candidate.endpoint}' was rejected.`)); - } - return; - } - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN && !settled) { - const reason = Buffer.from(event.payload).toString("utf8"); - fail(new Error(`CultMesh QUIC connection closed during handshake: ${reason || event.code}`)); - return; - } - transport.handleConnectionEvent(event); - if (!settled && event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) { - succeed(transport); - } - }, - onFault, - ); - }); - } -} - -/** - * `CultMeshQuicRealtimeTransport`, over the native v2 bridge. `TransportId` - * is `"msquic-realtime"`. - */ -export class CultMeshQuicRealtimeTransport implements CultMeshRealtimeTransport { - readonly transportId = "msquic-realtime"; - readonly endpoint: string; - - private readonly runtime: CultMeshQuicNativeRuntime; - private readonly connectionId: bigint; - private readonly target: CultMeshRealtimeTarget; - private readonly routeGeneration: string; - /** Serializes writes to the reliable-ordered outbound stream: each send chains onto this tail. */ - private reliableSendTail: Promise = Promise.resolve(); - private readonly pendingSends = new Map(); - /** Peer-opened stream id to its declared kind, pruned on `STREAM_SHUTDOWN`; see `trackedStreamCount`. */ - private readonly streamKinds = new Map(); - /** Generation staleness filter: the newest `(channel, body)` generation delivered so far. */ - private readonly latestGenerations = new Map(); - /** Inbound frame queue: bounded to one pending latest-only frame per key. */ - private readonly inbox = new CultMeshRealtimeInbox(); - private reliableOutboundStreamId: bigint | undefined; - private disposed = false; - private terminalError: Error | undefined; - private readonly disposalHandlers = new Set<() => void>(); - - constructor( - endpoint: string, - runtime: CultMeshQuicNativeRuntime, - connectionId: bigint, - target: CultMeshRealtimeTarget, - routeGeneration: string, - ) { - this.endpoint = endpoint; - this.runtime = runtime; - this.connectionId = connectionId; - this.target = target; - this.routeGeneration = routeGeneration ?? ""; - } - - isVerifiedFor( - verseId: string, - authorityRuntimeId: string, - protocolId: string, - routeGeneration: string, - ): boolean { - return ( - this.target.verseId === verseId && - this.target.authorityRuntimeId === authorityRuntimeId && - CULTMESH_REALTIME_STATE_PROTOCOL_ID === protocolId && - this.routeGeneration === routeGeneration - ); - } - - /** Number of peer-opened streams still tracked for their kind. Test-visible: proves `STREAM_SHUTDOWN` prunes `streamKinds` instead of leaking one entry per stream for the life of the connection. */ - get trackedStreamCount(): number { - return this.streamKinds.size; - } - - async sendFrame(frame: CultMeshRealtimeFrame): Promise { - if (this.disposed) throw new Error("CultMesh QUIC realtime transport is disposed."); - if (frame.delivery === "unreliable") { - throw new Error(CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE); - } - return this.sendEncodedFrame(frame.delivery, encodeRealtimeFrame(frame)); - } - - /** - * @internal The primitive `sendFrame` itself calls, after encoding. Exposed - * so a caller that already holds the encoded bytes for many peers at once - * — `CultMeshQuicRealtimeProvider.broadcast` fanning a frame out to every - * peer, and each peer's `CultMeshQuicRealtimeProviderOutbox` pump — sends - * them without re-encoding (and re-validating) per peer. - */ - async sendEncodedFrame(delivery: "reliable-ordered" | "latest-only", encoded: Uint8Array): Promise { - if (this.disposed) throw new Error("CultMesh QUIC realtime transport is disposed."); - if (delivery === "reliable-ordered") { - const send = this.reliableSendTail.then(async () => { - if (this.reliableOutboundStreamId === undefined) { - this.reliableOutboundStreamId = this.runtime.streamOpen( - this.connectionId, - CULTMESH_QUIC_STREAM_RELIABLE, - ); - } - await this.sendOnStream(this.reliableOutboundStreamId, encoded, false); - }); - // The tail must advance even if this send rejects, so the next queued - // send is not blocked forever on a settled promise; the caller still - // observes the rejection through `send` itself. - this.reliableSendTail = send.catch(() => {}); - return send; - } - // latest-only: a fresh stream per send, mirroring the C# reference. - const streamId = this.runtime.streamOpen(this.connectionId, CULTMESH_QUIC_STREAM_LATEST_ONLY); - await this.sendOnStream(streamId, encoded, true); - } - - /** - * A pending send registered after `cleanup()` already ran (a `sendFrame` - * queued behind `reliableSendTail` whose microtask lands after `dispose()` - * or a fault settles this transport) would otherwise sit in `pendingSends` - * forever: `cleanup()`'s own reject sweep already ran once and is guarded - * against running again, and `offConnectionEvent` means no later native - * event can resolve it either. Checking `this.disposed` here closes that - * window: any send that loses the race against disposal rejects - * immediately instead of registering an entry nothing will ever settle. - */ - private sendOnStream(streamId: bigint, encoded: Uint8Array, fin: boolean): Promise { - return new Promise((resolve, reject) => { - if (this.disposed) { - reject(this.terminalError ?? new Error("CultMesh QUIC realtime transport is disposed.")); - return; - } - this.pendingSends.set(streamId, { resolve, reject }); - try { - this.runtime.streamSendFrame(streamId, encoded, fin); - } catch (error) { - this.pendingSends.delete(streamId); - reject(error instanceof Error ? error : new Error(String(error))); - } - }); - } - - async receiveFrame(signal?: AbortSignal): Promise { - return this.inbox.receive(signal); - } - - /** Registers a handler invoked once, when this transport becomes unusable. */ - onDisposed(handler: () => void): void { - if (this.disposed) { - handler(); - return; - } - this.disposalHandlers.add(handler); - } - - dispose(): void { - if (this.disposed) return; - try { - this.runtime.connectionShutdown(this.connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); - } catch { - // Best-effort: the connection may already be gone. - } - this.cleanup(); - } - - /** - * @internal Called by the connector when the native runtime faults this - * connection mid-dispatch (a malformed frame, a stream-kind mismatch, or a - * throwing caller callback). Mirrors the C# reference's - * `_received.Complete(error)` / `_shutdown.Cancel()`: pending and future - * `receiveFrame` calls reject with `error`, and the connection is torn - * down. The native side has already shut the connection down and removed - * its listener by the time this runs. - */ - fault(error: Error): void { - this.cleanup(error); - } - - /** - * Runs exactly once, however the transport becomes unusable: explicit - * `dispose()`, a peer-initiated `CONNECTION_SHUTDOWN`, or `fault()`. - * Releases the runtime reference before running any `onDisposed` handler, - * so a handler that throws (or blocks) can never delay or skip the - * release; every handler runs in its own try/catch, so one throwing - * handler cannot stop the rest from running. Also rejects every pending - * and future receive/send, so none of those paths can leak the reference - * or hang a waiter forever. - */ - private cleanup(error?: Error): void { - if (this.disposed) return; - this.disposed = true; - this.terminalError = error; - this.runtime.offConnectionEvent(this.connectionId); - const rejection = error ?? new Error("CultMesh QUIC realtime transport is disposed."); - for (const [, pending] of this.pendingSends) pending.reject(rejection); - this.pendingSends.clear(); - this.inbox.complete(rejection); - void this.runtime.release(); - const handlers = [...this.disposalHandlers]; - this.disposalHandlers.clear(); - for (const handler of handlers) { - try { - handler(); - } catch (handlerError) { - // eslint-disable-next-line no-console - console.error("CultMesh QUIC realtime transport onDisposed handler threw:", handlerError); - } - } - } - - /** @internal Routed here by the connector's connection-event registration. */ - handleConnectionEvent(event: CultMeshQuicNativeEvent): void { - switch (event.type) { - case CULTMESH_QUIC_EVENT_STREAM_STARTED: - this.streamKinds.set(event.streamId, event.streamKind); - return; - case CULTMESH_QUIC_EVENT_STREAM_FRAME: - this.onStreamFrame(event); - return; - case CULTMESH_QUIC_EVENT_STREAM_SHUTDOWN: - this.streamKinds.delete(event.streamId); - return; - case CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE: { - const pending = this.pendingSends.get(event.streamId); - if (!pending) return; - this.pendingSends.delete(event.streamId); - if (event.code === BigInt(CULTMESH_QUIC_SEND_CANCELED)) { - pending.reject(new Error("CultMesh QUIC send was canceled.")); - } else { - pending.resolve(); - } - return; - } - case CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN: - this.cleanup(new Error("CultMesh QUIC connection was closed by the remote peer.")); - return; - default: - return; - } - } - - /** - * `ReadStreamAsync`'s stream-kind/delivery consistency check (`:519-524`). - * Throwing here is intentional: the native runtime's dispatch loop (fix 1) - * catches it, faults only this connection, and keeps the pump and every - * other connection running. - */ - private onStreamFrame(event: CultMeshQuicNativeEvent): void { - const kind = this.streamKinds.get(event.streamId); - const frame = decodeRealtimeFrame(event.payload); - if (kind === CULTMESH_QUIC_STREAM_RELIABLE && frame.delivery !== "reliable-ordered") { - throw new Error("Reliable QUIC stream carried incompatible delivery semantics."); - } - if (kind === CULTMESH_QUIC_STREAM_LATEST_ONLY && frame.delivery !== "latest-only") { - throw new Error("Latest-only QUIC stream carried incompatible delivery semantics."); - } - this.publishReceived(frame); - } - - /** - * `PublishReceived`'s per-`(channel, body)` generation filter (`:538-560`), - * plus the inbox's own coalescing: a latest-only frame overwrites the - * still-pending frame for its key rather than queuing beside it, so an - * unread backlog holds at most one frame per `(channel, body)`. - * Reliable-ordered frames queue directly, unbounded, exactly as the C# - * reference's unbounded channel does. - */ - private publishReceived(frame: CultMeshRealtimeFrame): void { - if (frame.delivery === "latest-only") { - const key = frame.channelId + "\u001f" + frame.bodyId; - const candidate: LatestGeneration = { producerEpoch: frame.producerEpoch, sequence: frame.sequence }; - const current = this.latestGenerations.get(key); - if (current && compareGeneration(candidate, current) <= 0) return; - this.latestGenerations.set(key, candidate); - this.inbox.publish(frame, key); - return; - } - this.inbox.publish(frame); - } -} - -/** - * `TryPublishLatest` plus `SendPublishedFramesAsync` (`:363-367`, `:421-439`), - * over a coalescing outbox (`:572-618`). Owns one peer's outbound - * `latest-only` traffic: `publish` never blocks (it just replaces whatever is - * still pending for the frame's `(channel, body)` key and, if a pump is not - * already draining, starts one), and the pump opens a fresh kind-2 stream per - * frame through the peer's own `sendFrame` (unchanged from the consumer path - * above), sending with `fin` and waiting for `send_complete` before it takes - * the next ready key. A stalled peer therefore accumulates at most one - * pending frame per key and never blocks a caller's `broadcast`. A send - * failure evicts the peer once, the same way a reliable-ordered send failure - * does in `CultMeshQuicRealtimeProvider.broadcast`. - */ -interface OutboxEntry { - readonly frame: CultMeshRealtimeFrame; - readonly encoded: Uint8Array; -} - -class CultMeshQuicRealtimeProviderOutbox { - private readonly inbox = new CultMeshRealtimeInbox(); - private readonly transport: CultMeshQuicRealtimeTransport; - private readonly onSendFailure: (error: Error) => void; - private pumping = false; - private disposed = false; - - constructor(transport: CultMeshQuicRealtimeTransport, onSendFailure: (error: Error) => void) { - this.transport = transport; - this.onSendFailure = onSendFailure; - } - - /** - * Enqueues an already-encoded `frame`, coalescing on `(channelId, - * bodyId)`. Never blocks. Takes the encoded bytes rather than encoding its - * own copy so every peer's outbox reuses the one encode `broadcast` (or a - * retained-frame seed) already did for this frame. - */ - publish(frame: CultMeshRealtimeFrame, encoded: Uint8Array): void { - if (this.disposed) return; - const key = frame.channelId + "\u001f" + frame.bodyId; - this.inbox.publish({ frame, encoded }, key); - if (!this.pumping) void this.pump(); - } - - dispose(): void { - this.disposed = true; - this.inbox.complete(); - } - - /** One in flight per peer: the next ready key is taken only after `send_complete`, mirroring `SendPublishedFramesAsync`. */ - private async pump(): Promise { - this.pumping = true; - try { - for (;;) { - if (this.disposed) return; - let entry: OutboxEntry; - try { - entry = await this.inbox.receive(); - } catch { - return; - } - try { - await this.transport.sendEncodedFrame("latest-only", entry.encoded); - } catch (error) { - this.dispose(); - this.onSendFailure(error instanceof Error ? error : new Error(String(error))); - return; - } - } - } finally { - this.pumping = false; - } - } -} - -/** Stable placeholder target for a provider's accepted peer connections: `isVerifiedFor` is a consumer-side concept the provider never calls. */ -const CULTMESH_PROVIDER_PEER_TARGET: CultMeshRealtimeTarget = { verseId: "", authorityRuntimeId: "" }; - -/** Configures a `CultMeshQuicRealtimeProvider` listener. */ -export interface CultMeshQuicRealtimeProviderOptions { - readonly host: string; - readonly port: number; - readonly serverCertificate: { readonly pkcs12: Uint8Array; readonly password?: string }; - readonly handshakeTimeoutMs?: number; - /** - * The host advertised in `advertisedEndpoint`, when it differs from the - * bind address `host` — the usual case for a public service: `host` is - * typically `0.0.0.0`, an empty string, or an IPv6 wildcard (`::`), none of - * which describe a route a client can dial, and the native listener (which - * `host` also configures) only accepts IP addresses in the first place, not - * a DNS name. Defaults to `host`. - */ - readonly advertisedHost?: string; - /** - * When `false` (the default: every existing caller, test, and interop - * peer calls `receive()`), the provider fans client-originated streams - * into `receive()` as before. When `true`, the provider is publish-only: - * any stream a peer opens faults that connection at `STREAM_STARTED`, - * before a frame can be decoded or queued, so no inbox, generation-map, - * or fan-in entry is ever created for client data; `receive()` itself - * rejects. StreamPixels sets this explicitly — it closes the unbounded - * per-client memory growth Soul's probe found. - */ - readonly acceptClientFrames?: boolean; - /** - * Refuses a new connection at accept once the number of connections this - * provider is tracking (including mid-handshake ones) has reached this - * many: shut down immediately, before any transport, outbox, or - * retained-frame seeding is allocated for it. Existing peers are - * unaffected. Defaults to 1024 — far more than any single StreamPixels - * fan-out needs today, while still bounding per-connection overhead - * against a public UDP port whose pin authenticates only the server, so - * any internet client can dial in. StreamPixels sets its own value. - */ - readonly maxConnections?: number; -} - -/** - * Brackets an IPv6 literal for use in a URL authority (`[::1]:443`), the way - * `URL` itself renders one; a hostname or IPv4 literal passes through - * unchanged, and an already-bracketed literal is left alone. - */ -function formatEndpointHost(host: string): string { - if (host.startsWith("[")) return host; - return host.includes(":") ? `[${host}]` : host; -} - -/** - * Recovers the leaf certificate's DER bytes from a PKCS12 credential, to - * compute the pin a provider advertises. Node has no public PKCS12 decoder; - * `tls.createSecureContext` loads the PKCS12 through OpenSSL, and its - * internal `context.getCertificate()` is the only way to read back the DER - * bytes OpenSSL parsed out of it. Probed against the pinned Node 24 build and - * this package's own test fixture pair (`test/fixtures/quic-test.p12` and - * `quic-test-cert.der`, generated independently by `openssl pkcs12 -export`): - * the returned buffer is byte-identical to the `.der` file and its SHA-256 - * matches the pin the fixture's tests already assert. - */ -function extractLeafCertificateDer(serverCertificate: CultMeshQuicRealtimeProviderOptions["serverCertificate"]): Uint8Array { - const context = createSecureContext({ - pfx: Buffer.from(serverCertificate.pkcs12), - passphrase: serverCertificate.password ?? "", - }) as unknown as { context: { getCertificate(): Buffer | undefined } }; - const der = context.context.getCertificate(); - if (!der || der.length === 0) { - throw new Error("CultMesh QUIC provider certificate has no parseable leaf certificate."); - } - return new Uint8Array(der); -} - -/** - * The StreamPixels role: a TypeScript listener that broadcasts with an - * explicit delivery mode and cannot be backpressured by a slow consumer. - * Rule for rule, this is `CultMeshQuicRealtimeServer` (`CultMeshQuicRealtimeTransport.cs:151-310`): - * `listen` opens a listener and computes the advertised endpoint; - * `broadcast` mirrors `BroadcastAsync` (`:216-235`); `receive` mirrors - * `ReceiveAsync` fanned in from every accepted peer's inbox - * (`CultMeshRealtimeInbox.ReceiveAsync`, `:597-609`); peers are evicted on - * send failure or shutdown, and there is no reconnect logic here, matching - * the C# reference's `AcceptLoopAsync` (`:276-309`). - */ -export class CultMeshQuicRealtimeProvider { - /** `cultmesh-state+quic://:?cert-sha256=`, uppercase — the Unity connector requires the pin. */ - readonly advertisedEndpoint: string; - - private readonly runtime: CultMeshQuicNativeRuntime; - private readonly listenerId: bigint; - private readonly handshakeTimeoutMs: number; - private readonly peers = new Set(); - private readonly outboxes = new Map(); - /** The newest `latest-only` frame broadcast so far per `(channel, body)` key, already - * encoded, seeded into every peer attached after that broadcast. Reliable-ordered frames - * are events, not state, and are never retained. */ - private readonly retained = new Map(); - /** Client-to-provider fan-in, bounded the same way as the transport's inbound queue. */ - private readonly received = new CultMeshRealtimeInbox(); - /** - * Every transport `acceptConnection` has created for a still-live native - * connection, whether or not its handshake has reached CONNECTED and - * joined `peers` yet. A connection whose handshake never reaches CONNECTED - * (a rejected certificate, a stalled client) would otherwise sit on its - * own handshake timer — and hold a runtime reference — for up to - * `handshakeTimeoutMs` after the provider itself was disposed, keeping the - * process alive over a connection nothing can still reach; `dispose()` - * sweeps this set instead of only `peers` to close that gap. - */ - private readonly accepted = new Set(); - private readonly acceptClientFrames: boolean; - private readonly maxConnections: number; - private disposed = false; - - private constructor( - runtime: CultMeshQuicNativeRuntime, - listenerId: bigint, - advertisedEndpoint: string, - handshakeTimeoutMs: number, - acceptClientFrames: boolean, - maxConnections: number, - ) { - this.runtime = runtime; - this.listenerId = listenerId; - this.advertisedEndpoint = advertisedEndpoint; - this.handshakeTimeoutMs = handshakeTimeoutMs; - this.acceptClientFrames = acceptClientFrames; - this.maxConnections = maxConnections; - } - - /** Number of currently accepted peer connections. */ - get connectionCount(): number { - return this.peers.size; - } - - /** Frames fanned in from peers but not yet drained by `receive()`. Test-visible: proves the fan-in queue is bounded. */ - get receiveQueueSize(): number { - return this.received.size; - } - - static async listen(options: CultMeshQuicRealtimeProviderOptions): Promise { - const handshakeTimeoutMs = options.handshakeTimeoutMs ?? 10_000; - const der = extractLeafCertificateDer(options.serverCertificate); - const pinHex = certificateSha256Hex(der).toUpperCase(); - const bindHost = options.host && options.host.length > 0 ? options.host : "127.0.0.1"; - const advertisedHost = options.advertisedHost && options.advertisedHost.length > 0 ? options.advertisedHost : bindHost; - - const runtime = await CultMeshQuicNativeRuntime.open(); - let listenerId: bigint; - let boundPort: number; - try { - ({ listenerId, boundPort } = runtime.listenerOpen( - options.host || null, - options.port, - options.serverCertificate.pkcs12, - options.serverCertificate.password ?? null, - )); - } catch (error) { - await runtime.release(); - throw error; - } - - const advertisedEndpoint = `${CULTMESH_QUIC_REALTIME_SCHEME}://${formatEndpointHost(advertisedHost)}:${boundPort}?cert-sha256=${pinHex}`; - const provider = new CultMeshQuicRealtimeProvider( - runtime, - listenerId, - advertisedEndpoint, - handshakeTimeoutMs, - options.acceptClientFrames ?? true, - options.maxConnections ?? 1024, - ); - runtime.onListenerEvent(listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) provider.acceptConnection(event.connectionId); - }); - return provider; - } - - /** - * `BroadcastAsync` (`:216-235`). `latest-only` enqueues into each peer's - * outbox and returns as soon as every peer has accepted the frame into its - * outbox — never once any peer's I/O. `reliable-ordered` awaits every - * peer's `send_complete` (or eviction) before resolving, matching - * `Task.WhenAll` over `SendToConnectedClientAsync` (`:233-255`). - */ - async broadcast(frame: CultMeshRealtimeFrame): Promise { - if (this.disposed) throw new Error("CultMesh QUIC realtime provider is disposed."); - if (frame.delivery === "unreliable") { - throw new Error(CULTMESH_QUIC_UNRELIABLE_UNSUPPORTED_MESSAGE); - } - // Encode (and validate) exactly once, up front: an invalid frame throws - // here, to the caller, before touching any peer. The old per-peer encode - // inside `sendFrame` meant an invalid frame failed identically for every - // peer in turn, evicting all of them while `broadcast` itself still - // reported success. - const encoded = encodeRealtimeFrame(frame); - if (frame.delivery === "latest-only") { - const key = frame.channelId + "\u001f" + frame.bodyId; - const current = this.retained.get(key); - const candidate: LatestGeneration = { producerEpoch: frame.producerEpoch, sequence: frame.sequence }; - if (!current || compareGeneration(candidate, current.generation) > 0) { - this.retained.set(key, { frame, encoded, generation: candidate }); - } - for (const outbox of this.outboxes.values()) outbox.publish(frame, encoded); - return; - } - const sends = [...this.peers].map(async (transport) => { - try { - await transport.sendEncodedFrame("reliable-ordered", encoded); - } catch { - transport.dispose(); - } - }); - if (sends.length > 0) await Promise.all(sends); - } - - /** The next client-originated frame, fanned in from every accepted peer. */ - async receive(signal?: AbortSignal): Promise { - if (!this.acceptClientFrames) { - throw new Error( - "CultMesh QUIC realtime provider is publish-only (acceptClientFrames: false); it never accepts client frames.", - ); - } - return this.received.receive(signal); - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - try { - this.runtime.listenerClose(this.listenerId); - } catch { - // Best-effort: the listener may already be gone. - } - // `accepted` is every transport this provider ever created for a native - // connection, from the moment `acceptConnection` synchronously builds - // it — whether or not its handshake ever reached CONNECTED and joined - // `peers`. A still-handshaking connection has no other owner to sweep it. - for (const transport of [...this.accepted]) transport.dispose(); - this.received.complete(new Error("CultMesh QUIC realtime provider is disposed.")); - void this.runtime.release(); - } - - /** Coalesces client-sent `latest-only` frames by `(channel, body)`, so a publish-only `receive()` caller (StreamPixels) backs up to at most one pending frame per key. */ - private async pumpReceivedFrom(transport: CultMeshQuicRealtimeTransport): Promise { - for (;;) { - let frame: CultMeshRealtimeFrame; - try { - frame = await transport.receiveFrame(); - } catch { - return; // The transport is disposed/faulted; nothing more to read. - } - if (frame.delivery === "latest-only") { - this.received.publish(frame, frame.channelId + "\u001f" + frame.bodyId); - } else { - this.received.publish(frame); - } - } - } - - /** - * Handles `LISTENER_NEW_CONNECTION`, fully synchronously: one handler owns - * this connection from this point on, so there is no async window in which - * a NEW_CONNECTION-then-CONNECTED pair (the bridge's own event order, - * `native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp:731-757,627`) - * can be split across a temporary buffering handler and a later real one. - * `this.runtime.retain()` takes a reference on the provider's own - * already-open runtime instead of `await`ing a fresh - * `CultMeshQuicNativeRuntime.open()`, which is what used to force that - * split: the previous async gap left `CONNECTION_CONNECTED` dispatched - * straight to `transport.handleConnectionEvent` (which has no case for - * it) with nothing to clear the handshake timer, so a healthy peer was - * evicted at `handshakeTimeoutMs` regardless of how well-behaved the - * client was. - */ - private acceptConnection(connectionId: bigint): void { - if (this.disposed) { - this.runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); - return; - } - if (this.accepted.size >= this.maxConnections) { - // Refused before any transport, timer, or outbox exists for it: - // existing accepted connections are untouched. - this.runtime.connectionShutdown(connectionId, CULTMESH_REALTIME_CONNECTION_CLOSE_CODE); - return; - } - - const transport = new CultMeshQuicRealtimeTransport( - `quic-peer-${connectionId}`, - this.runtime.retain(), - connectionId, - CULTMESH_PROVIDER_PEER_TARGET, - "", - ); - this.accepted.add(transport); - - const handshakeTimer = setTimeout(() => transport.dispose(), this.handshakeTimeoutMs); - transport.onDisposed(() => { - clearTimeout(handshakeTimer); - this.accepted.delete(transport); - this.peers.delete(transport); - this.outboxes.get(transport)?.dispose(); - this.outboxes.delete(transport); - }); - - // CONNECTED fires at most once per connection; `attached` guards against - // ever running the join-`peers` bookkeeping twice. - let attached = false; - this.runtime.onConnectionEvent( - connectionId, - (event) => { - // Publish-only: a peer-opened stream faults this connection alone, - // before `handleConnectionEvent` can record its stream kind or - // decode a frame off it. Throwing here drives the dispatch loop's - // own fault path (`CultMeshQuicNativeRuntime.dispatch`), which - // shuts the connection down and evicts it. - if (!this.acceptClientFrames && event.type === CULTMESH_QUIC_EVENT_STREAM_STARTED) { - throw new Error( - "CultMesh QUIC provider is publish-only (acceptClientFrames: false); it does not accept inbound streams from peers.", - ); - } - transport.handleConnectionEvent(event); - if (attached || event.type !== CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) return; - attached = true; - clearTimeout(handshakeTimer); - // Cut C's retained-frame seeding happens atomically here, before the - // peer joins `peers`: nothing can observe this transport as an - // attached peer with a not-yet-seeded outbox. - const outbox = new CultMeshQuicRealtimeProviderOutbox(transport, () => transport.dispose()); - for (const { frame, encoded } of this.retained.values()) outbox.publish(frame, encoded); - this.outboxes.set(transport, outbox); - this.peers.add(transport); - if (this.acceptClientFrames) void this.pumpReceivedFrom(transport); - }, - (error) => transport.fault(error), - ); - } -} - -/** `CultMeshStaticRealtimeLookupSource`: an array of pre-resolved Verse descriptors. */ -export class CultMeshStaticRealtimeLookupSource implements ICultMeshRealtimeLookupSource { - private readonly verses: readonly CultMeshVerseDescriptorMessage[]; - - constructor(verses: readonly CultMeshVerseDescriptorMessage[]) { - this.verses = verses; - } - - async resolve(target: CultMeshRealtimeTarget): Promise { - return this.verses.filter((verse) => verse.verseId === target.verseId); - } -} - -/** Options for `CultMeshQuicRealtimeSessionManager`. */ -export interface CultMeshQuicRealtimeSessionManagerOptions { - readonly lookupSource: ICultMeshRealtimeLookupSource; - readonly trust: CultMeshAuthorityTrustPolicy; - readonly connectors: readonly CultMeshRealtimeTransportConnector[]; - readonly maxRacedCandidates?: number; -} - -/** - * Binds an advertised route to a connected, authority-verified realtime - * transport. Rule for rule, this is `CultMeshSessionManager`'s realtime path - * in the C# reference (`ConnectRealtimeAsync`, `:833-896`). - */ -export class CultMeshQuicRealtimeSessionManager { - private readonly options: CultMeshQuicRealtimeSessionManagerOptions; - private readonly sessions = new Map(); - /** Single-flight: concurrent `connect()` calls for the same key share one dial. */ - private readonly connecting = new Map>(); - /** One controller per in-flight dial, so `dispose()` and `disconnect()` can cancel it. */ - private readonly dialAborts = new Map(); - private disposed = false; - - constructor(options: CultMeshQuicRealtimeSessionManagerOptions) { - if (options.connectors.length === 0) { - throw new Error("CultMeshQuicRealtimeSessionManager requires at least one realtime connector."); - } - if ((options.maxRacedCandidates ?? 2) <= 0) { - throw new Error("maxRacedCandidates must be positive."); - } - this.options = options; - } - - private sessionKey(target: CultMeshRealtimeTarget): string { - return target.verseId + "\u001f" + target.authorityRuntimeId; - } - - /** - * Resolves, verifies, races and connects; disposes losing candidates. - * Returns the cached session for `target` when one is live. Concurrent - * calls for the same target share one dial (mirrors C#'s `Lazy`); a - * transport that dies later (peer shutdown, an internal fault, or explicit - * disposal) evicts itself from the cache so the next call redials, mirroring - * `CultMeshSessions.InvalidateRealtimeSession` (`:898-913`). - */ - async connect(target: CultMeshRealtimeTarget): Promise { - if (this.disposed) throw new Error("CultMeshQuicRealtimeSessionManager is disposed."); - const key = this.sessionKey(target); - const existing = this.sessions.get(key); - if (existing) return existing; - - const inFlight = this.connecting.get(key); - if (inFlight) return inFlight; - - const controller = new AbortController(); - this.dialAborts.set(key, controller); - const attempt = this.connectOwnedAsync(key, target, controller.signal).finally(() => { - this.connecting.delete(key); - if (this.dialAborts.get(key) === controller) this.dialAborts.delete(key); - }); - this.connecting.set(key, attempt); - return attempt; - } - - private async connectOwnedAsync( - key: string, - target: CultMeshRealtimeTarget, - signal: AbortSignal, - ): Promise { - const transport = await this.dialAsync(key, target, signal); - if (this.disposed || signal.aborted) { - transport.dispose(); - throw new Error( - this.disposed - ? "CultMeshQuicRealtimeSessionManager was disposed while connecting." - : `Dial for '${key}' was cancelled while connecting.`, - ); - } - this.sessions.set(key, transport); - transport.onDisposed?.(() => { - if (this.sessions.get(key) === transport) this.sessions.delete(key); - }); - return transport; - } - - private async dialAsync( - key: string, - target: CultMeshRealtimeTarget, - signal: AbortSignal, - ): Promise { - const verses = await this.options.lookupSource.resolve(target); - const routes = verses - .flatMap((verse) => verse.authorityRoutes ?? []) - .filter((route) => route.authorityRuntimeId === target.authorityRuntimeId) - .filter((route) => route.protocolIds.includes(CULTMESH_REALTIME_STATE_PROTOCOL_ID)); - if (routes.length === 0) { - throw new Error(`No realtime state route was advertised for '${key}'.`); - } - - const verifiedCandidates: CultMeshRealtimeCandidate[] = []; - const verificationFailures: Error[] = []; - for (const route of routes) { - const candidate: CultMeshRealtimeCandidate = { - endpoint: route.endpoint, - authorityRuntimeId: route.authorityRuntimeId, - priority: route.priority, - generation: trimCSharp(route.generation), - }; - try { - await verifyAuthorityRoute( - { - verseId: target.verseId, - authorityRuntimeId: route.authorityRuntimeId, - endpoint: route.endpoint, - protocolIds: route.protocolIds, - priority: route.priority, - generation: route.generation, - certificate: routeCertificateView(route.certificate), - }, - this.options.trust, - ); - verifiedCandidates.push(candidate); - } catch (error) { - verificationFailures.push(error instanceof Error ? error : new Error(String(error))); - } - } - if (verifiedCandidates.length === 0) { - throw new Error( - `No trusted realtime route remained for '${key}': ${verificationFailures.map((error) => error.message).join("; ")}`, - ); - } - - const tiers = new Map>(); - for (const candidate of verifiedCandidates) { - for (const connector of this.options.connectors) { - if (!connector.canConnect(candidate)) continue; - const tier = connector.priority; - const list = tiers.get(tier) ?? []; - list.push({ candidate, connector }); - tiers.set(tier, list); - } - } - if (tiers.size === 0) { - throw new Error(`No realtime connector supports an advertised route for '${key}'.`); - } - - const maxRaced = this.options.maxRacedCandidates ?? 2; - const failures: Error[] = []; - for (const tier of [...tiers.keys()].sort((a, b) => a - b)) { - if (signal.aborted) break; - const entries = [...(tiers.get(tier) ?? [])] - .sort((a, b) => a.candidate.priority - b.candidate.priority) - .slice(0, maxRaced); - const winner = await this.raceFirstSuccessAsync(key, target, entries, failures, signal); - if (winner) return winner; - } - throw new Error( - `No realtime state path connected for '${key}': ${failures.map((error) => error.message).join("; ")}`, - ); - } - - /** - * Races one tier's candidates and returns the first verified success, - * mirroring C#'s `Task.WhenAny` loop (`:851-866`): as soon as one candidate - * both connects and proves `isVerifiedFor`, the rest are aborted (an - * `AbortSignal` is threaded into `connector.connect`) and disposed once - * they settle, without blocking the return. `Promise.allSettled` here would - * instead wait out every candidate, including a dead route stalled on its - * own handshake timeout. - */ - private async raceFirstSuccessAsync( - key: string, - target: CultMeshRealtimeTarget, - entries: ReadonlyArray<{ candidate: CultMeshRealtimeCandidate; connector: CultMeshRealtimeTransportConnector }>, - failures: Error[], - outerSignal: AbortSignal, - ): Promise { - const controller = new AbortController(); - if (outerSignal.aborted) controller.abort(); - else outerSignal.addEventListener("abort", () => controller.abort(), { once: true }); - const pending = new Set( - entries.map((entry) => this.connectCandidateAsync(key, target, entry, controller.signal)), - ); - let winner: CultMeshRealtimeTransport | undefined; - while (pending.size > 0 && !winner) { - const settled = await Promise.race( - [...pending].map((attempt) => - attempt.then( - (value) => ({ attempt, ok: true as const, value }), - (error) => ({ attempt, ok: false as const, error }), - ), - ), - ); - pending.delete(settled.attempt); - if (settled.ok) { - winner = settled.value; - } else { - failures.push(settled.error instanceof Error ? settled.error : new Error(String(settled.error))); - } - } - if (!winner) return undefined; - - controller.abort(); - // The remaining candidates may already be in flight past the point an - // abort signal helps; dispose whatever they eventually produce, without - // blocking the winner's return. - for (const loser of pending) { - loser.then((transport) => transport.dispose()).catch(() => {}); - } - return winner; - } - - private async connectCandidateAsync( - key: string, - target: CultMeshRealtimeTarget, - entry: { candidate: CultMeshRealtimeCandidate; connector: CultMeshRealtimeTransportConnector }, - signal: AbortSignal, - ): Promise { - const transport = await entry.connector.connect(entry.candidate, target, signal); - if (!transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, entry.candidate.generation)) { - transport.dispose(); - throw new Error(`Transport did not prove CultMesh authority for '${key}'.`); - } - return transport; - } - - /** - * Marks the session for `target` offline, disposing its transport. Also - * cancels a dial still in flight for `target`, so a connect racing this - * call cannot cache a transport this call meant to discard. - */ - disconnect(target: CultMeshRealtimeTarget): void { - const key = this.sessionKey(target); - const transport = this.sessions.get(key); - if (transport) { - this.sessions.delete(key); - transport.dispose(); - } - this.dialAborts.get(key)?.abort(); - } - - dispose(): void { - this.disposed = true; - for (const controller of this.dialAborts.values()) controller.abort(); - this.dialAborts.clear(); - for (const transport of this.sessions.values()) transport.dispose(); - this.sessions.clear(); - } -} diff --git a/packages/cultmesh-ts/src/realtime-wire.ts b/packages/cultmesh-ts/src/realtime-wire.ts deleted file mode 100644 index fa647133..00000000 --- a/packages/cultmesh-ts/src/realtime-wire.ts +++ /dev/null @@ -1,191 +0,0 @@ -// The one TypeScript encoding of a CultMesh realtime frame. -// -// Rule for rule, this is `CultMeshRealtimeWireProtocol` in the C# reference -// (`src/GameCult.Mesh/CultMeshRealtimeWireProtocol.cs`), over the frame shape -// `CultMeshRealtimeTransports.cs` defines. QUIC stream framing stays -// transport-owned; this file owns only the typed frame bytes and never touches -// a socket. -// -// Byte parity is proven both ways, cross-process, by two committed vector files -// under `contracts/cultmesh/`: `realtime-frame-vectors.json`, which the C# test -// `CultMeshRealtimeWireProtocolTests` writes under CULTMESH_WRITE_VECTORS=1 and -// this package's test decodes and re-encodes, and -// `realtime-frame-vectors.ts-written.json`, which -// `scripts/write-cultmesh-realtime-frame-vectors.mjs` writes with this codec and -// the same C# test decodes. A fixture authored on one side alone would only -// agree with itself. - -import { isNullOrWhiteSpaceCSharp } from "cultnet-ts"; - -/** `CultMeshRealtimeWireProtocol.ApplicationProtocolName`: the QUIC ALPN. */ -export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1"; - -/** `ConnectionCloseCode`: ASCII "CULT". A QUIC error code is 62-bit, so bigint. */ -export const CULTMESH_REALTIME_CONNECTION_CLOSE_CODE = 0x43554c54n; - -/** `StreamAbortCode`: ASCII "STAT". */ -export const CULTMESH_REALTIME_STREAM_ABORT_CODE = 0x53544154n; - -/** `ReliableStream`: the stream kind carrying `reliable-ordered`. */ -export const CULTMESH_REALTIME_RELIABLE_STREAM = 1; - -/** `LatestOnlyStream`: the stream kind carrying `latest-only`. */ -export const CULTMESH_REALTIME_LATEST_ONLY_STREAM = 2; - -/** `MaximumFrameBytes`: the payload ceiling, 64 MiB. */ -export const CULTMESH_REALTIME_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024; - -/** `MaximumEncodedFrameBytes`: the payload ceiling plus the header and three maximal identities. */ -export const CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES = - CULTMESH_REALTIME_MAX_PAYLOAD_BYTES + 37 + 3 * 0xffff; - -/** - * `CultMeshRealtimeDelivery`. The wire byte is the C# enum's ordinal, so the - * order of these three is the wire contract, not a naming choice. - */ -export type CultMeshRealtimeDelivery = "reliable-ordered" | "latest-only" | "unreliable"; - -const DELIVERY_BYTES: readonly CultMeshRealtimeDelivery[] = ["reliable-ordered", "latest-only", "unreliable"]; - -/** `CultMeshRealtimeFrame`. `producerEpoch` and `sequence` are `long` in C#, which exceeds `Number.MAX_SAFE_INTEGER`. */ -export interface CultMeshRealtimeFrame { - channelId: string; - schemaId: string; - bodyId: string; - producerEpoch: bigint; - sequence: bigint; - delivery: CultMeshRealtimeDelivery; - payload: Uint8Array; -} - -const MAGIC = 0x31545343; -const FIXED_HEADER_BYTES = 37; -const WIRE_VERSION = 1; -const MAX_IDENTITY_BYTES = 0xffff; -const INT64_MIN = -(2n ** 63n); -const INT64_MAX = 2n ** 63n - 1n; - -/** - * `CultMeshRealtimeFrame.Validate`. The C# reading of "non-empty" is - * `string.IsNullOrWhiteSpace`, whose whitespace set is not - * `String.prototype.trim`'s, so this borrows the one TypeScript implementation - * of that set from `cultnet-ts` rather than keeping a second reading here. - * - * The range check has no C# counterpart because `long` gives it for free; a - * `bigint` does not, and a `DataView` would otherwise throw a bare `RangeError` - * from inside the encoder. - */ -function validateFrame(frame: CultMeshRealtimeFrame): void { - if (isNullOrWhiteSpaceCSharp(frame.channelId)) throw new Error("Realtime channel identity is required."); - if (isNullOrWhiteSpaceCSharp(frame.schemaId)) throw new Error("Realtime schema identity is required."); - if (isNullOrWhiteSpaceCSharp(frame.bodyId)) throw new Error("Realtime body identity is required."); - if (frame.producerEpoch < 0n || frame.sequence < 0n) { - throw new Error("Realtime epoch and sequence must be non-negative."); - } - if (frame.producerEpoch > INT64_MAX || frame.sequence > INT64_MAX || frame.producerEpoch < INT64_MIN || frame.sequence < INT64_MIN) { - throw new Error("Realtime epoch and sequence must fit a 64-bit signed integer."); - } -} - -/** `CultMeshRealtimeWireProtocol.EncodeFrame`. */ -export function encodeRealtimeFrame(frame: CultMeshRealtimeFrame): Uint8Array { - validateFrame(frame); - const deliveryByte = DELIVERY_BYTES.indexOf(frame.delivery); - // C# reads an enum, which holds any byte; a TypeScript union does not, so the - // unspellable value is refused here with the message decode gives it. - if (deliveryByte < 0) throw new Error("Realtime frame delivery mode is invalid."); - const encoder = new TextEncoder(); - const channel = encoder.encode(frame.channelId); - const schema = encoder.encode(frame.schemaId); - const body = encoder.encode(frame.bodyId); - if (channel.byteLength > MAX_IDENTITY_BYTES || schema.byteLength > MAX_IDENTITY_BYTES || body.byteLength > MAX_IDENTITY_BYTES) { - throw new Error("Realtime frame identity exceeds the QUIC wire limit."); - } - if (frame.payload.byteLength > CULTMESH_REALTIME_MAX_PAYLOAD_BYTES) { - throw new Error("Realtime frame payload exceeds the QUIC wire limit."); - } - - const result = new Uint8Array( - FIXED_HEADER_BYTES + channel.byteLength + schema.byteLength + body.byteLength + frame.payload.byteLength, - ); - const view = new DataView(result.buffer); - view.setUint32(0, MAGIC, true); - result[4] = deliveryByte; - view.setBigInt64(5, frame.producerEpoch, true); - view.setBigInt64(13, frame.sequence, true); - view.setUint16(21, channel.byteLength, true); - view.setUint16(23, schema.byteLength, true); - view.setUint16(25, body.byteLength, true); - view.setInt32(27, frame.payload.byteLength, true); - view.setInt32(31, FIXED_HEADER_BYTES, true); - view.setUint16(35, WIRE_VERSION, true); - let offset = FIXED_HEADER_BYTES; - result.set(channel, offset); offset += channel.byteLength; - result.set(schema, offset); offset += schema.byteLength; - result.set(body, offset); offset += body.byteLength; - result.set(frame.payload, offset); - return result; -} - -/** - * `CultMeshRealtimeWireProtocol.DecodeFrame`, refusal for refusal and in the - * same order. Like the reference, it does not call `validateFrame`: a frame - * whose identities are blank decodes here exactly as it does in C#, and only - * encoding refuses it. - * - * One corner has no C# counterpart. The reference sums the lengths under - * `checked`, so a payload length near `int.MaxValue` throws `OverflowException` - * there rather than its length refusal; JavaScript numbers do not overflow, so - * the sum simply fails to match and the length refusal covers that case too. - * No encoder on either side can produce such a frame. - */ -export function decodeRealtimeFrame(bytes: Uint8Array): CultMeshRealtimeFrame { - if (bytes.byteLength < FIXED_HEADER_BYTES) throw new Error("Realtime frame header is truncated."); - const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (view.getUint32(0, true) !== MAGIC) throw new Error("Realtime frame magic is invalid."); - if (view.getUint16(35, true) !== WIRE_VERSION || view.getInt32(31, true) !== FIXED_HEADER_BYTES) { - throw new Error("Realtime frame wire version is unsupported."); - } - const delivery = DELIVERY_BYTES[bytes[4]!]; - if (delivery === undefined) throw new Error("Realtime frame delivery mode is invalid."); - const channelLength = view.getUint16(21, true); - const schemaLength = view.getUint16(23, true); - const bodyLength = view.getUint16(25, true); - const payloadLength = view.getInt32(27, true); - const expected = FIXED_HEADER_BYTES + channelLength + schemaLength + bodyLength + payloadLength; - if (payloadLength < 0 || expected !== bytes.byteLength) throw new Error("Realtime frame length is invalid."); - // `Encoding.UTF8.GetString` substitutes U+FFFD for invalid bytes rather than - // throwing, which is what a non-fatal `TextDecoder` does. `ignoreBOM` is not - // cosmetic: by default `TextDecoder` *deletes* a leading U+FEFF, and - // `GetString` keeps it, so an identity starting with a byte-order mark would - // decode to a different string on the two sides. U+FEFF is not whitespace to - // .NET either, so such an identity passes the transport contract and reaches - // the wire. - const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); - let offset = FIXED_HEADER_BYTES; - const channelId = decoder.decode(bytes.subarray(offset, offset + channelLength)); offset += channelLength; - const schemaId = decoder.decode(bytes.subarray(offset, offset + schemaLength)); offset += schemaLength; - const bodyId = decoder.decode(bytes.subarray(offset, offset + bodyLength)); offset += bodyLength; - return { - channelId, - schemaId, - bodyId, - producerEpoch: view.getBigInt64(5, true), - sequence: view.getBigInt64(13, true), - delivery, - // Not `bytes.slice(...)`. Node's `Buffer` overrides `slice` with an alias of - // `subarray`, so on a `Buffer` — which is what every socket read hands a Node - // caller — that spelling returns a *view* of the frame, and a decoded payload - // that changes when the frame buffer is reused. - // - // Nor `Uint8Array.prototype.slice.call(bytes, ...)`, which does bypass the - // override and copy, but takes its result constructor from `@@species`: on a - // `Buffer` that is `FastBuffer`, so the payload comes back a `Buffer` from a - // `Buffer` frame and a `Uint8Array` from a `Uint8Array` one. The declared - // type holds either way, but `deepStrictEqual` does not, so the same frame - // would compare equal or not depending on how it reached the decoder. - // - // The constructor copies and is the one type on both inputs. - payload: new Uint8Array(bytes.subarray(offset, offset + payloadLength)), - }; -} diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 3954c697..16d51e1e 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -2500,20 +2500,16 @@ test("CultMesh TS branded facade exposes schema and shard catalogs", () => { const builtIns = CultMesh.createBuiltInSchemaCatalog(); assert.equal( - builtIns.get( - "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-request.schema.json", - )?.schemaVersion, + builtIns.get("cultnet.shard_catalog_request.v0")?.schemaVersion, "cultnet.shard_catalog_request.v0", ); assert.equal( - builtIns.get( - "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.shard-catalog-response.schema.json", - )?.kind, + builtIns.get("cultnet.shard_catalog_response.v0")?.kind, "wire_message", ); assert.equal( builtIns.get( - "https://github.com/GameCult/CultLib/contracts/cultnet/cultnet.transport-profile.schema.json", + "https://github.com/GameCult/cultnet-ts/contracts/cultnet.transport-profile.schema.json", )?.schemaVersion, "cultnet.transport_profile.v0", ); diff --git a/packages/cultmesh-ts/test/fixtures/README.md b/packages/cultmesh-ts/test/fixtures/README.md deleted file mode 100644 index 70573ff2..00000000 --- a/packages/cultmesh-ts/test/fixtures/README.md +++ /dev/null @@ -1,27 +0,0 @@ -# QUIC test fixtures - -`quic-test.p12` and `quic-test-cert.der` are a self-signed P-256 credential -used only to open a loopback MsQuic listener in tests -(`realtime-quic-native.test.ts`, `realtime-quic-consumer.test.ts`). Not a -production credential; no private key outside this pair signs anything real. - -`quic-test-cert.der`'s subject is `CN=cultmesh-quic-native-tests`, and its -SHA-256 is what the tests pin as the expected certificate pin -(`cert-sha256`). - -Generated with (mirrors the recipe the cut map records at P13): - -```sh -openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ - -keyout quic-test-key.pem -out quic-test-cert.pem \ - -subj "/CN=cultmesh-quic-native-tests" -days 3650 - -openssl x509 -in quic-test-cert.pem -outform der -out quic-test-cert.der - -openssl pkcs12 -export -inkey quic-test-key.pem -in quic-test-cert.pem \ - -out quic-test.p12 -passout pass: -``` - -Regenerate the same way if the pair ever needs replacing; nothing here -depends on today's exact bytes past what the tests read back out (the DER -cert and its SHA-256, and a PKCS12 the native bridge can load). diff --git a/packages/cultmesh-ts/test/fixtures/quic-test-cert.der b/packages/cultmesh-ts/test/fixtures/quic-test-cert.der deleted file mode 100644 index 5cce6f94..00000000 Binary files a/packages/cultmesh-ts/test/fixtures/quic-test-cert.der and /dev/null differ diff --git a/packages/cultmesh-ts/test/fixtures/quic-test.p12 b/packages/cultmesh-ts/test/fixtures/quic-test.p12 deleted file mode 100644 index e9d03ce6..00000000 Binary files a/packages/cultmesh-ts/test/fixtures/quic-test.p12 and /dev/null differ diff --git a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts deleted file mode 100644 index aadd1265..00000000 --- a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts +++ /dev/null @@ -1,234 +0,0 @@ -// The TypeScript half of the QUIC realtime interop lane. Two modes: -// -// `dial` (Cut 4): a one-shot process that dials an advertised -// `cultmesh-state+quic://` endpoint with the native connector, receives -// `--expect` frames from a peer (normally the C# managed provider started by -// `cultnet-interop.test.ts`'s "CultMesh QUIC realtime" test), and prints them -// as one JSON line. -// -// `serve` (Cut 5): the StreamPixels role. Opens a `CultMeshQuicRealtimeProvider` -// listener, prints `{status: "ready", endpoint}` (the same shape the C# -// `quic-realtime-serve` peer prints), waits for a first peer before -// broadcasting (mirroring `QuicRealtimeServeAsync`'s -// `WaitForFirstConnectionAsync`, since a broadcast to nobody would otherwise -// silently no-op), then broadcasts `--frames` frames at `--delivery` with -// `--interval-ms` between them, watching stdin for EOF as its own shutdown -// signal like the C# peer's `WatchStdinCloseAsync`. `--linger-ms` (default 0) -// keeps the listener open that long after broadcasting finishes, so the -// harness can dial a peer late and exercise Cut C's retained-state seed -// instead of the provider always tearing down the moment it is done sending. -// -// Neither mode imports `cultmesh-ts` itself (the harness drives processes, -// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the -// reverse); this script reaches the provider/connector through its own -// package's `src/`. - -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import process from "node:process"; - -import { - CultMeshQuicRealtimeConnector, - CultMeshQuicRealtimeProvider, - type CultMeshRealtimeTarget, -} from "../../src/realtime-quic"; -import { encodeRealtimeFrame, type CultMeshRealtimeDelivery, type CultMeshRealtimeFrame } from "../../src/realtime-wire"; - -// `__dirname` at runtime is `dist-test/test/interop`; fixtures are binary and -// are never compiled/copied there, so they are read from their source -// location, three levels up. The same self-signed test credential the unit -// tests use (`test/fixtures/README.md`): not a production credential. -const FIXTURE_P12 = join(__dirname, "..", "..", "..", "test", "fixtures", "quic-test.p12"); - -function parseArgs(argv: readonly string[]): Map { - const args = new Map(); - for (let index = 0; index < argv.length; index += 1) { - const token = argv[index]; - if (!token.startsWith("--")) continue; - const name = token.slice(2); - const value = argv[index + 1]; - if (!value || value.startsWith("--")) throw new Error(`Missing value for --${name}`); - args.set(name, value); - index += 1; - } - return args; -} - -function requireArg(args: Map, name: string): string { - const value = args.get(name); - if (!value) throw new Error(`Missing required argument --${name}`); - return value; -} - -function optionalIntArg(args: Map, name: string, fallback: number): number { - const raw = args.get(name); - if (!raw) return fallback; - const parsed = Number.parseInt(raw, 10); - if (!Number.isInteger(parsed)) throw new Error(`Argument --${name} must be an integer.`); - return parsed; -} - -function writeJsonLine(value: unknown): void { - process.stdout.write(`${JSON.stringify(value)}\n`); -} - -function writeLog(event: string, payload: Record): void { - process.stderr.write(`${JSON.stringify({ event, ...payload })}\n`); -} - -function watchStdinCloseAsync(): Promise { - return new Promise((resolve) => { - process.stdin.resume(); - process.stdin.once("end", resolve); - }); -} - -async function serveAsync(args: Map): Promise { - const frames = optionalIntArg(args, "frames", 5); - const deliveryArg = args.get("delivery") ?? "latest-only"; - if (deliveryArg !== "latest-only" && deliveryArg !== "reliable-ordered") { - throw new Error(`Unsupported --delivery '${deliveryArg}'.`); - } - const delivery = deliveryArg as CultMeshRealtimeDelivery; - const intervalMs = optionalIntArg(args, "interval-ms", 50); - const lingerMs = optionalIntArg(args, "linger-ms", 0); - const port = optionalIntArg(args, "port", 0); - - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - }); - writeJsonLine({ status: "ready", endpoint: provider.advertisedEndpoint }); - - try { - // A broadcast to zero peers is not an error, but it sends nothing: - // matching the C# `quic-realtime-serve` peer, wait for a first - // connection, bounded, so a peer that never shows up still exits. - const connectDeadline = Date.now() + 30_000; - while (provider.connectionCount === 0) { - if (Date.now() >= connectDeadline) throw new Error("quic-realtime-serve: no client connected within 30000ms."); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - const stdinClosed = watchStdinCloseAsync(); - let stopped = false; - void stdinClosed.then(() => { - stopped = true; - }); - for (let sequence = 1; sequence <= frames && !stopped; sequence += 1) { - const frame: CultMeshRealtimeFrame = { - channelId: "interop.quic-realtime", - schemaId: "gamecult.interop.quic_realtime_frame.v0", - bodyId: "interop:quic-realtime:frame", - producerEpoch: 1n, - sequence: BigInt(sequence), - delivery, - payload: Buffer.from(`frame-${sequence}`, "utf8"), - }; - await provider.broadcast(frame); - // Printed so the harness can compare this exact frame's TypeScript - // encoding against the same frame's C#-side re-encoding (golden bytes - // across processes), without the harness importing the codec itself. - writeLog("quic-realtime-serve", { sent: sequence, hex: Buffer.from(encodeRealtimeFrame(frame)).toString("hex") }); - if (intervalMs > 0) await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - if (lingerMs > 0 && !stopped) await new Promise((resolve) => setTimeout(resolve, lingerMs)); - } finally { - provider.dispose(); - // Mirrors the C# peer returning from `QuicRealtimeServeAsync`: once - // broadcasting is done (or stdin closed early), let the process exit on - // its own instead of keeping the event loop alive on a stdin that may - // never close. The harness still ends/kills this process explicitly in - // its own cleanup; this only lets a plain manual run exit cleanly too. - process.stdin.pause(); - } -} - -async function main(): Promise { - const [mode, ...rest] = process.argv.slice(2); - if (mode === "serve") { - await serveAsync(parseArgs(rest)); - return; - } - if (mode !== "dial") throw new Error("Expected mode: dial | serve"); - const args = parseArgs(rest); - const endpoint = requireArg(args, "endpoint"); - const expect = optionalIntArg(args, "expect", 1); - // Alternative stop condition to --expect: keep receiving (through whatever - // the consumer's own coalescing inbox delivers) until a frame with this - // sequence arrives, then stop. Lets a caller prove convergence under - // latest-only coalescing, where the frame *count* a stalled reader sees is - // not the interesting number, but the final value it converges on is. - const expectSequenceRaw = args.get("expect-sequence"); - const expectSequence = expectSequenceRaw !== undefined ? BigInt(expectSequenceRaw) : undefined; - const timeoutMs = optionalIntArg(args, "timeout-ms", 15_000); - // Simulates "a test hook on the runtime that stops calling next_event" - // (spec section 10, LatestOnly-under-a-stalled-consumer lane) at the level - // this subprocess can reach: after receiving --pause-after frames, stop - // calling receiveFrame for --pause-ms. The shared native pump keeps - // dispatching and coalescing into this transport's own inbox regardless - // (Cut 4), so not reading is externally equivalent to not polling. - const pauseAfter = optionalIntArg(args, "pause-after", -1); - const pauseMs = optionalIntArg(args, "pause-ms", 0); - - const target: CultMeshRealtimeTarget = { verseId: "interop", authorityRuntimeId: "interop.csharp-provider" }; - const connector = new CultMeshQuicRealtimeConnector(); - const transport = await connector.connect( - { endpoint, authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "interop" }, - target, - ); - writeLog("connected", { endpoint, transportId: transport.transportId }); - - const frames: Array<{ - channelId: string; - schemaId: string; - bodyId: string; - producerEpoch: string; - sequence: string; - delivery: string; - payloadHex: string; - }> = []; - try { - const deadline = Date.now() + timeoutMs; - let lastSequence: bigint | undefined; - while ( - (expectSequence === undefined && frames.length < expect) || - (expectSequence !== undefined && lastSequence !== expectSequence) - ) { - if (pauseAfter >= 0 && frames.length === pauseAfter) { - writeLog("pausing", { afterFrames: frames.length, pauseMs }); - await new Promise((resolve) => setTimeout(resolve, pauseMs)); - writeLog("resumed", { afterFrames: frames.length }); - } - const remaining = deadline - Date.now(); - if (remaining <= 0) throw new Error(`Timed out after receiving ${frames.length} frames (last sequence ${lastSequence}).`); - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), remaining); - try { - const frame = await transport.receiveFrame(controller.signal); - lastSequence = frame.sequence; - frames.push({ - channelId: frame.channelId, - schemaId: frame.schemaId, - bodyId: frame.bodyId, - producerEpoch: frame.producerEpoch.toString(), - sequence: frame.sequence.toString(), - delivery: frame.delivery, - payloadHex: Buffer.from(frame.payload).toString("hex"), - }); - } finally { - clearTimeout(timer); - } - } - } finally { - transport.dispose(); - } - - writeJsonLine({ endpoint, transportId: transport.transportId, frames }); -} - -main().catch((error) => { - writeLog("fatal", { error: error instanceof Error ? (error.stack ?? error.message) : String(error) }); - process.exitCode = 1; -}); diff --git a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts deleted file mode 100644 index 8a2063fe..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts +++ /dev/null @@ -1,1074 +0,0 @@ -// The TypeScript QUIC realtime consumer, proven two ways: directly against a -// raw listener opened through the native runtime (no provider semantics yet, -// mirrors `CultMeshNativeQuicRealtimeTransportTests.cs`), and through -// `CultMeshQuicRealtimeSessionManager` with a static lookup source and routes -// signed with `node:crypto`, mirroring `CultMeshQuicRealtimeTransportTests.cs` -// and `CultMeshSessions.cs`'s realtime binding. -// -// Skips instead of failing when no native bridge is available; see -// `realtime-quic-native.test.ts` for why. - -import assert from "node:assert/strict"; -import { createHash, generateKeyPairSync, sign as cryptoSign, type KeyObject } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import test from "node:test"; - -import { - canonicalRoute, - type CultMeshAuthorityRouteView, - type CultMeshAuthorityTrustPolicy, - type CultMeshP256PublicKey, -} from "cultnet-ts"; -import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; - -import { - CultMeshQuicNativeRuntime, - CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, - CULTMESH_QUIC_STREAM_LATEST_ONLY, - CULTMESH_QUIC_STREAM_RELIABLE, -} from "../src/realtime-quic-native"; -import { - CultMeshQuicRealtimeConnector, - CultMeshQuicRealtimeSessionManager, - CultMeshStaticRealtimeLookupSource, - CULTMESH_REALTIME_STATE_PROTOCOL_ID, - type CultMeshQuicRealtimeTransport, - type CultMeshRealtimeCandidate, - type CultMeshRealtimeTarget, - type CultMeshRealtimeTransport, - type CultMeshRealtimeTransportConnector, -} from "../src/realtime-quic"; -import { encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire"; -import { nativeBridgeAvailable } from "./support/native-bridge"; - -// __dirname at runtime is dist-test/test; fixtures are binary and are never -// compiled/copied there, so they are read from their source location, two -// levels up. -const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); -const FIXTURE_DER = join(__dirname, "..", "..", "test", "fixtures", "quic-test-cert.der"); - -function fixturePinHex(): string { - return createHash("sha256").update(readFileSync(FIXTURE_DER)).digest("hex"); -} - -/** A bare loopback listener over the raw native runtime, no provider semantics. */ -class RawQuicListener { - private constructor( - private readonly runtime: CultMeshQuicNativeRuntime, - private readonly listenerId: bigint, - readonly boundPort: number, - ) {} - - static async open(): Promise { - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - return new RawQuicListener(runtime, listenerId, boundPort); - } catch (error) { - // A runtime opened above and never handed to a caller would otherwise - // leak: the shared runtime's refcount never reaches zero, its MsQuic - // registration and worker threads never tear down, and a process that - // hits this on every one of several tests never exits on its own. - await runtime.release(); - throw error; - } - } - - /** Waits for one inbound connection and returns its native connection id. */ - async acceptOnce(): Promise { - return await new Promise((resolve) => { - this.runtime.onListenerEvent(this.listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); - }); - }); - } - - /** Sends one frame to an accepted connection on a fresh stream of `kind`. */ - sendFrame(connectionId: bigint, frame: CultMeshRealtimeFrame, kind: number): void { - const streamId = this.runtime.streamOpen(connectionId, kind); - this.runtime.streamSendFrame(streamId, encodeRealtimeFrame(frame), true); - } - - /** Sends arbitrary bytes (not necessarily a valid encoded frame) on a fresh stream of `kind`. */ - sendRaw(connectionId: bigint, bytes: Uint8Array, kind: number): void { - const streamId = this.runtime.streamOpen(connectionId, kind); - this.runtime.streamSendFrame(streamId, bytes, true); - } - - /** Shuts down one accepted connection from this side, as a peer disconnect. */ - shutdownConnection(connectionId: bigint, code = 0n): void { - this.runtime.connectionShutdown(connectionId, code); - } - - async close(): Promise { - this.runtime.listenerClose(this.listenerId); - await this.runtime.release(); - } -} - -/** - * `assert.rejects`, but safe against a mutant or a real regression that - * makes the promise resolve instead: a transport that unexpectedly connects - * is disposed before the assertion fails, rather than left live with an open - * native connection and an outstanding runtime reference. A bare - * `assert.rejects(connector.connect(...))` hung the whole file exactly this - * way under M1 (the certificate-accept mutation): three tests failed their - * assertion correctly, but each left a connected, undisposed transport - * behind, and Node's test runner refuses to exit while any promise or - * native handle is still outstanding. - */ -async function assertRejectsAndDisposes( - promise: Promise<{ dispose(): void }>, - match?: RegExp, -): Promise { - let resolved: { dispose(): void } | undefined; - try { - resolved = await promise; - } catch (error) { - if (match) assert.match(error instanceof Error ? error.message : String(error), match); - return; - } - resolved.dispose(); - assert.fail("expected the promise to reject, but it resolved (the resolved value has been disposed)"); -} - -/** - * `transport.receiveFrame()`, bounded: a fault-path regression that leaves a - * malformed frame undelivered-but-not-rejected would otherwise hang this - * call (and, through a bare `assert.rejects`, the whole test file) forever, - * rather than fail the one assertion it was supposed to fail. - */ -function receiveFrameOrTimeout(transport: { receiveFrame(signal?: AbortSignal): Promise }, ms = 3_000): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), ms); - return transport.receiveFrame(controller.signal).finally(() => clearTimeout(timer)); -} - -function testFrame(overrides: Partial = {}): CultMeshRealtimeFrame { - return { - channelId: "aetheria.entities", - schemaId: "eve.entity_soa.v1", - bodyId: "body:aetheria:entities", - producerEpoch: 7n, - sequence: 1n, - delivery: "latest-only", - payload: new Uint8Array([3, 4, 5]), - ...overrides, - }; -} - -test("connect with the correct pin succeeds and receives a frame", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - assert.equal(transport.transportId, "msquic-realtime"); - const connectionId = await accepted; - listener.sendFrame(connectionId, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transport.receiveFrame(); - assert.equal(received.bodyId, "body:aetheria:entities"); - assert.equal(received.sequence, 1n); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("connect refuses a wrong advertised pin", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${"0".repeat(64)}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); - } finally { - await listener.close(); - } -}); - -test("connect refuses a missing certificate pin when no validator is supplied", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); - } finally { - await listener.close(); - } -}); - -test("a caller-supplied validator observes the target identity and the DER bytes", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - let observedTarget: CultMeshRealtimeTarget | undefined; - let observedDerLength = 0; - const connector = new CultMeshQuicRealtimeConnector({ - validateProviderCertificate: (validatorTarget, der) => { - observedTarget = validatorTarget; - observedDerLength = der.byteLength; - return true; - }, - }); - const transport = await connector.connect(candidate, target); - transport.dispose(); - assert.deepEqual(observedTarget, target); - assert.ok(observedDerLength > 0); - } finally { - await listener.close(); - } -}); - -test("a late older generation is dropped by the receive-side filter", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - listener.sendFrame(connectionId, testFrame({ producerEpoch: 8n, sequence: 0n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const latest = await transport.receiveFrame(); - assert.equal(latest.producerEpoch, 8n); - assert.equal(latest.sequence, 0n); - - listener.sendFrame(connectionId, testFrame({ producerEpoch: 7n, sequence: 999n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const staleTimeout = new AbortController(); - const timer = setTimeout(() => staleTimeout.abort(), 300); - await assert.rejects(transport.receiveFrame(staleTimeout.signal)); - clearTimeout(timer); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -// --------------------------------------------------------------------------- -// Trust negatives, through the session manager with a static lookup source. -// --------------------------------------------------------------------------- - -function base64UrlToBase64(value: string): string { - let base64 = value.replace(/-/g, "+").replace(/_/g, "/"); - while (base64.length % 4 !== 0) base64 += "="; - return base64; -} - -let odinRootSequence = 0; - -function generateOdinRoot(): { privateKey: KeyObject; publicKey: CultMeshP256PublicKey } { - const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" }); - const jwk = publicKey.export({ format: "jwk" }) as { x: string; y: string }; - odinRootSequence += 1; - return { - privateKey, - publicKey: { - keyId: `odin-root-${odinRootSequence}`, - x: base64UrlToBase64(jwk.x), - y: base64UrlToBase64(jwk.y), - }, - }; -} - -interface SignedRouteOptions { - readonly verseId: string; - readonly authorityRuntimeId: string; - readonly endpoint: string; - readonly protocolIds: readonly string[]; - readonly privateKey: KeyObject; - readonly odinKeyId: string; - readonly priority?: number; - readonly generation?: string; - readonly issuedAtUnixMilliseconds?: number; - readonly expiresAtUnixMilliseconds?: number; - readonly providerKey?: CultMeshP256PublicKey; - readonly corruptSignature?: boolean; -} - -function signRoute(options: SignedRouteOptions): CultMeshAuthorityRouteMessage { - const priority = options.priority ?? 0; - const generation = options.generation ?? ""; - const issuedAt = options.issuedAtUnixMilliseconds ?? Date.now() - 60_000; - const expiresAt = options.expiresAtUnixMilliseconds ?? Date.now() + 3_600_000; - const providerKey = options.providerKey ?? { keyId: "provider-1", x: "unused", y: "unused" }; - const view: CultMeshAuthorityRouteView = { - verseId: options.verseId, - authorityRuntimeId: options.authorityRuntimeId, - endpoint: options.endpoint, - protocolIds: options.protocolIds, - priority, - generation, - certificate: { - providerKey, - odinKeyId: options.odinKeyId, - issuedAtUnixMilliseconds: issuedAt, - expiresAtUnixMilliseconds: expiresAt, - signature: "", - }, - }; - const payload = canonicalRoute(view); - const signatureBytes = options.corruptSignature - ? Buffer.alloc(64, 0x41) - : (cryptoSign("sha256", Buffer.from(payload), { key: options.privateKey, dsaEncoding: "ieee-p1363" }) as Buffer); - return { - authorityRuntimeId: options.authorityRuntimeId, - endpoint: options.endpoint, - protocolIds: [...options.protocolIds], - priority, - generation, - certificate: { - providerKeyId: providerKey.keyId, - providerPublicKeyX: providerKey.x, - providerPublicKeyY: providerKey.y, - odinKeyId: options.odinKeyId, - issuedAtUnixMilliseconds: issuedAt, - expiresAtUnixMilliseconds: expiresAt, - signature: signatureBytes.toString("base64"), - }, - }; -} - -function verseWithRoute(verseId: string, route: CultMeshAuthorityRouteMessage): CultMeshVerseDescriptorMessage { - return { - verseId, - displayName: verseId, - authorityModel: "operator-cluster", - compatibility: { transportVersion: "cultmesh.v1", rulesHash: "test", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, - discoveryEndpoints: [route.endpoint], - authorityRuntimeIds: [route.authorityRuntimeId], - authorityRoutes: [route], - }; -} - -const unreachableConnector: CultMeshRealtimeTransportConnector = { - connectorId: "unreachable", - priority: 0, - canConnect: () => true, - connect: async () => { - throw new Error("a trust-refused route must never reach a connector"); - }, -}; - -test("trust negative (a): an expired certificate is refused", async () => { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const root = generateOdinRoot(); - const route = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: "cultmesh-state+quic://127.0.0.1:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: root.privateKey, - odinKeyId: root.publicKey.keyId, - issuedAtUnixMilliseconds: Date.now() - 120_000, - expiresAtUnixMilliseconds: Date.now() - 60_000, - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [unreachableConnector], - }); - await assertRejectsAndDisposes(manager.connect(target), /valid|expired/i); -}); - -test("trust negative (b): an unsigned remote route is refused", async () => { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const route: CultMeshAuthorityRouteMessage = { - authorityRuntimeId: target.authorityRuntimeId, - endpoint: "cultmesh-state+quic://198.51.100.7:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - priority: 0, - generation: "gen-1", - }; - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [unreachableConnector], - }); - await assertRejectsAndDisposes(manager.connect(target), /Odin-signed/i); -}); - -test("trust negative (c): a route signed by an untrusted Odin root is refused", async () => { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const signer = generateOdinRoot(); - const trustedRoot = generateOdinRoot(); - const route = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: "cultmesh-state+quic://127.0.0.1:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: signer.privateKey, - odinKeyId: signer.publicKey.keyId, - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [trustedRoot.publicKey] }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [unreachableConnector], - }); - await assertRejectsAndDisposes(manager.connect(target), /not trusted/i); -}); - -test("trust negative (d): a route that verifies but pins the wrong certificate is refused at the transport", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const root = generateOdinRoot(); - const route = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${"0".repeat(64)}`, - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: root.privateKey, - odinKeyId: root.publicKey.keyId, - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [new CultMeshQuicRealtimeConnector()], - }); - await assertRejectsAndDisposes(manager.connect(target), /certificate|rejected/i); - } finally { - await listener.close(); - } -}); - -test("trust negative (e): a transport that fails isVerifiedFor is disposed and rejected", async () => { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const root = generateOdinRoot(); - const route = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: "cultmesh-state+quic://127.0.0.1:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: root.privateKey, - odinKeyId: root.publicKey.keyId, - generation: "route-generation-1", - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; - - let disposed = false; - const staleTransport: CultMeshRealtimeTransport = { - transportId: "msquic-realtime", - endpoint: route.endpoint, - async sendFrame() {}, - async receiveFrame() { - return testFrame(); - }, - dispose() { - disposed = true; - }, - // A replayed/stale generation: the transport proves a different route - // generation than the one the session manager just verified. - isVerifiedFor: () => false, - }; - const staleConnector: CultMeshRealtimeTransportConnector = { - connectorId: "stale", - priority: 0, - canConnect: () => true, - connect: async () => staleTransport, - }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, route)]), - trust, - connectors: [staleConnector], - }); - - await assertRejectsAndDisposes(manager.connect(target), /did not prove|No realtime state path/i); - assert.equal(disposed, true, "the session manager must dispose a transport that fails isVerifiedFor"); -}); - -test("M3: a real connected transport's isVerifiedFor rejects a generation other than the one it connected with", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "route-generation-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const transport = await connector.connect(candidate, target); - try { - assert.equal( - transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, "route-generation-1"), - true, - "the generation it actually connected with must verify", - ); - assert.equal( - transport.isVerifiedFor(target.verseId, target.authorityRuntimeId, CULTMESH_REALTIME_STATE_PROTOCOL_ID, "route-generation-2"), - false, - "a mutated/replayed generation must not verify against a real transport", - ); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("trust negative (e2): a real transport whose generation no longer matches the freshly verified route is disposed", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const root = generateOdinRoot(); - const signedRoute = signRoute({ - verseId: target.verseId, - authorityRuntimeId: target.authorityRuntimeId, - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - privateKey: root.privateKey, - odinKeyId: root.publicKey.keyId, - generation: "route-generation-1", - }); - const trust: CultMeshAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [root.publicKey] }; - const realConnector = new CultMeshQuicRealtimeConnector(); - let connectedTransport: CultMeshRealtimeTransport | undefined; - // A real transport, connected for real against the raw listener, but - // stamped with a generation different from the one the session manager - // just verified — the way a stale cached transport would look after its - // route rotated underneath it. - const replayConnector: CultMeshRealtimeTransportConnector = { - connectorId: "replay", - priority: 0, - canConnect: (c) => realConnector.canConnect(c), - connect: async (c, t, signal) => { - const accepted = listener.acceptOnce(); - const transport = await realConnector.connect({ ...c, generation: "route-generation-1-stale" }, t, signal); - await accepted; - connectedTransport = transport; - return transport; - }, - }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoute(target.verseId, signedRoute)]), - trust, - connectors: [replayConnector], - }); - - await assertRejectsAndDisposes(manager.connect(target), /did not prove|No realtime state path/i); - assert.ok(connectedTransport, "a real transport must have connected"); - // Real cleanup ran: receiveFrame on the disposed transport rejects. - await assert.rejects(connectedTransport!.receiveFrame(), /disposed/i); - } finally { - await listener.close(); - } -}); - -test("the advertised certificate pin is matched case-insensitively (M2)", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex().toUpperCase()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const transport = await connector.connect(candidate, target); - transport.dispose(); - } finally { - await listener.close(); - } -}); - -// --------------------------------------------------------------------------- -// Fix batch: pump/dispatch isolation (1), peer-shutdown cleanup (3), the -// bounded receive backlog and stream-kind pruning (6). -// --------------------------------------------------------------------------- - -test("fix 1 / M4: a stream-kind/delivery mismatch faults only that connection; the pump keeps running", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; - - // A reliable stream carrying a frame whose own delivery byte says - // latest-only: `onStreamFrame` throws, which must fault only this - // connection. - const connectorA = new CultMeshQuicRealtimeConnector(); - const acceptedA = listener.acceptOnce(); - const transportA = await connectorA.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdA = await acceptedA; - listener.sendFrame(connectionIdA, testFrame({ delivery: "latest-only" }), CULTMESH_QUIC_STREAM_RELIABLE); - // Bounded: if the mismatch check regresses (never faults), receiveFrame() - // waits forever for a frame that will not arrive, and a bare - // `assert.rejects` would hang this test, and the whole file, right along - // with it (as M4 did before this bound existed). - await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); - // Future receives on the faulted transport reject immediately, with the - // same error, rather than hanging. - await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); - } finally { - transportA.dispose(); - } - - // A second, independent connection through the same process-wide runtime - // and pump must still work: the fault above did not bring it down. - const connectorB = new CultMeshQuicRealtimeConnector(); - const acceptedB = listener.acceptOnce(); - const transportB = await connectorB.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdB = await acceptedB; - listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transportB.receiveFrame(); - assert.equal(received.bodyId, "body:aetheria:entities"); - } finally { - transportB.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("M4b: a latest-only stream carrying a reliable-ordered frame faults only that connection", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; - - // The mirror image of the M4 test above: a latest-only stream carrying - // a frame whose own delivery byte says reliable-ordered. - const connectorA = new CultMeshQuicRealtimeConnector(); - const acceptedA = listener.acceptOnce(); - const transportA = await connectorA.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdA = await acceptedA; - listener.sendFrame(connectionIdA, testFrame({ delivery: "reliable-ordered" }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - await assert.rejects(receiveFrameOrTimeout(transportA), /incompatible delivery semantics/i); - } finally { - transportA.dispose(); - } - - // A second, independent connection still works. - const connectorB = new CultMeshQuicRealtimeConnector(); - const acceptedB = listener.acceptOnce(); - const transportB = await connectorB.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdB = await acceptedB; - listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transportB.receiveFrame(); - assert.equal(received.bodyId, "body:aetheria:entities"); - } finally { - transportB.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("fix 1: a throwing onDisposed handler cannot crash the pump when a malformed frame faults a connection", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const endpoint = (): string => `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`; - - const connectorA = new CultMeshQuicRealtimeConnector(); - const acceptedA = listener.acceptOnce(); - const transportA = await connectorA.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - // A throwing onDisposed handler, registered before the fault: fix 1 - // must run it inside its own try/catch so it cannot escape cleanup(), - // and must have already released the runtime reference regardless. - let secondHandlerRan = false; - transportA.onDisposed?.(() => { - throw new Error("first onDisposed handler throws on purpose"); - }); - transportA.onDisposed?.(() => { - secondHandlerRan = true; - }); - - try { - const connectionIdA = await acceptedA; - // Random bytes on a reliable stream: not a valid encoded frame, so - // decodeRealtimeFrame throws inside onStreamFrame, which native's - // dispatch loop (fix 1's native half) catches and faults only this - // connection. Bounded: if the fault path regresses and never faults - // this connection, receiveFrame() would otherwise wait forever for a - // frame that will not arrive. - listener.sendRaw(connectionIdA, new Uint8Array([9, 9, 9, 9, 9, 9, 9, 9]), CULTMESH_QUIC_STREAM_RELIABLE); - await assert.rejects(receiveFrameOrTimeout(transportA)); - assert.equal(secondHandlerRan, true, "a throwing handler must not stop the next onDisposed handler from running"); - } finally { - transportA.dispose(); - } - - // The process (and this test file's pump) survives, and a second, - // independent connection through the same runtime still works. - const connectorB = new CultMeshQuicRealtimeConnector(); - const acceptedB = listener.acceptOnce(); - const transportB = await connectorB.connect( - { endpoint: endpoint(), authorityRuntimeId: target.authorityRuntimeId, priority: 0, generation: "gen-1" }, - target, - ); - try { - const connectionIdB = await acceptedB; - listener.sendFrame(connectionIdB, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transportB.receiveFrame(); - assert.equal(received.bodyId, "body:aetheria:entities"); - } finally { - transportB.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("fix 1: a throwing onDisposed handler on a peer shutdown still releases the runtime and runs every handler", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - - let secondHandlerRan = false; - transport.onDisposed?.(() => { - throw new Error("first onDisposed handler throws on purpose"); - }); - transport.onDisposed?.(() => { - secondHandlerRan = true; - }); - - const before = CultMeshQuicNativeRuntime.refCount; - listener.shutdownConnection(connectionId); - // Wait for cleanup() to run (driven by the pump's CONNECTION_SHUTDOWN - // dispatch), rather than asserting immediately. Bounded: if the - // shutdown-cleanup path regresses (F3) and never rejects, this would - // otherwise hang instead of failing the assertion below it. - await assert.rejects(receiveFrameOrTimeout(transport)); - assert.equal( - CultMeshQuicNativeRuntime.refCount, - before - 1, - "the runtime reference must be released even though a handler threw", - ); - assert.equal(secondHandlerRan, true, "every onDisposed handler must still run despite an earlier one throwing"); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("M10: an equal generation delivered after the first was already consumed is dropped, not redelivered", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - - // Send one frame and consume it, so the coalescer no longer holds - // anything pending for this key: the generation filter's own record - // (`latestGenerations`) is the only thing left that could still know - // this generation was already seen. Sending a frame back-to-back with - // an unconsumed duplicate would mask a broken filter behind the - // coalescer overwriting the pending value with an identical one; this - // sequencing is what M10 needs to be observable at all (Soul's note). - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const first = await transport.receiveFrame(); - assert.equal(first.sequence, 3n); - - // The exact same generation, sent again after consumption: the - // filter must drop it outright rather than deliver it as new. - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - await new Promise((resolve) => setTimeout(resolve, 200)); - - const timeout = new AbortController(); - const timer = setTimeout(() => timeout.abort(), 300); - await assert.rejects( - transport.receiveFrame(timeout.signal), - "an equal generation already consumed must not be redelivered", - ); - clearTimeout(timer); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -// F7 ("a canceled send must not resolve as success") is covered in -// realtime-quic-native.test.ts, at the native binding layer, not here. -// A consumer-level scenario was attempted first: prime the reliable stream, -// start a second send, and shut the connection down from the peer mid-flight. -// It proved unreliable on this host regardless of payload size or added -// delay before the shutdown call — MsQuic here appears to accept and locally -// complete a `StreamSend` (marking it as a plain, non-canceled -// SEND_COMPLETE) faster than any JS-observable window can reliably land a -// shutdown ahead of it, and a delay long enough to guarantee the shutdown -// wins just lets the send finish first instead. This is recorded as not yet -// reached at the consumer layer, not as unreachable: the native-layer test -// asserts the exact field (`code`, decoded through the same struct decode -// F7 mutates) that this scenario would also exercise, so the fix itself is -// defended; only the end-to-end SEND_CANCELED proof through the consumer's -// own `sendFrame()` promise is the open gap. - -test("fix 3: a peer-initiated shutdown rejects a pending receive and cleans up exactly once", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - - // Bounded: this is F3's own mutant target (removing the - // CONNECTION_SHUTDOWN cleanup call), and a broken cleanup path means - // this receive never settles. - const pendingReceive = receiveFrameOrTimeout(transport); - listener.shutdownConnection(connectionId); - await assert.rejects(pendingReceive, /closed by the remote peer/i); - - // Cleanup already ran: a further explicit dispose is a harmless no-op, - // and a fresh receive rejects immediately instead of hanging forever - // (the leak fix 3 exists for). - transport.dispose(); - await assert.rejects(receiveFrameOrTimeout(transport), /closed by the remote peer/i); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("fix 6: a slow latest-only reader accumulates at most one pending frame per key, and an equal generation is dropped", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - - // Three increasing-sequence frames for the same (channel, body), - // published while nothing reads: an unbounded queue would deliver all - // three in order; the coalescing inbox keeps only the newest. - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 1n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 2n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - // An equal generation, sent last: it must be dropped outright, not - // queued as a fourth pending frame. - listener.sendFrame(connectionId, testFrame({ producerEpoch: 1n, sequence: 3n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - - // Give the pump time to process all four sends before reading. - await new Promise((resolve) => setTimeout(resolve, 200)); - - const first = await transport.receiveFrame(); - assert.equal(first.sequence, 3n, "only the newest pending frame for the key is kept"); - - const timeout = new AbortController(); - const timer = setTimeout(() => timeout.abort(), 300); - await assert.rejects(transport.receiveFrame(timeout.signal), "nothing else should be pending"); - clearTimeout(timer); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("fix 6: a stream shutdown prunes its entry so streamKinds does not grow without bound", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const accepted = listener.acceptOnce(); - const transport = await connector.connect(candidate, target); - try { - const connectionId = await accepted; - listener.sendFrame(connectionId, testFrame(), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const received = await transport.receiveFrame(); - assert.equal(received.sequence, 1n); - - // The server-side stream the frame arrived on is a fresh latest-only - // stream the listener opened and finished with `fin: true`; the native - // bridge emits its own shutdown event once MsQuic completes teardown. - // Wait for that STREAM_SHUTDOWN to actually land, then assert the - // internal map was pruned, not just that a second frame still works - // (F6kinds's removed prune line has no behavioural symptom until a - // stream id is reused, which QUIC never does within one connection). - await new Promise((resolve) => setTimeout(resolve, 300)); - assert.equal( - (transport as CultMeshQuicRealtimeTransport).trackedStreamCount, - 0, - "streamKinds must be pruned once the stream shuts down, not accumulate one entry per stream", - ); - - listener.sendFrame(connectionId, testFrame({ sequence: 2n }), CULTMESH_QUIC_STREAM_LATEST_ONLY); - const second = await transport.receiveFrame(); - assert.equal(second.sequence, 2n); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); - -test("P8: a reliable send still queued behind reliableSendTail rejects instead of hanging when disposal wins the race", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const listener = await RawQuicListener.open(); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: `cultmesh-state+quic://127.0.0.1:${listener.boundPort}?cert-sha256=${fixturePinHex()}`, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - const transport = await connector.connect(candidate, target); - try { - // Prime the reliable outbound stream and let it fully settle, so its - // id is already cached: a second reliable send then chains straight - // onto `sendOnStream` with no `streamOpen` call of its own in between - // (a `streamOpen` against an already-shut-down connection fails with - // its own native error, which would mask the guard this test targets). - await transport.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - - // `sendFrame` chains onto `reliableSendTail`, which resolves as a - // microtask; calling `dispose()` synchronously right after, in the - // same turn, guarantees `cleanup()` has already set `disposed = true` - // by the time that microtask runs `sendOnStream`. Without the - // `this.disposed` guard `sendOnStream` registers (P8's target), this - // send would sit in `pendingSends` forever: `cleanup()`'s reject sweep - // already ran once before this send was ever registered, and no later - // native event can reach it either, since `offConnectionEvent` already - // ran too. - const send = transport.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 2n })); - transport.dispose(); - // Bounded: without the guard, `send` never settles (nothing left to - // reject it), so a bare `assert.rejects(send, ...)` would hang this - // whole file instead of failing this one test by assertion. Racing - // against a timeout that rejects with a different message turns a - // regression into a prompt, loud assertion mismatch. - const bounded = Promise.race([ - send, - new Promise((_, reject) => - setTimeout(() => reject(new Error("P8 guard timed out instead of rejecting")), 3_000), - ), - ]); - await assert.rejects(bounded, /disposed/i); - } finally { - transport.dispose(); - } - } finally { - await listener.close(); - } -}); diff --git a/packages/cultmesh-ts/test/realtime-quic-native.test.ts b/packages/cultmesh-ts/test/realtime-quic-native.test.ts deleted file mode 100644 index 039d9047..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-native.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -// The Cut 3 FFI smoke, now run against `CultMeshQuicNativeRuntime` instead of -// raw koffi calls: open a runtime, open a listener with a real credential, -// connect a real loopback connection, exchange one frame's worth of raw -// bytes, and close everything down. This proves the binding's declared ABI -// matches the shipped bridge; frame semantics (delivery, generation) are -// `realtime-quic-consumer.test.ts`'s job. -// -// Skips instead of failing when no native bridge is available: set -// CULTMESH_QUIC_NATIVE_DIR to a directory `scripts/build-quic-native.ps1` or -// `scripts/build-quic-native.sh` built, or run from the committed -// `packages/cultmesh-ts/native/` tree once a later cut lands it. - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import test from "node:test"; - -import { - CultMeshQuicNativeRuntime, - CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION, - CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, - CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED, - CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN, - CULTMESH_QUIC_EVENT_STREAM_STARTED, - CULTMESH_QUIC_EVENT_STREAM_FRAME, - CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE, - CULTMESH_QUIC_STREAM_RELIABLE, -} from "../src/realtime-quic-native"; -import { nativeBridgeAvailable } from "./support/native-bridge"; - -// __dirname at runtime is dist-test/test; fixtures are binary and are never -// compiled/copied there, so they are read from their source location, two -// levels up. -const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); - -async function waitFor(collect: (resolve: (value: T) => void) => void, timeoutMs = 5_000): Promise { - return await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error("timed out waiting for a native QUIC event")), timeoutMs); - collect((value) => { - clearTimeout(timer); - resolve(value); - }); - }); -} - -test("CultMeshQuicNativeRuntime opens and closes with no listener or connection", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - await runtime.release(); -}); - -test("CultMeshQuicNativeRuntime opens and closes a listener with a real PKCS12 credential", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - assert.ok(boundPort > 0); - runtime.listenerClose(listenerId); - } finally { - await runtime.release(); - } -}); - -test("a full loopback connection delivers one raw frame end to end", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - - const accepted = waitFor((resolve) => { - runtime.onListenerEvent(listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); - }); - }); - - const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort); - const clientConnected = waitFor((resolve) => { - runtime.onConnectionEvent(clientConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - runtime.connectionCertificateComplete(clientConnectionId, true); - return; - } - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); - }); - }); - - const serverConnectionId = await accepted; - const serverFrame = waitFor((resolve) => { - runtime.onConnectionEvent(serverConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_STREAM_STARTED) { - assert.equal(event.streamKind, CULTMESH_QUIC_STREAM_RELIABLE); - return; - } - if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload); - }); - }); - - await clientConnected; - const streamId = runtime.streamOpen(clientConnectionId, CULTMESH_QUIC_STREAM_RELIABLE); - const sendComplete = waitFor((resolve) => { - runtime.onConnectionEvent(clientConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_STREAM_SEND_COMPLETE) resolve(); - }); - }); - runtime.streamSendFrame(streamId, new Uint8Array([1, 2, 3, 4]), true); - await sendComplete; - - const payload = await serverFrame; - assert.deepEqual(Array.from(payload), [1, 2, 3, 4]); - - runtime.connectionShutdown(clientConnectionId, 0n); - runtime.connectionShutdown(serverConnectionId, 0n); - runtime.listenerClose(listenerId); - } finally { - await runtime.release(); - } -}); - -test("F7: a 64-bit event code round-trips exactly through the struct decode", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - - // `code` (a `uint64_t`, the same field `CULTMESH_QUIC_SEND_CANCELED` is - // compared against on the stream-send path) is exercised here through - // CONNECTION_SHUTDOWN instead, whose code is an application-chosen value - // this test fully controls: two values above 2^53 (where koffi's decode - // stops being a plain safe-integer `number`) prove the whole - // encode-native-decode-coerce path is exact, deterministically, with no - // dependency on MsQuic's own send-completion timing. - for (const code of [(1n << 53n) + 1n, (1n << 62n) - 1n]) { - const accepted = waitFor((resolve) => { - runtime.onListenerEvent(listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); - }); - }); - const clientConnectionId = runtime.connectionOpen("127.0.0.1", boundPort); - const clientConnected = waitFor((resolve) => { - runtime.onConnectionEvent(clientConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - runtime.connectionCertificateComplete(clientConnectionId, true); - return; - } - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); - }); - }); - const serverConnectionId = await accepted; - await clientConnected; - - const seenOnClient = waitFor((resolve) => { - runtime.onConnectionEvent(clientConnectionId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_SHUTDOWN) resolve(event.code); - }); - }); - runtime.connectionShutdown(serverConnectionId, code); - const seen = await seenOnClient; - assert.equal(typeof seen, "bigint", "a decoded event code must always be bigint, never number"); - assert.equal(seen, code, `shutdown code ${code} must round-trip exactly through the struct decode`); - } - - runtime.listenerClose(listenerId); - } finally { - await runtime.release(); - } -}); - -test( - "P10b: a throwing onFault handler is isolated — the pump survives and other connections' handlers still run", - { timeout: 10_000 }, - async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - try { - const pkcs12 = readFileSync(FIXTURE_P12); - const { listenerId, boundPort } = runtime.listenerOpen("127.0.0.1", 0, pkcs12, ""); - - // Connection B: a normal, fully connected loopback pair, established - // before A ever faults — so proving B still works afterward exercises - // an already-running connection's handler, not a second accept cycle - // racing whatever state A's fault left behind. - const acceptedB = waitFor((resolve) => { - runtime.onListenerEvent(listenerId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) resolve(event.connectionId); - }); - }); - const clientB = runtime.connectionOpen("127.0.0.1", boundPort); - const clientBConnected = waitFor((resolve) => { - runtime.onConnectionEvent(clientB, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - runtime.connectionCertificateComplete(clientB, true); - return; - } - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CONNECTED) resolve(); - }); - }); - const serverBId = await acceptedB; - const serverBFrame = waitFor((resolve) => { - runtime.onConnectionEvent(serverBId, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_STREAM_FRAME) resolve(event.payload); - }); - }); - await clientBConnected; - - // Connection A, opened only after B is fully connected: both its - // listener and its onFault handler throw on the first event delivered - // to it. Without `faultConnection`'s own try/catch isolating a - // throwing `onFault` (P10b's target), that throw would escape the - // pump's dispatch loop and take every other connection — including - // the already-live B — down with it, instead of staying scoped to A. - // - // The server-side connection listener is registered synchronously, - // inside the LISTENER_NEW_CONNECTION callback itself, rather than - // after an `await`: the native bridge can dispatch a connection's next - // event (CERTIFICATE_RECEIVED, or CONNECTED) before a later `await`'s - // continuation ever runs, and an event with no listener registered for - // its connection id is silently dropped (`dispatch`'s own - // `if (!entry) return`) — the same async-registration gap fix 1 closed - // for the provider's own accept path. - const serverAFaulted = waitFor((resolve) => { - runtime.onListenerEvent(listenerId, (event) => { - if (event.type !== CULTMESH_QUIC_EVENT_LISTENER_NEW_CONNECTION) return; - runtime.onConnectionEvent( - event.connectionId, - () => { - throw new Error("P10b: connection A's listener always throws."); - }, - () => { - resolve(); - throw new Error("P10b: connection A's onFault also throws."); - }, - ); - }); - }); - const clientA = runtime.connectionOpen("127.0.0.1", boundPort); - runtime.onConnectionEvent(clientA, (event) => { - if (event.type === CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED) { - runtime.connectionCertificateComplete(clientA, true); - } - }); - await serverAFaulted; - - // Prove B's handler is still live after A's onFault threw: send a - // frame on B and confirm the server side still receives it. - const streamId = runtime.streamOpen(clientB, CULTMESH_QUIC_STREAM_RELIABLE); - runtime.streamSendFrame(streamId, new Uint8Array([9, 9, 9]), true); - const payload = await serverBFrame; - assert.deepEqual( - Array.from(payload), - [9, 9, 9], - "connection B must still deliver frames after A's onFault threw", - ); - - runtime.connectionShutdown(clientA, 0n); - runtime.connectionShutdown(clientB, 0n); - runtime.connectionShutdown(serverBId, 0n); - runtime.listenerClose(listenerId); - } finally { - await runtime.release(); - } - }, -); - -test("fix 2: release() refuses a call with no outstanding reference", async (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - const runtime = await CultMeshQuicNativeRuntime.open(); - await runtime.release(); - await assert.rejects(runtime.release(), /no outstanding reference/i); -}); - -test("negative grep: no koffi callbacks, .async only on nextEvent", () => { - // __dirname at runtime is dist-test/test; the source lives two levels up, - // in src/, since only .js output is mirrored under dist-test. - const source = readFileSync(join(__dirname, "..", "..", "src", "realtime-quic-native.ts"), "utf8"); - // `.async` is referenced (not necessarily called with a literal "(" at the - // reference site: it is handed to `promisify`, which calls it) only for - // `nextEvent`, the ABI's single blocking export. - const asyncUses = [...source.matchAll(/\.async\b/g)]; - assert.ok(asyncUses.length > 0, "expected at least one .async use for nextEvent"); - for (const match of asyncUses) { - const before = source.slice(Math.max(0, match.index! - 40), match.index!); - assert.match(before, /nextEvent/i, "the only .async reference must be nextEvent"); - } - assert.doesNotMatch(source, /koffi\.register/, "no koffi callbacks"); -}); - -// The measurement below runs in its own `node --expose-gc` child process -// instead of in this test's own process: by the time this test would run, -// ~150 earlier tests in the suite have already grown and partly freed the -// glibc heap, so a real per-call native leak can land in already-mapped free -// space and never show up as RSS growth in *this* process (confirmed by hand: -// the in-process version of this test passed even with `nextEvent` mutated -// back to allocating fresh koffi buffers every call). A freshly spawned -// process has no such history, matching the ~8 MiB (fixed) vs ~30 MiB -// (pre-fix) split measured in isolation. -const MEMORY_PROBE_SCRIPT = ` -const { CultMeshQuicNativeRuntime } = require(process.argv[1]); -(async () => { - const runtime = await CultMeshQuicNativeRuntime.open(); - const nextEvent = runtime.nextEvent.bind(runtime); - try { - for (let i = 0; i < 2000; i += 1) await nextEvent(0, null, 0); - global.gc(); - const before = process.memoryUsage().rss; - const ITERATIONS = 200000; - for (let i = 0; i < ITERATIONS; i += 1) await nextEvent(0, null, 0); - global.gc(); - const after = process.memoryUsage().rss; - process.stdout.write(JSON.stringify({ before, after, growthBytes: after - before, ITERATIONS })); - } finally { - await runtime.release(); - } -})().catch((error) => { - console.error(error); - process.exit(1); -}); -`; - -test( - "memory: sustained nextEvent calls do not leak koffi-allocated out-parameter buffers", - { timeout: 60_000 }, - (t) => { - if (!nativeBridgeAvailable()) { - t.skip("CULTMESH_QUIC_NATIVE_DIR is not set to a built native bridge."); - return; - } - - // `nextEvent` is private; the compiled module path is resolved the same - // way this file's own `import` resolves it, and handed to the child by - // argv so the child needs no path logic of its own. - const modulePath = require.resolve("../src/realtime-quic-native"); - const result = spawnSync(process.execPath, ["--expose-gc", "-e", MEMORY_PROBE_SCRIPT, modulePath], { - encoding: "utf8", - env: process.env, - }); - if (result.status !== 0 || typeof result.stdout !== "string" || result.stdout.trim().length === 0) { - if (/--expose-gc/.test(result.stderr ?? "")) { - t.skip("this Node build does not support --expose-gc."); - return; - } - assert.fail( - `memory probe child process failed (status ${String(result.status)}):\n${result.stderr ?? "(no stderr)"}`, - ); - } - - const { before, after, growthBytes, ITERATIONS } = JSON.parse(result.stdout.trim()) as { - before: number; - after: number; - growthBytes: number; - ITERATIONS: number; - }; - // Measured on Yggdrasil, same build and host, in a dedicated process: - // fixed code (0 `koffi.alloc` calls in the loop): ~8.0 MiB RSS growth. - // pre-fix code (400k `koffi.alloc` calls, never freed): ~29.9 MiB. - // 18 MiB sits well above the fixed baseline and well below the leaking - // one, so the bound stays robust to ordinary run-to-run noise while - // still catching the regression. - const BOUND_BYTES = 18 * 1024 * 1024; - assert.ok( - growthBytes < BOUND_BYTES, - `RSS grew by ${growthBytes} bytes (${(growthBytes / 1024 / 1024).toFixed(2)} MiB, from ${before} to ${after}) ` + - `over ${ITERATIONS} nextEvent calls; expected it to stay under ${BOUND_BYTES / 1024 / 1024} MiB once ` + - "out-parameter buffers are reused instead of allocated per call.", - ); - }, -); diff --git a/packages/cultmesh-ts/test/realtime-quic-provider.test.ts b/packages/cultmesh-ts/test/realtime-quic-provider.test.ts deleted file mode 100644 index 8a541641..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-provider.test.ts +++ /dev/null @@ -1,1083 +0,0 @@ -// `CultMeshQuicRealtimeProvider`, Cut 5's StreamPixels role, exercised -// end-to-end against the real native bridge and the real consumer connector -// from Cut 4 (no mocks): reliable ordering in both directions -// (`CultMeshQuicRealtimeTransportTests.cs:71-110`), latest-only coalescing -// per `(channel, body)` key, a non-blocking broadcast, the uppercase pin, and -// eviction on peer shutdown. -// -// Skips instead of failing when no native bridge is available; see -// `realtime-quic-native.test.ts` for why. - -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import test from "node:test"; - -import { - CultMeshQuicNativeRuntime, -} from "../src/realtime-quic-native"; -import { - CultMeshQuicRealtimeConnector, - CultMeshQuicRealtimeProvider, - CultMeshQuicRealtimeTransport as CultMeshQuicRealtimeTransportClass, - parseQuicRealtimeEndpoint, - type CultMeshRealtimeCandidate, - type CultMeshRealtimeTarget, - type CultMeshRealtimeTransport, -} from "../src/realtime-quic"; -import type { CultMeshRealtimeFrame } from "../src/realtime-wire"; -import { nativeBridgeAvailable } from "./support/native-bridge"; - -// __dirname at runtime is dist-test/test; fixtures are binary and are never -// compiled/copied there, so they are read from their source location, two -// levels up. -const FIXTURE_P12 = join(__dirname, "..", "..", "test", "fixtures", "quic-test.p12"); - -/** - * `assert.rejects`, but safe against a mutant or a real regression that - * makes the promise resolve instead: a transport that unexpectedly connects - * is disposed before the assertion fails, so a broken certificate check - * cannot hang this file the way M1 did before realtime-quic-consumer.test.ts - * gained this same helper. - */ -async function assertRejectsAndDisposes( - promise: Promise<{ dispose(): void }>, - match?: RegExp, -): Promise { - let resolved: { dispose(): void } | undefined; - try { - resolved = await promise; - } catch (error) { - if (match) assert.match(error instanceof Error ? error.message : String(error), match); - return; - } - resolved.dispose(); - assert.fail("expected the promise to reject, but it resolved (the resolved value has been disposed)"); -} - -function testFrame(overrides: Partial = {}): CultMeshRealtimeFrame { - return { - channelId: "aetheria.entities", - schemaId: "eve.entity_soa.v1", - bodyId: "body:aetheria:entities", - producerEpoch: 7n, - sequence: 1n, - delivery: "latest-only", - payload: new Uint8Array([3, 4, 5]), - ...overrides, - }; -} - -async function startProvider(): Promise { - return await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - }); -} - -async function dialProvider(provider: CultMeshQuicRealtimeProvider): Promise { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - endpoint: provider.advertisedEndpoint, - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - return await connector.connect(candidate, target); -} - -async function waitUntil(predicate: () => boolean, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs; - while (!predicate()) { - if (Date.now() >= deadline) throw new Error("timed out waiting for a provider condition"); - await new Promise((resolve) => setTimeout(resolve, 10)); - } -} - -/** - * Waits for `CultMeshQuicNativeRuntime.refCount` to stop moving: `dispose()` - * releases its runtime reference fire-and-forget (`void this.runtime.release()`), - * so a just-finished test's cleanup can still be landing when the next test - * starts. A test that snapshots refCount as a baseline without waiting for - * this first can catch that in-flight decrement mid-test and see refCount - * undershoot its own "before" value instead of returning to it. - */ -async function waitForRefCountToStabilize(quietMs = 200, timeoutMs = 5_000): Promise { - const deadline = Date.now() + timeoutMs; - let last = CultMeshQuicNativeRuntime.refCount; - let stableSince = Date.now(); - for (;;) { - await new Promise((resolve) => setTimeout(resolve, 20)); - const current = CultMeshQuicNativeRuntime.refCount; - if (current !== last) { - last = current; - stableSince = Date.now(); - } else if (Date.now() - stableSince >= quietMs) { - return current; - } - if (Date.now() >= deadline) return current; - } -} - -test("the advertised endpoint carries an uppercase cert-sha256 pin the Unity connector requires", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - assert.match( - provider.advertisedEndpoint, - /^cultmesh-state\+quic:\/\/127\.0\.0\.1:\d+\?cert-sha256=[0-9A-F]{64}$/, - ); - // Not merely uppercase-tolerant: it must not contain a lowercase hex digit. - const pin = new URL(provider.advertisedEndpoint).searchParams.get("cert-sha256")!; - assert.equal(pin, pin.toUpperCase()); - assert.notEqual(pin, pin.toLowerCase()); - } finally { - provider.dispose(); - } -}); - -test("reliable-ordered delivers frames in order, provider to consumer and consumer to provider", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 2n })); - const firstAtConsumer = await consumer.receiveFrame(); - const secondAtConsumer = await consumer.receiveFrame(); - assert.deepEqual([firstAtConsumer.sequence, secondAtConsumer.sequence], [1n, 2n]); - - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 3n })); - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 4n })); - const firstAtProvider = await provider.receive(); - const secondAtProvider = await provider.receive(); - assert.deepEqual([firstAtProvider.sequence, secondAtProvider.sequence], [3n, 4n]); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("latest-only broadcast coalesces back-to-back sends for the same key to the newest frame, and never blocks on I/O", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // Three broadcasts for the same (channelId, bodyId) key, issued back to - // back with no await between them. `broadcast` for latest-only never - // awaits any I/O (mutating it to await the peer's send would make this - // loop, and the assertion below, run after real network round trips - // instead of within one JS turn): the synchronous middle publish is - // therefore always coalesced away, deterministically, not by luck of - // network timing. Mutating the coalescing key (e.g. dropping bodyId) - // would let sequence 200 leak through as a separate delivered frame - // instead of overwriting 100. - const before = Date.now(); - const first = provider.broadcast(testFrame({ sequence: 1n })); - const second = provider.broadcast(testFrame({ sequence: 100n })); - const third = provider.broadcast(testFrame({ sequence: 200n })); - const afterCallsReturned = Date.now(); - // All three promises exist before any of them could possibly have - // waited on the peer's round trip: the loop above ran in well under a - // network round trip's worth of time. - assert.ok(afterCallsReturned - before < 50, "broadcast must not block on any peer's I/O"); - await Promise.all([first, second, third]); - - // The consumer's own receive-side inbox (Cut 4) also coalesces - // latest-only frames it has not yet read, so whether 1 is delivered as - // its own frame or is itself coalesced away by 200 arriving first - // depends on read timing; that is Cut 4's contract, not this test's. - // What this test owns is the provider's outbox: 100 must never appear, - // and the last frame delivered must be 200. - const received: bigint[] = []; - let last: bigint | undefined; - while (last !== 200n) { - const frame = await consumer.receiveFrame(); - received.push(frame.sequence); - last = frame.sequence; - } - assert.ok(!received.includes(100n), `100 must be coalesced away, got ${received}`); - assert.ok(received.length <= 2, `expected at most 2 delivered frames, got ${received}`); - assert.equal(last, 200n); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("a stalled peer's outbox holds at most one pending frame per key across many broadcasts", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // 200 broadcasts, none awaited individually, none read by the consumer - // until they are all issued: mirrors the interop lane's stalled-reader - // scenario at unit-test speed. `broadcast` completing this loop at all - // (bounded time) proves it never blocks on the unread peer. - const started = Date.now(); - const sends: Promise[] = []; - for (let sequence = 1; sequence <= 200; sequence += 1) { - sends.push(provider.broadcast(testFrame({ sequence: BigInt(sequence) }))); - } - await Promise.all(sends); - assert.ok(Date.now() - started < 2_000, "200 broadcasts to one key must complete quickly, unread"); - - const receivedSequences: bigint[] = []; - let last: bigint | undefined; - while (last !== 200n) { - const frame = await consumer.receiveFrame(); - receivedSequences.push(frame.sequence); - last = frame.sequence; - } - assert.equal(last, 200n); - // Coalescing means far fewer than 200 frames were actually put on the - // wire; the exact count depends on scheduling, but it must be small. - assert.ok(receivedSequences.length < 200, `expected coalescing, got ${receivedSequences.length} frames`); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test( - "20,000 client latest-only frames on one key leave at most 1 pending in the provider's receive queue", - { timeout: 120_000 }, - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // The exact scenario Soul's probe reproduced against the unbounded - // `readyFrames` array this replaced: a publish-only StreamPixels-style - // consumer of `receive()` never drains the provider's fan-in queue, - // so a client hammering one key must not be able to grow it past one - // entry no matter how many frames it sends. - const total = 20_000; - const sends: Promise[] = []; - for (let sequence = 1; sequence <= total; sequence += 1) { - sends.push(consumer.sendFrame(testFrame({ delivery: "latest-only", sequence: BigInt(sequence) }))); - } - await Promise.all(sends); - await waitUntil(() => provider.receiveQueueSize > 0, 30_000); - // Let any trailing send_complete events land before asserting the - // queue has settled, so this isn't racing the last few deliveries. - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.equal( - provider.receiveQueueSize, - 1, - `one client key must coalesce to at most one pending frame, got ${provider.receiveQueueSize}`, - ); - const frame = await provider.receive(); - assert.equal(frame.sequence, BigInt(total), "the one pending frame must be the newest generation sent"); - assert.equal(provider.receiveQueueSize, 0); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } - }, -); - -test( - "many distinct client latest-only keys stay bounded by the key count, not the frame count", - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - const keyCount = 50; - const sendsPerKey = 20; - const sends: Promise[] = []; - for (let key = 0; key < keyCount; key += 1) { - for (let sequence = 1; sequence <= sendsPerKey; sequence += 1) { - sends.push( - consumer.sendFrame( - testFrame({ - delivery: "latest-only", - bodyId: `body:${key}`, - sequence: BigInt(sequence), - }), - ), - ); - } - } - await Promise.all(sends); - await waitUntil(() => provider.receiveQueueSize > 0, 10_000); - await new Promise((resolve) => setTimeout(resolve, 200)); - - assert.equal( - provider.receiveQueueSize, - keyCount, - `the queue must hold exactly one pending frame per distinct key, got ${provider.receiveQueueSize}`, - ); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } - }, -); - -test("reliable-ordered client frames are delivered in order at the provider, never coalesced", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - const total = 500; - for (let sequence = 1; sequence <= total; sequence += 1) { - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: BigInt(sequence) })); - } - await waitUntil(() => provider.receiveQueueSize === total, 10_000); - - const received: bigint[] = []; - for (let i = 0; i < total; i += 1) { - received.push((await provider.receive()).sequence); - } - const expected = Array.from({ length: total }, (_, i) => BigInt(i + 1)); - assert.deepEqual(received, expected, "reliable-ordered frames must never be coalesced or reordered"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("frames already queued in the provider's receive queue drain before dispose's rejection", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - await consumer.sendFrame(testFrame({ delivery: "reliable-ordered", sequence: 2n })); - await waitUntil(() => provider.receiveQueueSize === 2); - - // Dispose while two frames are still sitting in the queue, unread. - provider.dispose(); - - const first = await provider.receive(); - const second = await provider.receive(); - assert.deepEqual([first.sequence, second.sequence], [1n, 2n], "already-queued frames must drain first"); - await assert.rejects(() => provider.receive(), /disposed/, "only a call past the drained backlog rejects"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("unreliable broadcast fails closed", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await assert.rejects(provider.broadcast(testFrame({ delivery: "unreliable" })), /datagrams/i); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("a peer is evicted from the provider on connection shutdown", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - await waitUntil(() => provider.connectionCount === 1); - consumer.dispose(); - await waitUntil(() => provider.connectionCount === 0); - } finally { - provider.dispose(); - } -}); - -test("a reliable-ordered send failure evicts the peer without broadcast() throwing to the caller", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - await waitUntil(() => provider.connectionCount === 1); - consumer.dispose(); - // Keep broadcasting reliable-ordered frames into the dying/dead peer's - // outbound stream for a short window: somewhere in this window the - // send to that peer fails (either the native stream call itself, or - // the peer's own CONNECTION_SHUTDOWN cleanup rejecting it), and - // CultMeshQuicRealtimeProvider.broadcast()'s per-peer try/catch must - // dispose it rather than let the rejection propagate or leave a dead - // peer counted forever. - const deadline = Date.now() + 2_000; - let sequence = 0n; - while (Date.now() < deadline && provider.connectionCount > 0) { - sequence += 1n; - // Must never throw: a removed subtraction of the catch's dispose() - // call would not itself make broadcast() throw, but a regression - // that let the rejection propagate instead of being caught at all - // would, and this loop would then fail loudly instead of hanging. - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence })); - } - assert.equal(provider.connectionCount, 0, "the dead peer must be evicted, not counted forever"); - } finally { - provider.dispose(); - } -}); - -test("connectionCount reflects accepted peers, and dispose releases the runtime for a fresh listen", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - assert.equal(provider.connectionCount, 0); - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - } finally { - consumer.dispose(); - provider.dispose(); - } - // A leaked runtime reference from any earlier path (accept, handshake - // timeout, dispose) would leave the shared runtime unable to open a fresh - // listener cleanly; opening and tearing down a second provider proves - // release ran on every path exercised above. - const again = await startProvider(); - again.dispose(); -}); - -test("the runtime reference is released when extracting the certificate fails, before any listener opens", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const before = CultMeshQuicNativeRuntime.refCount; - await assert.rejects( - CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - // Not a PKCS12 credential at all: extractLeafCertificateDer() throws - // before CultMeshQuicNativeRuntime.open() is ever called, so there is - // no reference to leak on this path; this pins that (trivial but - // real) invariant rather than exercising listen()'s own - // `catch { await runtime.release(); throw error; }`, which the next - // test covers with a listener-level failure instead. - serverCertificate: { pkcs12: new Uint8Array([1, 2, 3, 4]), password: "" }, - }), - ); - assert.equal(CultMeshQuicNativeRuntime.refCount, before, "must not leak a reference that was never opened"); -}); - -test("the runtime reference is released when the listener itself fails to open", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const holder = await startProvider(); - try { - const boundPort = new URL(holder.advertisedEndpoint).port; - const before = CultMeshQuicNativeRuntime.refCount; - // A real, valid PKCS12 (so extractLeafCertificateDer succeeds and - // CultMeshQuicNativeRuntime.open() actually runs), but the port - // `holder` already has bound: listenerOpen() itself must fail, driving - // listen()'s `catch { await runtime.release(); throw error; }`. - await assert.rejects( - CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: Number(boundPort), - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - }), - ); - assert.equal( - CultMeshQuicNativeRuntime.refCount, - before, - "a listenerOpen() failure must release the reference listen() had already opened", - ); - } finally { - holder.dispose(); - } -}); - -test( - "the runtime reference is released on dispose with a pending send in flight", - { timeout: 10_000 }, - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const before = CultMeshQuicNativeRuntime.refCount; - const provider = await startProvider(); - const consumer = await dialProvider(provider); - await waitUntil(() => provider.connectionCount === 1); - - // Start a reliable-ordered broadcast and dispose before it can possibly - // have settled; dispose() must tear the peer down (and release its - // runtime reference) regardless of the outstanding send. `broadcast()` - // must itself never hang: an explicit `{ timeout }` above fails this - // test loudly, rather than the whole file, if it ever does. - const broadcasting = provider.broadcast(testFrame({ delivery: "reliable-ordered" })).catch(() => {}); - provider.dispose(); - await broadcasting; - consumer.dispose(); - await waitUntil(() => CultMeshQuicNativeRuntime.refCount === before); - }, -); - -test( - "dispose() evicts a connection still mid-handshake, not just fully attached peers", - { timeout: 10_000 }, - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - // A long handshake timeout: without dispose() sweeping still-pending - // accepts (fixed alongside this test), a connection whose handshake - // never reaches CONNECTED — here, the client rejects the provider's - // certificate — would sit on its own handshake timer, and once - // attachPeer's own CultMeshQuicNativeRuntime.open() resolves, hold a - // runtime reference, for up to this long after dispose() returns. Found - // this way: mutating the certificate-pin comparison to be - // case-sensitive (M2) made every provider test that dials with an - // uppercase-pinned endpoint reject during the handshake, and the whole - // file then hung for over two minutes past its last visible test. - // Earlier tests' dispose() releases fire-and-forget; wait for the - // shared refCount to actually settle before treating it as a baseline - // (see waitForRefCountToStabilize's own doc comment). - const before = await waitForRefCountToStabilize(); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - handshakeTimeoutMs: 30_000, - }); - try { - const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - const candidate: CultMeshRealtimeCandidate = { - // A pin that can never match this provider's real certificate: the - // connector rejects it during CULTMESH_QUIC_EVENT_CONNECTION_CERTIFICATE_RECEIVED, - // before the connection ever reaches CONNECTED. - endpoint: provider.advertisedEndpoint.replace(/cert-sha256=[0-9A-F]+/, `cert-sha256=${"0".repeat(64)}`), - authorityRuntimeId: target.authorityRuntimeId, - priority: 0, - generation: "gen-1", - }; - const connector = new CultMeshQuicRealtimeConnector(); - // If the certificate check ever regresses into accepting everything - // (M1), this resolves into a fully attached peer instead of rejecting; - // assertRejectsAndDisposes() disposes the client-side transport either - // way, but only the outer `finally` below disposes the provider itself - // (and the peer it would then hold), which is what actually closes - // the file-hanging gap M1 found here. - await assertRejectsAndDisposes(connector.connect(candidate, target), /certificate|rejected/i); - - // The provider's own accept flow is asynchronous (attachPeer awaits a - // runtime reference before it can even see the rejection); give it a - // moment to actually start before disposing, so this test exercises a - // real in-flight pending accept rather than one that never began. - await new Promise((resolve) => setTimeout(resolve, 100)); - - const disposedAt = Date.now(); - provider.dispose(); - await waitUntil(() => CultMeshQuicNativeRuntime.refCount === before, 5_000); - assert.ok( - Date.now() - disposedAt < 5_000, - "dispose() must evict a mid-handshake connection promptly, not wait out its 30s handshake timeout", - ); - } finally { - provider.dispose(); - } - }, -); - -test("a peer attaching after two latest-only broadcasts for one key receives exactly the newer frame", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - await provider.broadcast(testFrame({ sequence: 1n, payload: new Uint8Array([1]) })); - await provider.broadcast(testFrame({ sequence: 2n, payload: new Uint8Array([2]) })); - - // Attach after both broadcasts have already resolved: nothing in this - // peer's outbox came from `broadcast()`'s own fan-out, only from the - // retained seed on attach. - const consumer = await dialProvider(provider); - try { - const seeded = await consumer.receiveFrame(); - assert.equal(seeded.sequence, 2n, "a late joiner must be seeded with the newest retained generation"); - assert.deepEqual(seeded.payload, new Uint8Array([2])); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("retention keeps the newer generation when an older one broadcasts afterward", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - await provider.broadcast(testFrame({ sequence: 5n, payload: new Uint8Array([5]) })); - // An older generation broadcast after a newer one must not overwrite the - // retained frame: mutating the retention comparison to a plain - // last-write-wins assignment would regress this. - await provider.broadcast(testFrame({ sequence: 3n, payload: new Uint8Array([3]) })); - - const consumer = await dialProvider(provider); - try { - const seeded = await consumer.receiveFrame(); - assert.equal(seeded.sequence, 5n, "an older generation must not replace the retained newer one"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("a reliable-ordered frame is never retained and is not delivered to a late joiner", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const firstConsumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 9n })); - assert.deepEqual((await firstConsumer.receiveFrame()).sequence, 9n); - } finally { - firstConsumer.dispose(); - } - await waitUntil(() => provider.connectionCount === 0); - - // A second peer attaches after the reliable-ordered frame above and - // after a real latest-only frame, so the assertion below proves the - // reliable-ordered frame specifically was never retained, rather than - // merely proving the provider had nothing at all to seed. - await provider.broadcast(testFrame({ delivery: "latest-only", sequence: 1n, payload: new Uint8Array([1]) })); - const secondConsumer = await dialProvider(provider); - try { - const seeded = await secondConsumer.receiveFrame(); - assert.equal(seeded.delivery, "latest-only"); - assert.equal(seeded.sequence, 1n, "only the latest-only frame may be seeded, never the reliable-ordered one"); - } finally { - secondConsumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test( - "fix 1: a peer stays connected and keeps receiving well past handshakeTimeoutMs", - { timeout: 10_000 }, - async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - // Must fail on the pre-fix code: the buffering-handler/attachPeer swap - // there loses `state.connected` to whichever handler happens to be - // registered when CONNECTED actually arrives, and the handshake timer - // evicts a fully healthy peer at this deadline regardless. - const handshakeTimeoutMs = 500; - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - handshakeTimeoutMs, - }); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await new Promise((resolve) => setTimeout(resolve, handshakeTimeoutMs * 3)); - assert.equal(provider.connectionCount, 1, "a connected peer must not be evicted by the handshake timer"); - - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - const received = await consumer.receiveFrame(); - assert.equal(received.sequence, 1n, "a peer past the handshake deadline must still receive frames"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } - }, -); - -test("fix 2: an invalid frame throws from broadcast, and connectionCount is unchanged", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - // A 70,000-byte channelId exceeds the wire's 0xffff identity limit: - // `encodeRealtimeFrame` throws. Before fix 2, encoding happened once - // per peer inside `sendFrame`, so the same failure evicted every peer - // while `broadcast()` itself reported success. - await assert.rejects( - provider.broadcast(testFrame({ delivery: "reliable-ordered", channelId: "x".repeat(70_000) })), - /exceeds the QUIC wire limit/i, - ); - assert.equal(provider.connectionCount, 1, "an invalid frame must not evict any peer"); - - // The peer must still be usable afterward: prove the connection itself - // survived, not merely that the count field wasn't decremented. - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 42n })); - assert.equal((await consumer.receiveFrame()).sequence, 42n); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("fix 2: a latest-only invalid frame also throws from broadcast without evicting any peer", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await assert.rejects( - provider.broadcast(testFrame({ delivery: "latest-only", channelId: "x".repeat(70_000) })), - /exceeds the QUIC wire limit/i, - ); - assert.equal(provider.connectionCount, 1, "an invalid latest-only frame must not evict any peer"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("fix 3: advertisedHost is used in place of the bind address, and round-trips through the module's parser", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "0.0.0.0", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - advertisedHost: "streampixels.gamecult.org", - }); - try { - assert.match( - provider.advertisedEndpoint, - /^cultmesh-state\+quic:\/\/streampixels\.gamecult\.org:\d+\?cert-sha256=[0-9A-F]{64}$/, - ); - const parsed = parseQuicRealtimeEndpoint(provider.advertisedEndpoint); - assert.equal(parsed.host, "streampixels.gamecult.org"); - assert.equal(parsed.port, Number(new URL(provider.advertisedEndpoint).port)); - assert.ok(parsed.certificateSha256); - } finally { - provider.dispose(); - } -}); - -test("fix 3: an IPv6 bind advertises a bracketed literal that round-trips through the module's parser", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "::1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - }); - try { - assert.match( - provider.advertisedEndpoint, - /^cultmesh-state\+quic:\/\/\[::1\]:\d+\?cert-sha256=[0-9A-F]{64}$/, - ); - // `URL`'s own `hostname` keeps the brackets for an IPv6 literal (unlike - // the unbracketed form `formatEndpointHost` accepted as input); the - // round trip's job is proving the endpoint parses at all, not stripping - // brackets the WHATWG URL parser itself never strips. - const parsed = parseQuicRealtimeEndpoint(provider.advertisedEndpoint); - assert.equal(parsed.host, "[::1]"); - } finally { - provider.dispose(); - } -}); - -test("fix 4: receive() with an already-aborted signal rejects immediately instead of hanging", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const controller = new AbortController(); - controller.abort(); - await assert.rejects(provider.receive(controller.signal), /aborted/i); - } finally { - provider.dispose(); - } -}); - -test("P3: a latest-only send failure evicts the peer without broadcast() throwing to the caller", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - await waitUntil(() => provider.connectionCount === 1); - consumer.dispose(); - // Mirrors the existing reliable-ordered eviction test, but for the - // outbox's own send failure path (`CultMeshQuicRealtimeProviderOutbox.pump`'s - // catch, which disposes the peer and reports failure through - // `onSendFailure`) rather than `broadcast()`'s direct per-peer catch. - // - // What made this flaky: unlike the reliable-ordered version, - // `broadcast()` never awaits a latest-only peer's actual send — it only - // hands the frame to that peer's outbox and returns (see - // `CultMeshQuicRealtimeProvider.broadcast`). A `while (Date.now() < - // deadline)` loop therefore does no real I/O per iteration; it just - // spins encoding and enqueueing frames as fast as the CPU allows, - // starving the event-loop turns the native pump needs to actually - // observe the dead connection and fire `send_complete`/ - // `CONNECTION_SHUTDOWN`. Under any extra load in the test process - // (exactly what running under mutation testing adds, regardless of - // which line was mutated) that starvation can burn the whole wall-clock - // bound without ever landing an attempt past the point the connection - // is actually dead — a failure with nothing to do with the mutant under - // test. Raising the bound only buys more spinning, not more real - // attempts. Fixed by decoupling "how many attempts to seed" (a small, - // fixed count, each with a real yield) from "how long to wait for the - // result" (a single `waitUntil` afterward, which is what actually - // bounds this test). - for (let sequence = 0n; sequence < 20n && provider.connectionCount > 0; sequence += 1n) { - await provider.broadcast(testFrame({ delivery: "latest-only", sequence, bodyId: `body:${sequence}` })); - await new Promise((resolve) => setTimeout(resolve, 25)); - } - await waitUntil(() => provider.connectionCount === 0); - assert.equal(provider.connectionCount, 0, "the dead peer must be evicted, not counted forever"); - } finally { - provider.dispose(); - } -}); - -test("I1: an inbox key keeps its first-arrival position when a newer frame overwrites it", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // Key A arrives first, then key B, then A is republished with a new - // value while still unread: `CultMeshRealtimeInbox.publish`'s - // coalescing must overwrite A's *value* in place, not move it to the - // back of the queue behind B. `received` is exactly that inbox, - // reached through the provider's client fan-in. - await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:A", sequence: 1n })); - await waitUntil(() => provider.receiveQueueSize === 1); - await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:B", sequence: 1n })); - await waitUntil(() => provider.receiveQueueSize === 2); - await consumer.sendFrame(testFrame({ delivery: "latest-only", bodyId: "body:A", sequence: 2n })); - // Still 2: the second A publish must coalesce onto the already-queued - // A entry, not add a third. - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.equal(provider.receiveQueueSize, 2, "a republish of a still-pending key must not grow the queue"); - - const first = await provider.receive(); - const second = await provider.receive(); - assert.equal(first.bodyId, "body:A", "A must still drain first: its position must survive the overwrite"); - assert.equal(first.sequence, 2n, "A's value must be the newer, overwritten one"); - assert.equal(second.bodyId, "body:B"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("L1: the outbox keeps one send in flight per peer; the next frame dequeues only after send_complete", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // Prototype-patched for the life of this test, restored in `finally`: - // the provider builds its peer transport internally, so this is the - // only vantage point outside the module from which to observe - // concurrency of `sendEncodedFrame` calls, which is what - // `CultMeshQuicRealtimeProviderOutbox.pump` serializes one-at-a-time - // against `send_complete`. - const original = CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame; - let active = 0; - let maxActive = 0; - CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame = function ( - this: InstanceType, - delivery: "reliable-ordered" | "latest-only", - encoded: Uint8Array, - ) { - active += 1; - maxActive = Math.max(maxActive, active); - return original.call(this, delivery, encoded).finally(() => { - active -= 1; - }); - }; - try { - const total = 20; - const sends: Promise[] = []; - for (let key = 0; key < total; key += 1) { - sends.push(provider.broadcast(testFrame({ bodyId: `body:${key}`, sequence: 1n }))); - } - await Promise.all(sends); - - const seen = new Set(); - while (seen.size < total) { - const frame = await consumer.receiveFrame(); - seen.add(frame.bodyId); - } - } finally { - CultMeshQuicRealtimeTransportClass.prototype.sendEncodedFrame = original; - } - assert.equal(maxActive, 1, "the outbox pump must never have two sends in flight for one peer at once"); - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("P15: broadcast() after dispose rejects instead of touching a torn-down provider", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - provider.dispose(); - await assert.rejects(provider.broadcast(testFrame()), /disposed/i); -}); - -test("P17: an accept arriving after dispose is refused, not attached as a peer", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await startProvider(); - const runtime = await CultMeshQuicNativeRuntime.open(); - let connectionId: bigint | undefined; - try { - // A real native connection handle this test owns outright (never dialed - // anywhere reachable, so shutting it down is safe): stands in for the - // handle a genuine NEW_CONNECTION event would have carried, without - // fabricating a bogus native pointer. `acceptConnection` is private; - // reaching it directly is what lets this test drive the exact race - // (an accept event landing after `dispose()` has already run) without - // depending on real listener-close timing to reproduce it. - connectionId = runtime.connectionOpen("127.0.0.1", 1); - provider.dispose(); - // This synthetic connection never reaches CONNECTED either way (it was - // never dialed anywhere), so `connectionCount` alone cannot distinguish - // "refused" from "attached but still handshaking". `retain()` is - // synchronous and only the attach path calls it: a stable `refCount` - // across the call proves no transport (and no runtime reference) was - // ever allocated for this connection. - const beforeRefCount = CultMeshQuicNativeRuntime.refCount; - assert.doesNotThrow(() => - (provider as unknown as { acceptConnection(id: bigint): void }).acceptConnection(connectionId!), - ); - assert.equal( - CultMeshQuicNativeRuntime.refCount, - beforeRefCount, - "a post-dispose accept must never retain a runtime reference for a new transport", - ); - assert.equal(provider.connectionCount, 0, "a post-dispose accept must never join peers"); - } finally { - if (connectionId !== undefined) { - try { - runtime.connectionShutdown(connectionId, 0n); - } catch { - // Best-effort: acceptConnection's own disposed-path may already have shut it down. - } - } - await runtime.release(); - } -}); - -test("fix 1: a publish-only provider faults a client that sends to it, and its memory stays empty", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - acceptClientFrames: false, - }); - try { - const consumer = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // receive() itself rejects clearly rather than hanging forever. - await assert.rejects(provider.receive(), /publish-only/i); - - // Any inbound stream from the client faults that connection. The - // client's own `sendFrame` promise settles on its *local* stream - // send completing (MsQuic hands the bytes off and reports - // send_complete without waiting for the peer to process them), so it - // may well resolve even though the server already faulted its side — - // the proof this test owns is the server's state, not the client's - // send outcome, so the client send is fired and ignored either way. - void consumer.sendFrame(testFrame({ delivery: "latest-only" })).catch(() => {}); - await waitUntil(() => provider.connectionCount === 0); - assert.equal(provider.receiveQueueSize, 0, "no frame from a faulted publish-only client may be queued"); - - // The provider itself is otherwise healthy: broadcasting still works - // for a freshly attached peer. - const second = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 1n })); - assert.equal((await second.receiveFrame()).sequence, 1n); - } finally { - second.dispose(); - } - } finally { - consumer.dispose(); - } - } finally { - provider.dispose(); - } -}); - -test("fix 2: a connection beyond maxConnections is refused and existing peers are unaffected", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - const provider = await CultMeshQuicRealtimeProvider.listen({ - host: "127.0.0.1", - port: 0, - serverCertificate: { pkcs12: readFileSync(FIXTURE_P12), password: "" }, - maxConnections: 1, - }); - try { - const first = await dialProvider(provider); - try { - await waitUntil(() => provider.connectionCount === 1); - - // Refused at accept: never reaches CONNECTED, never becomes a peer. - await assertRejectsAndDisposes(dialProvider(provider), /certificate|rejected|closed|shut|timed out/i); - // Give the refusal a moment to be visible either way, then prove the - // existing peer was never touched. - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.equal(provider.connectionCount, 1, "the existing peer must be unaffected by a refused connection"); - - await provider.broadcast(testFrame({ delivery: "reliable-ordered", sequence: 7n })); - assert.equal((await first.receiveFrame()).sequence, 7n, "the existing peer must still be fully usable"); - } finally { - first.dispose(); - } - } finally { - provider.dispose(); - } -}); diff --git a/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts b/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts deleted file mode 100644 index f7958570..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-release-uaf.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -// Regression test for fix 2: CultMeshQuicNativeRuntime.release() must await -// the pump loop's exit before calling cultmesh_quic_runtime_close, never -// after. Reverting that order (close before awaiting the pump) reliably -// crashes this test under a saturated libuv thread pool and MALLOC_PERTURB_, -// at every pool size tried by hand while verifying this batch; restored -// immediately afterward, never committed. -// -// UV_THREADPOOL_SIZE and MALLOC_PERTURB_ must be set before Node starts, so -// this spawns a child `node` process running test/support/release-uaf-probe.js -// with that environment, once per pool size, and asserts it exits 0. Kept to -// one scenario (see the probe's own header) so the whole test stays bounded -// to a few seconds. - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { join } from "node:path"; -import test from "node:test"; - -import { nativeBridgeAvailable } from "./support/native-bridge"; - -const PROBE_SCRIPT = join(__dirname, "..", "..", "test", "support", "release-uaf-probe.js"); - -test("fix 2 regression: release()'s ordering survives a saturated thread pool at pool sizes 1, 2 and 4", async (t) => { - if (!nativeBridgeAvailable()) return void t.skip("no native bridge available"); - - for (const poolSize of [1, 2, 4]) { - const result = spawnSync(process.execPath, [PROBE_SCRIPT], { - env: { - ...process.env, - UV_THREADPOOL_SIZE: String(poolSize), - MALLOC_PERTURB_: "165", - }, - encoding: "utf8", - timeout: 60_000, - }); - assert.equal( - result.status, - 0, - `pool size ${poolSize}: probe exited status=${result.status} signal=${result.signal}\n` + - `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`, - ); - } -}); diff --git a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts b/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts deleted file mode 100644 index e1843466..00000000 --- a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -// `CultMeshQuicRealtimeSessionManager`'s own rules, isolated from the native -// bridge with fake connectors/transports: eviction on transport death (fix -// 4), single-flight (fix 4), the disposed guard (fix 4), first-success racing -// with abort of the losers (fix 5), the `protocolIds` filter (M7), the -// `authorityRuntimeId` filter (M8), the session key including -// `authorityRuntimeId` (M9), and the `maxRacedCandidates` cap (M6). -// -// Routes use `trust.mode = "local-development"` over loopback endpoints, so -// `verifyAuthorityRoute` short-circuits without a signed certificate -// (`cultmesh-authority.ts:99`): these tests are about the session manager's -// own binding/racing/caching rules, not route trust, which -// `realtime-quic-consumer.test.ts`'s trust negatives already pin. - -import assert from "node:assert/strict"; -import test from "node:test"; - -import type { CultMeshAuthorityTrustPolicy } from "cultnet-ts"; -import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; - -import { - CultMeshQuicRealtimeSessionManager, - CultMeshStaticRealtimeLookupSource, - CULTMESH_REALTIME_STATE_PROTOCOL_ID, - type CultMeshRealtimeCandidate, - type CultMeshRealtimeTarget, - type CultMeshRealtimeTransport, - type CultMeshRealtimeTransportConnector, -} from "../src/realtime-quic"; - -const trust: CultMeshAuthorityTrustPolicy = { mode: "local-development", odinRoots: [] }; - -function route(overrides: Partial = {}): CultMeshAuthorityRouteMessage { - return { - authorityRuntimeId: "service:aetheria.daemon", - endpoint: "cultmesh-state+quic://127.0.0.1:9443", - protocolIds: [CULTMESH_REALTIME_STATE_PROTOCOL_ID], - priority: 0, - generation: "gen-1", - ...overrides, - }; -} - -function verseWithRoutes(verseId: string, routes: readonly CultMeshAuthorityRouteMessage[]): CultMeshVerseDescriptorMessage { - return { - verseId, - displayName: verseId, - authorityModel: "operator-cluster", - compatibility: { transportVersion: "cultmesh.v1", rulesHash: "test", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, - discoveryEndpoints: routes.map((r) => r.endpoint), - authorityRuntimeIds: [...new Set(routes.map((r) => r.authorityRuntimeId))], - authorityRoutes: [...routes], - }; -} - -interface FakeTransportHandle { - readonly transport: CultMeshRealtimeTransport; - disposed(): boolean; -} - -/** A transport that never resolves `receiveFrame` and tracks its own disposal. */ -function fakeTransport(endpoint: string, verified = true): FakeTransportHandle { - let disposed = false; - const disposalHandlers: Array<() => void> = []; - const transport: CultMeshRealtimeTransport = { - transportId: "fake", - endpoint, - async sendFrame() {}, - async receiveFrame() { - return await new Promise(() => {}); - }, - dispose() { - if (disposed) return; - disposed = true; - for (const handler of disposalHandlers) handler(); - }, - isVerifiedFor: () => verified, - onDisposed(handler) { - if (disposed) { - handler(); - return; - } - disposalHandlers.push(handler); - }, - }; - return { transport, disposed: () => disposed }; -} - -/** A connector that resolves immediately (unless `delayMs` is given) with one fake transport per call. */ -function spyConnector( - connectorId: string, - priority: number, - options: { delayMs?: number; fails?: boolean; ignoreAbort?: boolean } = {}, -): { - connector: CultMeshRealtimeTransportConnector; - calls: CultMeshRealtimeCandidate[]; - handles: FakeTransportHandle[]; - /** Timestamps at which this connector's abort signal actually fired. */ - aborts: number[]; -} { - const calls: CultMeshRealtimeCandidate[] = []; - const handles: FakeTransportHandle[] = []; - const aborts: number[] = []; - const connector: CultMeshRealtimeTransportConnector = { - connectorId, - priority, - canConnect: () => true, - connect: async (candidate, _target, signal) => { - calls.push(candidate); - if (options.delayMs) { - await new Promise((resolve, reject) => { - const timer = setTimeout(resolve, options.delayMs); - if (!options.ignoreAbort) { - signal?.addEventListener("abort", () => { - clearTimeout(timer); - aborts.push(Date.now()); - reject(new Error(`${connectorId} connect aborted`)); - }); - } - }); - } - if (options.fails) throw new Error(`${connectorId} refused to connect`); - const handle = fakeTransport(candidate.endpoint); - handles.push(handle); - return handle.transport; - }, - }; - return { connector, calls, handles, aborts }; -} - -const target: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:aetheria.daemon" }; - -test("fix 4: connect() caches a session and evicts it once the transport dies, so the next connect redials", async () => { - const { connector, calls } = spyConnector("fake", 0); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector], - }); - - const first = await manager.connect(target); - assert.equal(await manager.connect(target), first, "a live session is returned from cache, not redialed"); - assert.equal(calls.length, 1); - - first.dispose(); - const second = await manager.connect(target); - assert.notEqual(second, first, "a dead session is evicted, so the next connect redials"); - assert.equal(calls.length, 2); -}); - -test("fix 4: concurrent connect() calls for the same target share one dial (single-flight)", async () => { - const { connector, calls } = spyConnector("fake", 0, { delayMs: 50 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector], - }); - - const [a, b, c] = await Promise.all([manager.connect(target), manager.connect(target), manager.connect(target)]); - assert.equal(a, b); - assert.equal(b, c); - assert.equal(calls.length, 1, "three concurrent callers must share one dial"); -}); - -test("fix 4/low: dispose() aborts a dial in flight instead of letting it land and discarding the result", async () => { - // A long delay: if `dispose()` did not cancel the dial, this test would - // have to wait it out (or race a timing-dependent assertion) to observe - // that the eventually-created transport gets disposed instead of cached. - const { connector, handles } = spyConnector("fake", 0, { delayMs: 5_000 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector], - }); - - const pending = manager.connect(target); - const start = Date.now(); - manager.dispose(); - await assert.rejects(pending); - assert.ok( - Date.now() - start < 1_000, - "dispose() must cancel the in-flight dial rather than waiting out the connector's own timeout", - ); - assert.equal(handles.length, 0, "an aborted dial must never construct a transport only to immediately discard it"); - await assert.rejects(manager.connect(target), /disposed/i, "a disposed manager refuses further connects"); -}); - -test("fix 4/low: disconnect() during a dial does not let the dial cache a session once it lands", async () => { - const { connector, handles } = spyConnector("fake", 0, { delayMs: 30 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector], - }); - - const pending = manager.connect(target); - manager.disconnect(target); - await assert.rejects(pending); - // Whether or not the aborted dial still managed to construct a transport - // (a race against the abort), it must end up disposed, never cached. - await new Promise((resolve) => setTimeout(resolve, 60)); - for (const handle of handles) assert.equal(handle.disposed(), true, "a cancelled dial's transport must be disposed"); - - // The manager itself is still usable: a fresh connect redials and succeeds. - const { connector: connector2 } = spyConnector("fake", 0); - const manager2 = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([verseWithRoutes(target.verseId, [route()])]), - trust, - connectors: [connector2], - }); - const fresh = await manager2.connect(target); - assert.ok(fresh); -}); - -test("fix 5: the race returns the first verified success and aborts the losers", async () => { - const winner = spyConnector("fast", 0, { delayMs: 10 }); - const loser = spyConnector("slow", 0, { delayMs: 5_000 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [ - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), - ]), - ]), - trust, - connectors: [winner.connector, loser.connector], - maxRacedCandidates: 2, - }); - - const startedAt = Date.now(); - const transport = await manager.connect(target); - const elapsedMs = Date.now() - startedAt; - assert.ok(elapsedMs < 1_000, `connect() must return on the first success, not wait out the slow route (took ${elapsedMs}ms)`); - assert.equal(transport.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); -}); - -test("F5abort: a losing candidate is told to abort promptly, not left to run out its own timeout", async () => { - const winner = spyConnector("fast", 0, { delayMs: 10 }); - const loser = spyConnector("slow", 0, { delayMs: 5_000 }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [ - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), - ]), - ]), - trust, - connectors: [winner.connector, loser.connector], - maxRacedCandidates: 2, - }); - - const startedAt = Date.now(); - await manager.connect(target); - await new Promise((resolve) => setTimeout(resolve, 300)); - assert.equal(loser.aborts.length, 1, "the losing candidate's connect() must observe an abort signal"); - assert.ok( - loser.aborts[0]! - startedAt < 1_000, - "the abort must arrive promptly after the winner is decided, not after the loser's own 5s timeout", - ); -}); - -test("F5loser: a losing candidate that connects anyway (past the point abort helps) is disposed, not left dangling", async () => { - const winner = spyConnector("fast", 0, { delayMs: 10 }); - // Ignores the abort signal entirely, simulating a candidate already past - // the point where cancellation helps: it still produces a real transport. - const loser = spyConnector("slow", 0, { delayMs: 80, ignoreAbort: true }); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [ - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), - ]), - ]), - trust, - connectors: [winner.connector, loser.connector], - maxRacedCandidates: 2, - }); - - const transport = await manager.connect(target); - assert.equal(transport.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); - await new Promise((resolve) => setTimeout(resolve, 200)); - assert.equal(loser.handles.length, 1, "the loser still connects despite racing to lose"); - assert.equal( - loser.handles[0]!.disposed(), - true, - "a losing candidate that connects after the race is decided must be disposed, never left connected", - ); -}); - -test("M6: maxRacedCandidates caps how many candidates in a tier are attempted", async () => { - const spy = spyConnector("fake", 0); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [ - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9001", priority: 0 }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9002", priority: 1 }), - route({ endpoint: "cultmesh-state+quic://127.0.0.1:9003", priority: 2 }), - ]), - ]), - trust, - connectors: [spy.connector], - maxRacedCandidates: 1, - }); - - await manager.connect(target); - assert.equal(spy.calls.length, 1, "only the top maxRacedCandidates entries in the tier are attempted"); - assert.equal(spy.calls[0]!.endpoint, "cultmesh-state+quic://127.0.0.1:9001", "the lowest route priority races first"); -}); - -test("M7: a route whose protocolIds omit the realtime-state protocol is not a candidate", async () => { - const spy = spyConnector("fake", 0); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [route({ protocolIds: ["cultmesh.other_protocol.v1"] })]), - ]), - trust, - connectors: [spy.connector], - }); - - await assert.rejects(manager.connect(target), /No realtime state route was advertised/); - assert.equal(spy.calls.length, 0); -}); - -test("M8: a route advertised for a different authorityRuntimeId is not a candidate", async () => { - const spy = spyConnector("fake", 0); - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes(target.verseId, [route({ authorityRuntimeId: "service:other.daemon" })]), - ]), - trust, - connectors: [spy.connector], - }); - - await assert.rejects(manager.connect(target), /No realtime state route was advertised/); - assert.equal(spy.calls.length, 0); -}); - -test("M9: the session key includes authorityRuntimeId, so two authorities in the same Verse get separate sessions", async () => { - const spy = spyConnector("fake", 0); - const targetA: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:a.daemon" }; - const targetB: CultMeshRealtimeTarget = { verseId: "aetheria", authorityRuntimeId: "service:b.daemon" }; - const manager = new CultMeshQuicRealtimeSessionManager({ - lookupSource: new CultMeshStaticRealtimeLookupSource([ - verseWithRoutes("aetheria", [ - route({ authorityRuntimeId: "service:a.daemon", endpoint: "cultmesh-state+quic://127.0.0.1:9001" }), - route({ authorityRuntimeId: "service:b.daemon", endpoint: "cultmesh-state+quic://127.0.0.1:9002" }), - ]), - ]), - trust, - connectors: [spy.connector], - }); - - const sessionA = await manager.connect(targetA); - const sessionB = await manager.connect(targetB); - assert.notEqual(sessionA, sessionB); - assert.equal(sessionA.endpoint, "cultmesh-state+quic://127.0.0.1:9001"); - assert.equal(sessionB.endpoint, "cultmesh-state+quic://127.0.0.1:9002"); - assert.equal(spy.calls.length, 2); -}); diff --git a/packages/cultmesh-ts/test/realtime-wire.test.ts b/packages/cultmesh-ts/test/realtime-wire.test.ts deleted file mode 100644 index 93f23365..00000000 --- a/packages/cultmesh-ts/test/realtime-wire.test.ts +++ /dev/null @@ -1,397 +0,0 @@ -// The realtime frame codec, pinned rule by rule against the C# reference. -// -// The bytes themselves are pinned by two committed files under -// `contracts/cultmesh/`. `realtime-frame-vectors.json` is written by -// `CultMeshRealtimeWireProtocolTests.RealtimeFrameVectorsAreSharedWithTypeScript` -// under CULTMESH_WRITE_VECTORS=1: this side decodes it and re-encodes to the -// same bytes. `realtime-frame-vectors.ts-written.json` is written by -// `scripts/write-cultmesh-realtime-frame-vectors.mjs` with this codec and -// decoded by the C# reference, so parity is proven in both directions and in -// two processes. A fixture authored on one side alone would only agree with -// itself, which is what the authority vectors already taught. - -import test from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { - CULTMESH_REALTIME_ALPN, - CULTMESH_REALTIME_CONNECTION_CLOSE_CODE, - CULTMESH_REALTIME_LATEST_ONLY_STREAM, - CULTMESH_REALTIME_MAX_ENCODED_FRAME_BYTES, - CULTMESH_REALTIME_MAX_PAYLOAD_BYTES, - CULTMESH_REALTIME_RELIABLE_STREAM, - CULTMESH_REALTIME_STREAM_ABORT_CODE, - decodeRealtimeFrame, - encodeRealtimeFrame, - type CultMeshRealtimeDelivery, - type CultMeshRealtimeFrame, -} from "../src/realtime-wire"; - -const CONTRACTS = join(__dirname, "..", "..", "..", "..", "contracts", "cultmesh"); -const CSHARP_VECTORS = join(CONTRACTS, "realtime-frame-vectors.json"); -const TS_VECTORS = join(CONTRACTS, "realtime-frame-vectors.ts-written.json"); - -interface IdentityRule { repeat: string; byteLength: number } -interface RampRule { ramp: number } -interface VectorFields { - channelId: string | IdentityRule; - schemaId: string | IdentityRule; - bodyId: string | IdentityRule; - producerEpoch: number; - sequence: number; - delivery: CultMeshRealtimeDelivery; - payload: string | RampRule; -} -interface Vectors { - frames: { - label: string; - fields: VectorFields; - encoding: string | null; - encodedLength: number; - sha256OfEncoding: string; - }[]; - malformed: { label: string; encoding: string; message: string }[]; -} - -/** The repeat unit written whole until the UTF-8 byte length is exactly `byteLength`. */ -function repeatToByteLength(unit: string, byteLength: number): string { - const unitBytes = Buffer.byteLength(unit, "utf8"); - assert.equal(byteLength % unitBytes, 0, `'${unit}' is ${unitBytes} UTF-8 bytes and does not divide ${byteLength}`); - return unit.repeat(byteLength / unitBytes); -} - -/** The deterministic payload both runtimes build the same way. */ -function ramp(length: number): Uint8Array { - const bytes = new Uint8Array(length); - for (let index = 0; index < length; index += 1) bytes[index] = index % 251; - return bytes; -} - -function identity(value: string | IdentityRule): string { - return typeof value === "string" ? value : repeatToByteLength(value.repeat, value.byteLength); -} - -function payload(value: string | RampRule): Uint8Array { - return typeof value === "string" ? new Uint8Array(Buffer.from(value, "base64")) : ramp(value.ramp); -} - -function frameOf(fields: VectorFields): CultMeshRealtimeFrame { - return { - channelId: identity(fields.channelId), - schemaId: identity(fields.schemaId), - bodyId: identity(fields.bodyId), - // The C# `long` fields are written as JSON integers and can exceed the safe - // integer range, so they are read from the raw text, never through - // `JSON.parse`'s number. - producerEpoch: 0n, - sequence: 0n, - delivery: fields.delivery, - payload: payload(fields.payload), - }; -} - -// `JSON.parse` would round `long.MaxValue` to 9223372036854775808, which is not -// a valid i64 and not what either side encoded. The two i64 fields are lifted -// out of the raw text as bigints before the document is parsed. -function readVectors(path: string): { doc: Vectors; int64s: Map } { - const text = readFileSync(path, "utf8"); - const int64s = new Map(); - const pattern = /"label": "(?