Compare commits
117 changed files with 419 additions and 4084 deletions
21
.github/workflows/publish-packages.yml
vendored
21
.github/workflows/publish-packages.yml
vendored
|
|
@ -8,26 +8,15 @@ name: Publish packages
|
||||||
# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
|
# directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin
|
||||||
# consume the core stack.
|
# consume the core stack.
|
||||||
#
|
#
|
||||||
# Tag a Python release to publish, for example `cultnet-py-v0.1.0`. The tag's
|
# Tag a release to publish, for example `cultcache-ts-v0.13.6` or
|
||||||
# version must match the version in the package manifest;
|
# `cultnet-py-v0.1.0`. The tag's version must match the version in the package
|
||||||
# scripts/release-after-suite.mjs refuses the release otherwise rather than
|
# manifest; scripts/release-after-suite.mjs refuses the release otherwise rather
|
||||||
# shipping a mislabelled artifact.
|
# than shipping a mislabelled artifact.
|
||||||
#
|
|
||||||
# npm publishing of @gamecult/cultcache-ts is deliberately off: `cultcache-ts-v*`
|
|
||||||
# is not a tag trigger until it is ruled on (the cultcache-ts job below runs from
|
|
||||||
# workflow_dispatch only, which publishes nothing). A tag run uses the workflow
|
|
||||||
# file at the tagged commit, not the one on main, so a tag on an older commit
|
|
||||||
# (`cultcache-ts-v0.15.0` sits on 7d292085, before the guard) still runs that
|
|
||||||
# commit's triggers and jobs. Read this file at the tagged sha before pushing a tag.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# cultcache-ts-v* is deliberately not a trigger: publishing @gamecult/cultcache-ts is disabled
|
|
||||||
# until it is ruled on, and whether NPM_TOKEN is set is unknown, so a pushed tag must not reach
|
|
||||||
# `npm publish`. The cultcache-ts jobs below still run from workflow_dispatch, which publishes nothing.
|
|
||||||
# (The comment sits above `tags:` because the tag-prefix pin in scripts/check-changelog-semver.test.mjs
|
|
||||||
# reads the list as `tags:` followed directly by its items.)
|
|
||||||
tags:
|
tags:
|
||||||
|
- "cultcache-ts-v*"
|
||||||
- "cultcache-py-v*"
|
- "cultcache-py-v*"
|
||||||
- "cultnet-py-v*"
|
- "cultnet-py-v*"
|
||||||
- "cultmesh-py-v*"
|
- "cultmesh-py-v*"
|
||||||
|
|
|
||||||
|
|
@ -114,9 +114,7 @@ copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and
|
||||||
`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`,
|
`selection_invalid`, `cursor_stale`, `cursor_invalid`, `reference_outside_target`, `variant_unsupported`,
|
||||||
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
|
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
|
||||||
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
|
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
|
||||||
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) and
|
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value).
|
||||||
`message_invalid` (a request that lacks a required field or carries an ill-typed one: a document-put or delete, or a subscription without an id; `details.field` names the request field, or `message` when the whole frame cannot be read).
|
|
||||||
A malformed put or delete is `message_invalid`, not a per-operation code: the peer knows which operation it sent.
|
|
||||||
|
|
||||||
## Live Invariants
|
## Live Invariants
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"code": {
|
"code": {
|
||||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
|
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
|
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version"]
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["field", "value", "asOf", "current"],
|
"required": ["field", "value", "asOf", "current"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "type": ["string", "null"] },
|
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] },
|
||||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
|
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
|
||||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||||
|
|
|
||||||
|
|
@ -145,22 +145,6 @@
|
||||||
"current": null
|
"current": null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "message_invalid",
|
|
||||||
"messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
|
|
||||||
"message": {
|
|
||||||
"schemaVersion": "cultnet.error.v0",
|
|
||||||
"error": "message_invalid: Raw put messages must contain a document map.",
|
|
||||||
"routingHint": null,
|
|
||||||
"code": "message_invalid",
|
|
||||||
"details": {
|
|
||||||
"field": "document",
|
|
||||||
"value": null,
|
|
||||||
"asOf": null,
|
|
||||||
"current": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ ran under the session scratchpad (`imagination/`), not in the repo.
|
||||||
| F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source |
|
| F13 | Cross-process precedent for C# against a Node provider exists. `packages/cultnet-ts/test/interop/cultnet-interop.test.ts:536-560` spawns the TS QUIC provider, then runs `dotnet test --filter NativeConnectorReceivesFromExternalProvider` with the endpoint in `CULTMESH_NATIVE_EXTERNAL_ENDPOINT`. The C# test skips without the variable (`tests/GameCult.Mesh.Quic.Native.Tests`, line 21). | source |
|
||||||
| F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep |
|
| F14 | `cultnet-ts` is CommonJS. Its `./wire`, `./contracts` and `./authority` subpaths import only `@msgpack/msgpack`, `ajv` and `cultcache-ts/document`, so they are browser-safe. Its root `index` pulls `node:` modules (`peer`, `rudp`, `transport`, `security` and others). `cultmesh-browser` (ESM) already imports `cultnet-ts/wire`, `/contracts` and `/authority`. `cultmesh-ts` loads `koffi` lazily, inside a function (`realtime-quic-native.ts:139`), so a new subpath of it pays no native load. The root has `esbuild` ^0.28.1 as a devDependency. | source, grep |
|
||||||
| F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source |
|
| F15 | TS packages build with `tsc` and test with `node --test dist-test/test/*.test.js` per workspace. `scripts/run-typescript-workspaces.mjs` runs them in order `cultcache-ts, cultnet-ts, cultmesh-ts, cultmesh-browser`, after `check-typescript-tests-wired.mjs`. No StrykerJS config exists in CultLib (`docs/mutation-testing.md:156-159`). | source |
|
||||||
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggered on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` when this was written; it now triggers on the three `-py-v*` patterns only, because npm publishing is deliberately off until ruled on (a tag run uses the workflow at the tagged commit, so `cultcache-ts-v0.15.0` on `7d292085`, before that change, still ran the old one). Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returned 404 anonymously on 2026-11-08 for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
|
| F16 | **No CultLib TS package except `cultcache-ts` has a publish job.** `publish-packages.yml` triggers on `cultcache-ts-v*`, `cultcache-py-v*`, `cultnet-py-v*` and `cultmesh-py-v*` only. Tags `cultnet-ts-v0.1.1` and `cultmesh-ts-v0.1.1` exist, but no workflow consumes them. `npm view` returns 404 anonymously for `cultnet-ts`, `cultmesh-ts`, `cultmesh-browser` and even `@gamecult/cultcache-ts` (`@gamecult/cultcache-ts` may be private; unverified). The doctrine's rule of at most three tags per push stands (SKILL.md:674). | source; `npm view` |
|
||||||
| F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 |
|
| F17 | StreamPixels consumes CultLib as the git submodule `vendor/CultLib` (gitlink `30ee8b97`) with `file:` deps and `pnpm.overrides`. Its CI and Idunn recipes build the vendored workspaces (`npm ci --ignore-scripts`, then build `cultcache-ts`, `cultnet-ts` and `cultmesh-ts`). `apps/web` depends only on `cultnet-ts` today, not on `cultmesh-browser`. | StreamPixels `.gitmodules`, `apps/*/package.json`, CI workflow lines 62-95 |
|
||||||
| F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` |
|
| F18 | The Rust reader sets two guards that the TS reader should match. A caller cap `max_bytes` is checked before any request. And a zero-size chunk is refused unless it is the only one, because a manifest of zero-size chunks would be a request flood. | source `cultnet-rs/src/content.rs:364-385` |
|
||||||
| F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim |
|
| F19 | `BuildChunkUri` on more endpoint shapes, as `AbsoluteUri` (what `HttpClient` sends). `https://h` and `https://h/` both give `https://h/?chunkHash=…&recordKey=k`. `https://h:443/x` drops the default port. `https://H.Example/X/` lowercases the host and keeps the path's case. `https://h/x#frag` keeps the fragment after the query. `https://h/x?a=%20b+c` keeps the existing query verbatim and joins with `&`. **`https://h/x?` (an empty query) gives `https://h/x?&chunkHash=…`**, because `UriBuilder.Query` is `?`, which is not blank. `https://[::1]:8443/c` keeps the bracketed host. `Uri.EscapeDataString("!'()*~-._ +é𐐀")` is `%21%27%28%29%2A~-._%20%2B%C3%A9%F0%90%90%80`: unlike JS `encodeURIComponent`, it escapes `!'()*`. | probe: `dotnet fsi` on .NET 10, the three lines of `BuildChunkUri` (`CultMeshHttpsContentTransport.cs:114-122`) copied verbatim |
|
||||||
|
|
@ -276,10 +276,11 @@ remains:
|
||||||
history (Keep a Changelog headings).
|
history (Keep a Changelog headings).
|
||||||
- A Soul pass over the release diff, then four tags: `cultcache-ts-v…`,
|
- A Soul pass over the release diff, then four tags: `cultcache-ts-v…`,
|
||||||
`cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`.
|
`cultnet-ts-v0.3.0`, `cultmesh-ts-v0.2.0` and `cultmesh-browser-v0.4.0`.
|
||||||
These go in **two pushes** (at most three tags per push, SKILL.md). No
|
These go in **two pushes** (at most three tags per push, SKILL.md):
|
||||||
`-ts-v*` tag triggers `publish-packages.yml` on main (F16), so these tags
|
`cultcache-ts` first, because its tag triggers `publish-packages.yml`
|
||||||
publish nothing;
|
(F16), and the three plane tags second. Whether the `cultcache-ts` tag's
|
||||||
follow-up `npm-publish-ts-packages` owns npm publishing.
|
npm publish job still runs, and to where, is checked before that push;
|
||||||
|
follow-up `npm-publish-ts-packages` owns the rest.
|
||||||
- The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the
|
- The consumer's pickup (StreamPixels bumping `vendor/CultLib` to the
|
||||||
tagged commit) is StreamPixels' B-cuts, not this campaign.
|
tagged commit) is StreamPixels' B-cuts, not this campaign.
|
||||||
- Size: about 60 lines of version and CHANGELOG text; a short Hands pass.
|
- Size: about 60 lines of version and CHANGELOG text; a short Hands pass.
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ caught before it ships instead of written up afterward.
|
||||||
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
|
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
|
||||||
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
|
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
|
||||||
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
|
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
|
||||||
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml`, `workflow_dispatch` only; npm publishing is off (see "Coverage gaps") |
|
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` |
|
||||||
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||||
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||||
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||||
|
|
@ -198,8 +198,8 @@ or a missing declaration refuses), and:
|
||||||
proof: with no tag of its own prefix it is a first release. The suite test
|
proof: with no tag of its own prefix it is a first release. The suite test
|
||||||
that pins every declared prefix guards it: each prefix is declared once, each
|
that pins every declared prefix guards it: each prefix is declared once, each
|
||||||
workflow tag trigger must be a declared prefix, and each such package's prefix
|
workflow tag trigger must be a declared prefix, and each such package's prefix
|
||||||
must be a workflow trigger or, for a package the workflow does not tag-trigger
|
must be a workflow trigger or, for the Caching Unity package the workflow does
|
||||||
(the Caching Unity package, `cultcache-ts`), the prefix of tags that hold its own manifest at the tag's own
|
not build, the prefix of tags that hold its own manifest at the tag's own
|
||||||
version, so a typo or a copied prefix fails the suite. Nothing releases
|
version, so a typo or a copied prefix fails the suite. Nothing releases
|
||||||
without the suite passing; see below for how each release path runs it.
|
without the suite passing; see below for how each release path runs it.
|
||||||
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
|
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
|
||||||
|
|
@ -352,40 +352,10 @@ own path into the DLL. No `global.json` pins the .NET SDK, so the same refusal
|
||||||
at unchanged source and CRLF checkout means this SDK or host differs from the
|
at unchanged source and CRLF checkout means this SDK or host differs from the
|
||||||
one that built the tracked DLL.
|
one that built the tracked DLL.
|
||||||
|
|
||||||
### Before pushing a tag
|
|
||||||
|
|
||||||
A tag run uses the workflow files at the tagged commit, not the ones on main, and
|
|
||||||
the forge's GitHub mirror forwards the tag, so both CI systems run it. Before
|
|
||||||
pushing any tag, resolve the commit it will point at and read the tag-triggering
|
|
||||||
workflows as they are at that commit:
|
|
||||||
|
|
||||||
```
|
|
||||||
sha=$(git rev-parse <commit-the-tag-will-point-at>)
|
|
||||||
git ls-tree -r --name-only "$sha" .github/workflows
|
|
||||||
git show "$sha":.github/workflows/publish-packages.yml | sed -n '/^on:/,/^jobs:/p'
|
|
||||||
```
|
|
||||||
|
|
||||||
Confirm that the tag's prefix is a trigger there if you mean it to publish, and
|
|
||||||
is not if you do not. Do the same for every other workflow listed that has a
|
|
||||||
`tags:` trigger. A guard merged after that commit does not protect a tag that
|
|
||||||
points before it.
|
|
||||||
|
|
||||||
`cultcache-ts-v0.15.0` was pushed on `7d292085`, which predates the guard, and
|
|
||||||
its push started a publish run on both systems (GitHub run 42392334869, forge run
|
|
||||||
454). The forge job log shows how it ended: the Publish step ran
|
|
||||||
`release-after-suite.mjs`, whose `npm test` failed (11 cross-runtime tests,
|
|
||||||
`cargo: not found` on the runner), so it printed "@gamecult/cultcache-ts's own
|
|
||||||
tests failed; not releasing." and never reached `npm publish`. Neither
|
|
||||||
`cultcache-ts` nor `@gamecult/cultcache-ts` exists on the npm registry
|
|
||||||
(both return 404, checked 2026-11-08). The GitHub run's own log needs a login
|
|
||||||
to read, so its failure cause is inferred from the identical workflow and sha.
|
|
||||||
|
|
||||||
### Coverage gaps
|
### Coverage gaps
|
||||||
|
|
||||||
`publish-packages.yml` only triggers on `cultcache-py-v*`, `cultnet-py-v*`, and
|
`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`,
|
||||||
`cultmesh-py-v*` tag pushes (`cultcache-ts-v*` is deliberately not a trigger: npm
|
`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate
|
||||||
publishing is off until ruled on, and the cultcache-ts job runs from
|
|
||||||
`workflow_dispatch` only, publishing nothing) — it does not build or gate
|
|
||||||
the Unity UPM packages at all; those are consumed directly by git tag
|
the Unity UPM packages at all; those are consumed directly by git tag
|
||||||
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
|
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
|
||||||
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags
|
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,7 @@ public static class CultNetFirstCall
|
||||||
|
|
||||||
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
|
Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by
|
||||||
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
|
`npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports
|
||||||
from `../src` where the block reads `@gamecult/cultnet-ts`. More: `packages/cultnet-ts/README.md`.
|
from `../src` where the block reads `cultnet-ts`. More: `packages/cultnet-ts/README.md`.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createServer, connect } from "node:net";
|
import { createServer, connect } from "node:net";
|
||||||
|
|
@ -294,7 +294,7 @@ import {
|
||||||
CultNetDocumentRegistry,
|
CultNetDocumentRegistry,
|
||||||
createTcpFramedCultNetPeer,
|
createTcpFramedCultNetPeer,
|
||||||
defineCultNetDocumentBinding,
|
defineCultNetDocumentBinding,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
|
|
||||||
const noteDocument = defineDocumentType({
|
const noteDocument = defineDocumentType({
|
||||||
type: "note",
|
type: "note",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ leave because both lease the same document.
|
||||||
|
|
||||||
| Piece | Status |
|
| Piece | Status |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Two clients, real WebSockets, real `@gamecult/cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) |
|
| Two clients, real WebSockets, real `cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) |
|
||||||
| Edge clamp, and the source below matching the files that ran | **tested** |
|
| Edge clamp, and the source below matching the files that ran | **tested** |
|
||||||
| Browser bundle builds and the page is served | **tested** (HTTP fetch only) |
|
| Browser bundle builds and the page is served | **tested** (HTTP fetch only) |
|
||||||
| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe |
|
| The page running in a real browser, two windows | **not yet**: no browser on the host that built this recipe |
|
||||||
|
|
@ -19,7 +19,7 @@ leave because both lease the same document.
|
||||||
| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority |
|
| Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority |
|
||||||
| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported |
|
| `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported |
|
||||||
|
|
||||||
No `@gamecult/cultmesh-ts` or `@gamecult/cultnet-ts` TypeScript authority server exists. The server below is the CultNet
|
No `cultmesh-ts` or `cultnet-ts` TypeScript authority server exists. The server below is the CultNet
|
||||||
message handling a provider owes, written in this recipe with the `ws` package.
|
message handling a provider owes, written in this recipe with the `ws` package.
|
||||||
|
|
||||||
## Run it
|
## Run it
|
||||||
|
|
@ -61,8 +61,8 @@ export const arenaSize = 100;
|
||||||
import { createServer, type RequestListener, type Server } from "node:http";
|
import { createServer, type RequestListener, type Server } from "node:http";
|
||||||
import { CultCache } from "@gamecult/cultcache-ts";
|
import { CultCache } from "@gamecult/cultcache-ts";
|
||||||
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
|
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
|
||||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||||
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
|
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts";
|
||||||
import { WebSocketServer, type WebSocket } from "ws";
|
import { WebSocketServer, type WebSocket } from "ws";
|
||||||
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||||
|
|
||||||
|
|
@ -163,7 +163,7 @@ export async function startArena(port = 0, host = "127.0.0.1", onRequest?: Reque
|
||||||
## client.ts
|
## client.ts
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
|
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser";
|
||||||
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||||
|
|
||||||
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
|
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
|
import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser";
|
||||||
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||||
|
|
||||||
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
|
// Runs unchanged in a browser or in Node 22+: both have a global WebSocket.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { createServer, type RequestListener, type Server } from "node:http";
|
import { createServer, type RequestListener, type Server } from "node:http";
|
||||||
import { CultCache } from "@gamecult/cultcache-ts";
|
import { CultCache } from "@gamecult/cultcache-ts";
|
||||||
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
|
import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document";
|
||||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||||
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts";
|
import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts";
|
||||||
import { WebSocketServer, type WebSocket } from "ws";
|
import { WebSocketServer, type WebSocket } from "ws";
|
||||||
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts";
|
||||||
|
|
||||||
|
|
|
||||||
41
package-lock.json
generated
41
package-lock.json
generated
|
|
@ -482,18 +482,6 @@
|
||||||
"resolved": "packages/cultcache-ts",
|
"resolved": "packages/cultcache-ts",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
"node_modules/@gamecult/cultmesh-browser": {
|
|
||||||
"resolved": "packages/cultmesh-browser",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@gamecult/cultmesh-ts": {
|
|
||||||
"resolved": "packages/cultmesh-ts",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@gamecult/cultnet-ts": {
|
|
||||||
"resolved": "packages/cultnet-ts",
|
|
||||||
"link": true
|
|
||||||
},
|
|
||||||
"node_modules/@jsdevtools/ono": {
|
"node_modules/@jsdevtools/ono": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||||
|
|
@ -877,6 +865,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Python-2.0"
|
"license": "Python-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cultmesh-browser": {
|
||||||
|
"resolved": "packages/cultmesh-browser",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/cultmesh-ts": {
|
||||||
|
"resolved": "packages/cultmesh-ts",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/cultnet-ts": {
|
||||||
|
"resolved": "packages/cultnet-ts",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
|
|
@ -1184,7 +1184,7 @@
|
||||||
},
|
},
|
||||||
"packages/cultcache-ts": {
|
"packages/cultcache-ts": {
|
||||||
"name": "@gamecult/cultcache-ts",
|
"name": "@gamecult/cultcache-ts",
|
||||||
"version": "0.15.0",
|
"version": "0.14.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@msgpack/msgpack": "^3.1.3",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
|
|
@ -1200,16 +1200,15 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/cultmesh-browser": {
|
"packages/cultmesh-browser": {
|
||||||
"name": "@gamecult/cultmesh-browser",
|
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gamecult/cultcache-ts": "^0.15.0",
|
"@gamecult/cultcache-ts": "^0.14.0",
|
||||||
"@gamecult/cultnet-ts": "^0.3.0"
|
"cultnet-ts": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@gamecult/cultmesh-ts": "^0.2.0",
|
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
"cultmesh-ts": "^0.2.0",
|
||||||
"typescript": "^5.9.0"
|
"typescript": "^5.9.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
@ -1217,12 +1216,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/cultmesh-ts": {
|
"packages/cultmesh-ts": {
|
||||||
"name": "@gamecult/cultmesh-ts",
|
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gamecult/cultcache-ts": "^0.15.0",
|
"@gamecult/cultcache-ts": "^0.14.0",
|
||||||
"@gamecult/cultnet-ts": "^0.3.0",
|
"cultnet-ts": "^0.3.0",
|
||||||
"koffi": "3.3.0"
|
"koffi": "3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -1235,11 +1233,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"packages/cultnet-ts": {
|
"packages/cultnet-ts": {
|
||||||
"name": "@gamecult/cultnet-ts",
|
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gamecult/cultcache-ts": "^0.15.0",
|
"@gamecult/cultcache-ts": "^0.14.1",
|
||||||
"@msgpack/msgpack": "^3.1.3",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"ajv": "^8.17.1"
|
"ajv": "^8.17.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,7 @@
|
||||||
//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces.
|
//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces.
|
||||||
|
|
||||||
use cultcache_rs::{
|
use cultcache_rs::{
|
||||||
CacheBackingStore, CultCache, CultCacheSecondGlobal, CultCacheSoaTable, DatabaseEntry,
|
CacheBackingStore, CultCache, CultCacheSecondGlobal, DatabaseEntry, SingleFileMessagePackBackingStore,
|
||||||
SingleFileMessagePackBackingStore,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
|
||||||
|
|
@ -322,31 +321,3 @@ fn loading_a_store_that_holds_two_globals_is_refused() {
|
||||||
assert!(loading.pull_all_backing_stores().unwrap_err().is::<CultCacheSecondGlobal>());
|
assert!(loading.pull_all_backing_stores().unwrap_err().is::<CultCacheSecondGlobal>());
|
||||||
assert_eq!(loading.get_global::<Settings>().unwrap(), None, "a refused load installs nothing");
|
assert_eq!(loading.get_global::<Settings>().unwrap(), None, "a refused load installs nothing");
|
||||||
}
|
}
|
||||||
|
|
||||||
// load_soa installs a whole table at once, so the one-global rule has to hold there too: a table holding two
|
|
||||||
// records of a global type is refused whole and the prior view survives.
|
|
||||||
#[test]
|
|
||||||
fn loading_an_soa_table_that_holds_two_globals_is_refused() {
|
|
||||||
let source = cache();
|
|
||||||
let (first, _) = source.prepare_entry("g1", &settings("one")).unwrap();
|
|
||||||
let (second, _) = source.prepare_entry("g2", &settings("two")).unwrap();
|
|
||||||
|
|
||||||
let mut loading = cache();
|
|
||||||
loading.put("g0", &settings("prior")).unwrap();
|
|
||||||
loading.put("plain:1", &Plain { value: "prior".into() }).unwrap();
|
|
||||||
|
|
||||||
let refusal = loading
|
|
||||||
.load_soa(CultCacheSoaTable::from_envelopes(vec![first.clone(), second]))
|
|
||||||
.unwrap_err();
|
|
||||||
let second_global = refusal.downcast_ref::<CultCacheSecondGlobal>().expect("a typed refusal");
|
|
||||||
let mut named = [second_global.existing_key.as_str(), second_global.key.as_str()];
|
|
||||||
named.sort();
|
|
||||||
assert_eq!(named, ["g1", "g2"], "the refusal names both records");
|
|
||||||
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("prior")), "a refused load keeps the prior view");
|
|
||||||
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), Some(Plain { value: "prior".into() }));
|
|
||||||
|
|
||||||
// One global in the table is the rule's own case, not a second.
|
|
||||||
loading.load_soa(CultCacheSoaTable::from_envelopes(vec![first])).unwrap();
|
|
||||||
assert_eq!(loading.get_global::<Settings>().unwrap(), Some(settings("one")));
|
|
||||||
assert_eq!(loading.get::<Plain>("plain:1").unwrap(), None, "a load replaces the view");
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,26 +3,29 @@
|
||||||
All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## [0.15.0]
|
## Unreleased
|
||||||
|
|
||||||
0.14.1 and 0.14.2 were never tagged or published; this entry carries them with the Unreleased work, and `cultcache-ts-v0.14.0` is the previous release.
|
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
|
- A single-file store written as `cultcache.store.v3` is read and written: a record that holds an element id (an owned list element with an `[CultElementId]` member, carried through every write) marks the store v3, and a v3 store is carried through a rewrite with its ids. A runtime from before this change refuses a v3 store by name.
|
||||||
- A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`.
|
- A single-file store refuses a zero-byte file as `StoreUnreadableError`; only a path with nothing at it is an empty store. A header this runtime does not read is refused, and the refusal echoes the header only as `cultcache.store.v<digits>`.
|
||||||
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again. A full `pushAll` over a file with no readable stamp does not replace it: the reader refuses it. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
|
- A write over a file the reader refuses (`StoreUnreadableError`, from `push`, `pushAll` and `delete`) refuses and leaves the file as it is. A flush used to replace such a file. A write set cannot merge with a disk it cannot read, so the file is repaired or removed by its owner before the store writes again.
|
||||||
|
- This reverses the 0.14.2 `pushAll` fix in two ways. A full `pushAll` over a file with no readable stamp no longer replaces it (healing it): the reader refuses it, and the file is left as it is. And a `cultcache.store.v3` stamp is read, not refused. The stamp rule has one owner, `refuseUnreadFormat`, asked only by the one reader that every write consults under the lock. The reader judges the stamp from the header before it decodes the body, so a newer store is refused by name whether or not its body is whole.
|
||||||
- A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store.
|
- A dangling symbolic link at the store path is an I/O error with code `ENOENT`, not an empty store.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `@gamecult/cultcache-ts/browser`, also what a bundler resolves for the package root under the `browser` export condition: the root surface without `SingleFileMessagePackBackingStore` and `readSingleFileStore`, so it imports no `node:*` built-in and no `koffi` and bundles with esbuild `platform: "browser"`. The Node entry exports exactly what it did.
|
- `@gamecult/cultcache-ts/browser`, also what a bundler resolves for the package root under the `browser` export condition: the root surface without `SingleFileMessagePackBackingStore` and `readSingleFileStore`, so it imports no `node:*` built-in and no `koffi` and bundles with esbuild `platform: "browser"`. The Node entry exports exactly what it did.
|
||||||
|
|
||||||
- `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader.
|
- `readSingleFileStore(path)`, the one synchronous reader of a single-file store, for consumers that must read a store file rather than decode it themselves. `pullAll`, `push`, `delete` and `pushAll` ask the same reader.
|
||||||
- `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity.
|
- `StoreUnreadableError` for a store file this runtime cannot read, `SchemaConflictError` for a write the catalog cannot describe, and `schemaIdentityOf` with its result type `CultCacheSchemaIdentity` (`schemaId`, `schemaName`, `schemaVersion`, `compatibleSchemaIds`), the one derivation of a definition's identity.
|
||||||
- The document payload codec, exported from `@gamecult/cultcache-ts/document`: `decodeDocumentPayload`, `encodeDocumentPayload`, and the two named byte escapes `documentPayloadFromBytes` and `documentPayloadBytes`.
|
|
||||||
- `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`).
|
- `SingleFileStoreRead`, exported: the result type of `readSingleFileStore` (`absent`, `format`, `envelopes`, `repairedLegacyPayload`).
|
||||||
- `CultCacheInspection.format` is `StoreFormat | "legacy.envelope-array"`: it reports `cultcache.store.v3` for a v3 store.
|
- `CultCacheInspection.format` is `StoreFormat | "legacy.envelope-array"`: it reports `cultcache.store.v3` for a v3 store.
|
||||||
- A soft `pushAll` writes only where nothing is at the path, leaves a store it can read byte for byte, and refuses what the reader refuses.
|
- A soft `pushAll` writes only where nothing is at the path, leaves a store it can read byte for byte, and refuses what the reader refuses.
|
||||||
|
|
||||||
|
## [0.14.2]
|
||||||
|
|
||||||
|
### Added
|
||||||
- `SingleFileMessagePackBackingStore` takes a cross-process write lock on `<file>.lock`,
|
- `SingleFileMessagePackBackingStore` takes a cross-process write lock on `<file>.lock`,
|
||||||
interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows).
|
interoperable with the C# and Rust runtimes (`flock` on Unix, `LockFileEx` on Windows).
|
||||||
`push` and `delete` read the current file, merge and replace it under the lock, so concurrent
|
`push` and `delete` read the current file, merge and replace it under the lock, so concurrent
|
||||||
|
|
@ -32,18 +35,27 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
|
||||||
is, but C# writers can still overwrite records a concurrent TypeScript writer added.
|
is, but C# writers can still overwrite records a concurrent TypeScript writer added.
|
||||||
- The staged file is fsynced before the replace, and on Unix the directory after it. A
|
- The staged file is fsynced before the replace, and on Unix the directory after it. A
|
||||||
directory sync failure after the replace is reported as replaced but not durable.
|
directory sync failure after the replace is reported as replaced but not durable.
|
||||||
- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents
|
|
||||||
(`cultnet-ts/content`) bound payload nesting with the same check the cache
|
|
||||||
applies on decode.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers
|
- The Windows replace is a POSIX-semantics rename first (it lands while C# and Rust readers
|
||||||
hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms
|
hold the store open), falling back to a write-through `MoveFileExW`, with 25 attempts 20 ms
|
||||||
apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock.
|
apart on errors 5, 32 and 33. Writers wait up to 30 s for the lock.
|
||||||
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken
|
- Opening a legacy store that needs healing rewrites it under the lock, from a read taken
|
||||||
under the lock.
|
under the lock.
|
||||||
|
|
||||||
### Dependencies
|
### Fixed
|
||||||
|
- A full `pushAll` reads the store's header under the lock first and refuses a store format
|
||||||
|
this runtime does not read (the same error a read gives), leaving the file as it was. A
|
||||||
|
file with no readable stamp is still replaced, so a whole write heals it. Soft `pushAll`
|
||||||
|
is unchanged. The stamp is decoded by the decoder a read uses, so a stamp followed by bytes
|
||||||
|
that are not UTF-8 is refused by a whole write as a read refuses it.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
- `koffi` 3.3.0, loaded only on the first write.
|
- `koffi` 3.3.0, loaded only on the first write.
|
||||||
|
|
||||||
|
## [0.14.1]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `checkPayloadDepth` is re-exported from `@gamecult/cultcache-ts/document`, so dependents
|
||||||
|
(`cultnet-ts/content`) bound payload nesting with the same check the cache
|
||||||
|
applies on decode.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@gamecult/cultcache-ts",
|
"name": "@gamecult/cultcache-ts",
|
||||||
"version": "0.15.0",
|
"version": "0.14.2",
|
||||||
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
|
"description": "CultCache-style typed persistence for TypeScript with schema-backed polymorphic MessagePack storage.",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to `@gamecult/cultmesh-browser` are recorded here, following
|
All notable changes to `cultmesh-browser` are recorded here, following
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
|
|
||||||
- The package is renamed `cultmesh-browser` to `@gamecult/cultmesh-browser` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-browser` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`; the interop test uses `@gamecult/cultmesh-ts`.
|
|
||||||
|
|
||||||
## [0.4.0]
|
## [0.4.0]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# CultMesh Browser
|
# CultMesh Browser
|
||||||
|
|
||||||
`@gamecult/cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one
|
`cultmesh-browser` is the browser-safe CultMesh client boundary. It owns one
|
||||||
WebSocket session for a stable Verse/authority-runtime target, retains explicit raw
|
WebSocket session for a stable Verse/authority-runtime target, retains explicit raw
|
||||||
document leases across route replacement, and correlates typed CultNet
|
document leases across route replacement, and correlates typed CultNet
|
||||||
operations with provider receipts.
|
operations with provider receipts.
|
||||||
|
|
@ -95,7 +95,7 @@ WebCrypto `crypto.subtle`; without WebCrypto it fails with
|
||||||
endpoint CultMesh resolved is the only host the reader trusts.
|
endpoint CultMesh resolved is the only host the reader trusts.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { fetchVerifiedContent } from "@gamecult/cultmesh-browser";
|
import { fetchVerifiedContent } from "cultmesh-browser";
|
||||||
|
|
||||||
const bytes = await fetchVerifiedContent(endpoint, manifest, {
|
const bytes = await fetchVerifiedContent(endpoint, manifest, {
|
||||||
signal: AbortSignal.timeout(30_000),
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@gamecult/cultmesh-browser",
|
"name": "cultmesh-browser",
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
|
"description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
@ -41,12 +41,12 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gamecult/cultcache-ts": "^0.15.0",
|
"@gamecult/cultcache-ts": "^0.14.0",
|
||||||
"@gamecult/cultnet-ts": "^0.3.0"
|
"cultnet-ts": "^0.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@gamecult/cultmesh-ts": "^0.2.0",
|
"cultmesh-ts": "^0.2.0",
|
||||||
"typescript": "^5.9.0"
|
"typescript": "^5.9.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
// The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence).
|
// The response's declared length and coding are never consulted (Fetch decodes codings; the bytes received are the evidence).
|
||||||
// Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here.
|
// Hashing, hex rules, record keys, the loopback rule and manifest validation belong to cultnet-ts and are only called here.
|
||||||
|
|
||||||
import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content";
|
import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "cultnet-ts/content";
|
||||||
import { isLoopbackEndpoint } from "@gamecult/cultnet-ts/authority";
|
import { isLoopbackEndpoint } from "cultnet-ts/authority";
|
||||||
|
|
||||||
export type CultMeshContentErrorCode =
|
export type CultMeshContentErrorCode =
|
||||||
| "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network"
|
| "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document";
|
import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document";
|
||||||
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts";
|
import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts";
|
||||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||||
import {
|
import {
|
||||||
encodeCultNetMessageForWire,
|
encodeCultNetMessageForWire,
|
||||||
parseCultNetMessage,
|
parseCultNetMessage,
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
type CultMeshVerseCatalogResponseMessage,
|
type CultMeshVerseCatalogResponseMessage,
|
||||||
type CultMeshSessionAcceptedMessage,
|
type CultMeshSessionAcceptedMessage,
|
||||||
type CultMeshSessionOpenMessage,
|
type CultMeshSessionOpenMessage,
|
||||||
} from "@gamecult/cultnet-ts/contracts";
|
} from "cultnet-ts/contracts";
|
||||||
import {
|
import {
|
||||||
base64ToBytes,
|
base64ToBytes,
|
||||||
bytesToBase64,
|
bytesToBase64,
|
||||||
|
|
@ -31,7 +31,7 @@ import {
|
||||||
type CultMeshAuthorityTrustMode,
|
type CultMeshAuthorityTrustMode,
|
||||||
type CultMeshAuthorityTrustPolicy,
|
type CultMeshAuthorityTrustPolicy,
|
||||||
type CultMeshP256PublicKey,
|
type CultMeshP256PublicKey,
|
||||||
} from "@gamecult/cultnet-ts/authority";
|
} from "cultnet-ts/authority";
|
||||||
export * from "./content.js";
|
export * from "./content.js";
|
||||||
|
|
||||||
export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity;
|
export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity;
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { gzipSync } from "node:zlib";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { build } from "esbuild";
|
import { build } from "esbuild";
|
||||||
import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content";
|
import { EMPTY_SHA256, packContent, sha256Hex, type CultMeshCdnArtifactManifest } from "cultnet-ts/content";
|
||||||
|
|
||||||
import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js";
|
import { CultMeshContentError, fetchVerifiedChunk, fetchVerifiedContent, type CultMeshContentErrorCode } from "../src/content.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
||||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||||
import {
|
import {
|
||||||
encodeCultNetMessageForWire,
|
encodeCultNetMessageForWire,
|
||||||
parseCultNetMessage,
|
parseCultNetMessage,
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
type CultNetRawDocumentRecord,
|
type CultNetRawDocumentRecord,
|
||||||
type CultMeshVerseCatalogResponseMessage,
|
type CultMeshVerseCatalogResponseMessage,
|
||||||
type CultMeshSessionOpenMessage,
|
type CultMeshSessionOpenMessage,
|
||||||
} from "@gamecult/cultnet-ts/contracts";
|
} from "cultnet-ts/contracts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CultMeshBrowserClient,
|
CultMeshBrowserClient,
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { readFileSync } from "node:fs";
|
||||||
import { createServer } from "node:https";
|
import { createServer } from "node:https";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { createInterface } from "node:readline";
|
import { createInterface } from "node:readline";
|
||||||
import { chunkRecordKey } from "@gamecult/cultnet-ts/content";
|
import { chunkRecordKey } from "cultnet-ts/content";
|
||||||
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "@gamecult/cultmesh-ts/content";
|
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "cultmesh-ts/content";
|
||||||
|
|
||||||
const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string];
|
const [storeDir, corruptDir, keyPem, certPem, escapeKey] = process.argv.slice(2) as [string, string, string, string, string];
|
||||||
const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); };
|
const say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); };
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { decodeManifest } from "@gamecult/cultnet-ts/content";
|
import { decodeManifest } from "cultnet-ts/content";
|
||||||
import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js";
|
import { fetchVerifiedChunk, fetchVerifiedContent, CultMeshContentError } from "../../src/content.js";
|
||||||
|
|
||||||
const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined];
|
const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined];
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import { tmpdir } from "node:os";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { EMPTY_SHA256, encodeManifest, packContent } from "@gamecult/cultnet-ts/content";
|
import { EMPTY_SHA256, encodeManifest, packContent } from "cultnet-ts/content";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop
|
const here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,8 @@ import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts";
|
import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts";
|
||||||
import { decodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue } from "cultnet-ts/wire";
|
||||||
import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts/contracts";
|
import type { CultNetRawDocumentRecord } from "cultnet-ts/contracts";
|
||||||
|
|
||||||
import { decodeCultNetPayload } from "../src/index.js";
|
import { decodeCultNetPayload } from "../src/index.js";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -808,7 +808,6 @@ interface CultNetSchemaMessageTransport : AutoCloseable {
|
||||||
val profile: CultNetTransportProfile
|
val profile: CultNetTransportProfile
|
||||||
val stats: CultNetTransportStats
|
val stats: CultNetTransportStats
|
||||||
|
|
||||||
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
|
|
||||||
fun sendSchemaMessage(message: CultNetMessage)
|
fun sendSchemaMessage(message: CultNetMessage)
|
||||||
|
|
||||||
fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage?
|
fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage?
|
||||||
|
|
@ -3168,16 +3167,13 @@ class CultNetRudpSocketTransportConnection(
|
||||||
return connected
|
return connected
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
|
|
||||||
fun send(channelId: String, payload: ByteArray) {
|
fun send(channelId: String, payload: ByteArray) {
|
||||||
session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) }
|
session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) }
|
||||||
framesSent += 1
|
framesSent += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
|
|
||||||
fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8))
|
fun send(channelId: String, payload: String) = send(channelId, payload.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
|
||||||
/** Fire-and-forget: an ack or a return is not admission; read the result frame. */
|
|
||||||
fun sendSchema(payload: ByteArray) = send("schema", payload)
|
fun sendSchema(payload: ByteArray) = send("schema", payload)
|
||||||
|
|
||||||
fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8))
|
fun sendSchema(payload: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ game sessions with prediction reconciliation, committed simulation facts, the
|
||||||
local TCP/RUDP server, and a package daemon.
|
local TCP/RUDP server, and a package daemon.
|
||||||
|
|
||||||
It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and
|
It depends on `cultcache-py` and `cultnet-py`, mirroring `cultmesh-rs` and
|
||||||
`@gamecult/cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream
|
`cultmesh-ts`. The Verse catalog, peer exchange, authority lease, stream
|
||||||
negotiation, and simulation fact contracts are defined in
|
negotiation, and simulation fact contracts are defined in
|
||||||
`cultnet_py.cultmesh_contracts` and re-exported here.
|
`cultnet_py.cultmesh_contracts` and re-exported here.
|
||||||
|
|
||||||
|
|
@ -122,33 +122,6 @@ server = CultMesh.serve_node(
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Peer writes
|
|
||||||
|
|
||||||
**Breaking (0.x):** a served node refuses every peer `document_put_raw` and `document_delete` unless a write
|
|
||||||
authority grants it, mirroring C# `ICultNetRemoteWriteAuthority`. Before this, any connected peer could overwrite
|
|
||||||
or delete the node's documents. The refusal is a typed `cultnet.error.v0` with code `write_not_authorized` and the
|
|
||||||
record key in `details`; an authority that raises, or returns no decision, refuses too.
|
|
||||||
|
|
||||||
Known residual: ungranted peers can still distinguish registered from unregistered schemas by timing (the node resolves the
|
|
||||||
schema before the gate so the authority sees the canonical id); the refusal itself is identical.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from cultmesh_py import CultMeshGrantAllWrites, CultMeshRemoteWriteDecision, CultMeshRemoteWriteRequest
|
|
||||||
|
|
||||||
class OwnKeysOnly:
|
|
||||||
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision:
|
|
||||||
if request.record_key.startswith("input:python:"):
|
|
||||||
return CultMeshRemoteWriteDecision.grant()
|
|
||||||
return CultMeshRemoteWriteDecision.refuse("not your key")
|
|
||||||
|
|
||||||
server = CultMesh.serve_node(node, write_authority=OwnKeysOnly())
|
|
||||||
```
|
|
||||||
|
|
||||||
The request carries the peer (`endpoint`, `transport`), the operation, the canonical schema id the node
|
|
||||||
resolved (never the alias the peer sent), the record key and the shard id and epoch as sent. `session.serve(...)`
|
|
||||||
takes the same `write_authority`. A host that already authenticates its peers, or a test rig, opts in to
|
|
||||||
`CultMeshGrantAllWrites()`; the daemon's equivalent is `--allow-peer-writes`. Without it the daemon is read-only to peers.
|
|
||||||
|
|
||||||
The same local server can be launched as a package daemon when another runtime
|
The same local server can be launched as a package daemon when another runtime
|
||||||
or operator process needs a CultNet endpoint without embedding Python glue:
|
or operator process needs a CultNet endpoint without embedding Python glue:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,14 +39,6 @@ from .simulation import (
|
||||||
CultMeshSimulationFactCommitter,
|
CultMeshSimulationFactCommitter,
|
||||||
simulation_fact_document,
|
simulation_fact_document,
|
||||||
)
|
)
|
||||||
from .write_authority import (
|
|
||||||
CultMeshGrantAllWrites,
|
|
||||||
CultMeshRemoteWriteAuthority,
|
|
||||||
CultMeshRemoteWriteDecision,
|
|
||||||
CultMeshRemoteWriteOperation,
|
|
||||||
CultMeshRemoteWriteRequest,
|
|
||||||
CultMeshServerPeer,
|
|
||||||
)
|
|
||||||
from cultnet_py.cultmesh_contracts import (
|
from cultnet_py.cultmesh_contracts import (
|
||||||
PEER_EXCHANGE_REQUEST,
|
PEER_EXCHANGE_REQUEST,
|
||||||
PEER_EXCHANGE_RESPONSE,
|
PEER_EXCHANGE_RESPONSE,
|
||||||
|
|
@ -97,12 +89,6 @@ __all__ = [
|
||||||
"CultMeshSimulationFact",
|
"CultMeshSimulationFact",
|
||||||
"CultMeshSimulationFactCommit",
|
"CultMeshSimulationFactCommit",
|
||||||
"CultMeshSimulationFactCommitter",
|
"CultMeshSimulationFactCommitter",
|
||||||
"CultMeshGrantAllWrites",
|
|
||||||
"CultMeshRemoteWriteAuthority",
|
|
||||||
"CultMeshRemoteWriteDecision",
|
|
||||||
"CultMeshRemoteWriteOperation",
|
|
||||||
"CultMeshRemoteWriteRequest",
|
|
||||||
"CultMeshServerPeer",
|
|
||||||
"CultMeshAuthorityLease",
|
"CultMeshAuthorityLease",
|
||||||
"CultMeshAuthorityLeaseCatalog",
|
"CultMeshAuthorityLeaseCatalog",
|
||||||
"CultMeshHmacAuthorityLeaseVerifier",
|
"CultMeshHmacAuthorityLeaseVerifier",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser
|
||||||
|
|
||||||
from .facade import CultMesh
|
from .facade import CultMesh
|
||||||
from .server import CultMeshLocalServer
|
from .server import CultMeshLocalServer
|
||||||
from .write_authority import CultMeshGrantAllWrites
|
|
||||||
from cultnet_py.cultmesh_contracts import (
|
from cultnet_py.cultmesh_contracts import (
|
||||||
CultMeshPeerCard,
|
CultMeshPeerCard,
|
||||||
CultMeshPeerCatalog,
|
CultMeshPeerCatalog,
|
||||||
|
|
@ -54,7 +53,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
parser.add_argument("--simulation-quorum-ratio", type=float, default=0.5)
|
parser.add_argument("--simulation-quorum-ratio", type=float, default=0.5)
|
||||||
parser.add_argument("--verse-id")
|
parser.add_argument("--verse-id")
|
||||||
parser.add_argument("--verse-display-name")
|
parser.add_argument("--verse-display-name")
|
||||||
parser.add_argument("--allow-peer-writes", action="store_true", help="grant every connected peer put and delete; without it peer writes are refused")
|
|
||||||
parser.add_argument("--role", action="append", dest="roles")
|
parser.add_argument("--role", action="append", dest="roles")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
@ -119,7 +117,6 @@ def start_server(args: argparse.Namespace, parser: argparse.ArgumentParser) -> C
|
||||||
display_name=args.display_name,
|
display_name=args.display_name,
|
||||||
max_snapshot_documents=args.max_snapshot_documents,
|
max_snapshot_documents=args.max_snapshot_documents,
|
||||||
max_snapshot_bytes=args.max_snapshot_bytes,
|
max_snapshot_bytes=args.max_snapshot_bytes,
|
||||||
write_authority=CultMeshGrantAllWrites() if args.allow_peer_writes else None,
|
|
||||||
)
|
)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
# The server owns its option rules; a limit it refuses is a usage error, not a crash.
|
# The server owns its option rules; a limit it refuses is a usage error, not a crash.
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ from .node import (
|
||||||
from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer
|
from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer
|
||||||
from .session import CultMeshGameSession, CultMeshGameSessionOptions
|
from .session import CultMeshGameSession, CultMeshGameSessionOptions
|
||||||
from .simulation import CultMeshSimulationFactCommitter
|
from .simulation import CultMeshSimulationFactCommitter
|
||||||
from .write_authority import CultMeshRemoteWriteAuthority
|
|
||||||
from cultnet_py.cultmesh_contracts import (
|
from cultnet_py.cultmesh_contracts import (
|
||||||
AuthorityLeaseVerifier,
|
AuthorityLeaseVerifier,
|
||||||
CultMeshAuthorityLeaseCatalog,
|
CultMeshAuthorityLeaseCatalog,
|
||||||
|
|
@ -401,7 +400,6 @@ class CultMesh:
|
||||||
verse_catalog: CultMeshVerseCatalog | None = None,
|
verse_catalog: CultMeshVerseCatalog | None = None,
|
||||||
peer_catalog: CultMeshPeerCatalog | None = None,
|
peer_catalog: CultMeshPeerCatalog | None = None,
|
||||||
observation_hub: CultNetSimulationObservationHub | None = None,
|
observation_hub: CultNetSimulationObservationHub | None = None,
|
||||||
write_authority: CultMeshRemoteWriteAuthority | None = None,
|
|
||||||
host: str = "127.0.0.1",
|
host: str = "127.0.0.1",
|
||||||
port: int = 0,
|
port: int = 0,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
|
|
@ -418,7 +416,6 @@ class CultMesh:
|
||||||
verse_catalog=verse_catalog or CultMeshVerseCatalog(),
|
verse_catalog=verse_catalog or CultMeshVerseCatalog(),
|
||||||
peer_catalog=peer_catalog or CultMeshPeerCatalog(),
|
peer_catalog=peer_catalog or CultMeshPeerCatalog(),
|
||||||
observation_hub=observation_hub,
|
observation_hub=observation_hub,
|
||||||
write_authority=write_authority,
|
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
|
|
|
||||||
|
|
@ -650,29 +650,6 @@ class CultMeshDatabase:
|
||||||
self._append_shard_log_delete(message)
|
self._append_shard_log_delete(message)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
def raw_put_target(self, message: dict[str, Any]) -> tuple[str, str] | None:
|
|
||||||
"""The canonical schema id and record key a raw put would write, or None when it would not apply."""
|
|
||||||
document_record = message.get("document")
|
|
||||||
# Only a document of the wire shape is resolved, so an ungranted peer's payload is never
|
|
||||||
# converted or allocated here: a malformed one is the store's refusal, after the authority.
|
|
||||||
if (
|
|
||||||
not isinstance(document_record, dict)
|
|
||||||
or not isinstance(document_record.get("recordKey"), str)
|
|
||||||
or not isinstance(document_record.get("payload"), (bytes, bytearray, memoryview))
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
prepared = self._raw_put_envelope(message)
|
|
||||||
except Exception: # noqa: BLE001 - any other malformed shape
|
|
||||||
return None
|
|
||||||
return None if prepared is None else (prepared[0].catalog_entry().schema_id, prepared[1].key)
|
|
||||||
|
|
||||||
def raw_delete_target(self, message: dict[str, Any]) -> tuple[str, str] | None:
|
|
||||||
"""The canonical schema id and record key a raw delete names, or None when its schema is not registered."""
|
|
||||||
document = self._document_for_schema(str(message.get("schemaId") or ""))
|
|
||||||
record_key = str(message.get("recordKey") or "")
|
|
||||||
return None if document is None or not record_key else (document.catalog_entry().schema_id, record_key)
|
|
||||||
|
|
||||||
def apply_raw_put_message(self, message: dict[str, Any]) -> CultMeshDatabaseChange | None:
|
def apply_raw_put_message(self, message: dict[str, Any]) -> CultMeshDatabaseChange | None:
|
||||||
prepared = self._raw_put_envelope(message)
|
prepared = self._raw_put_envelope(message)
|
||||||
if prepared is None:
|
if prepared is None:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import errno
|
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
import threading
|
import threading
|
||||||
|
|
@ -11,8 +10,6 @@ from typing import Any
|
||||||
import msgpack
|
import msgpack
|
||||||
|
|
||||||
from cultnet_py import (
|
from cultnet_py import (
|
||||||
error_message,
|
|
||||||
message_invalid_error,
|
|
||||||
unsupported_schema_version_error,
|
unsupported_schema_version_error,
|
||||||
CultNetRawSnapshotResponse,
|
CultNetRawSnapshotResponse,
|
||||||
CultNetSimulationObservationHub,
|
CultNetSimulationObservationHub,
|
||||||
|
|
@ -31,12 +28,6 @@ from cultnet_py import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from .node import CultMeshNode
|
from .node import CultMeshNode
|
||||||
from .write_authority import (
|
|
||||||
CultMeshRemoteWriteAuthority,
|
|
||||||
CultMeshRemoteWriteOperation,
|
|
||||||
CultMeshRemoteWriteRequest,
|
|
||||||
CultMeshServerPeer,
|
|
||||||
)
|
|
||||||
|
|
||||||
# The shortest message id this server answers a snapshot request under. It takes a
|
# The shortest message id this server answers a snapshot request under. It takes a
|
||||||
# request's messageId as given, an empty one included, so no response it sends is smaller.
|
# request's messageId as given, an empty one included, so no response it sends is smaller.
|
||||||
|
|
@ -60,17 +51,6 @@ from cultnet_py.cultmesh_contracts import (
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# How many consecutive refused frames of one kind a RUDP session may send before it is ended: undecodable
|
|
||||||
# frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any decoded
|
|
||||||
# frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
|
|
||||||
# frame that reaches this number is answered too, then the session ends. The same number is the cap in every
|
|
||||||
# runtime.
|
|
||||||
MAX_CONSECUTIVE_REFUSED_FRAMES = 8
|
|
||||||
|
|
||||||
# How long a session whose capped frame was answered waits for the peer to acknowledge the answer before it ends anyway.
|
|
||||||
_RUDP_CAPPED_ANSWER_GRACE_MS = 2000
|
|
||||||
_EPHEMERAL_PAIR_ATTEMPTS = 64
|
|
||||||
|
|
||||||
|
|
||||||
class _ReplyUnqueueable(Exception):
|
class _ReplyUnqueueable(Exception):
|
||||||
"""A reply the peer's reliable queue cannot take. The peer's session ends, and the peer is
|
"""A reply the peer's reliable queue cannot take. The peer's session ends, and the peer is
|
||||||
|
|
@ -100,10 +80,6 @@ class _DatabaseSubscription:
|
||||||
class _RudpPeerConnection:
|
class _RudpPeerConnection:
|
||||||
session: CultNetRudpSession
|
session: CultNetRudpSession
|
||||||
subscriptions: dict[str, _DatabaseSubscription] = field(default_factory=dict)
|
subscriptions: dict[str, _DatabaseSubscription] = field(default_factory=dict)
|
||||||
undecodable_frames_in_a_row: int = 0
|
|
||||||
refused_deletes_in_a_row: int = 0
|
|
||||||
# Set once the peer's capped frame has been answered: the session ends when the answers are acknowledged.
|
|
||||||
ending_deadline_ms: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -112,9 +88,6 @@ class CultMeshLocalServer:
|
||||||
verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog)
|
verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog)
|
||||||
peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog)
|
peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog)
|
||||||
observation_hub: CultNetSimulationObservationHub | None = None
|
observation_hub: CultNetSimulationObservationHub | None = None
|
||||||
# Asked once per peer put and delete. None refuses every one of them (write_not_authorized); a node's own
|
|
||||||
# writes never come through here.
|
|
||||||
write_authority: CultMeshRemoteWriteAuthority | None = None
|
|
||||||
host: str = "127.0.0.1"
|
host: str = "127.0.0.1"
|
||||||
port: int = 0
|
port: int = 0
|
||||||
display_name: str | None = None
|
display_name: str | None = None
|
||||||
|
|
@ -185,49 +158,26 @@ class CultMeshLocalServer:
|
||||||
def start(self) -> "CultMeshLocalServer":
|
def start(self) -> "CultMeshLocalServer":
|
||||||
if self._socket is not None:
|
if self._socket is not None:
|
||||||
return self
|
return self
|
||||||
server, rudp_socket = self._bind_sockets()
|
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
server.bind((self.host, self.port))
|
||||||
|
server.listen()
|
||||||
server.settimeout(0.2)
|
server.settimeout(0.2)
|
||||||
self._socket = server
|
self._socket = server
|
||||||
self.port = int(server.getsockname()[1])
|
self.port = int(server.getsockname()[1])
|
||||||
self._stop.clear()
|
self._stop.clear()
|
||||||
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
|
self._thread = threading.Thread(target=self._accept_loop, daemon=True)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
if rudp_socket is not None:
|
if self.enable_rudp:
|
||||||
|
rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
rudp_socket.bind((self.host, self.port))
|
||||||
rudp_socket.settimeout(0.02)
|
rudp_socket.settimeout(0.02)
|
||||||
self._rudp_socket = rudp_socket
|
self._rudp_socket = rudp_socket
|
||||||
self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True)
|
self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True)
|
||||||
self._rudp_thread.start()
|
self._rudp_thread.start()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def _bind_sockets(self) -> tuple[socket.socket, socket.socket | None]:
|
|
||||||
"""The listening TCP socket and, when RUDP is on, the UDP socket on the same port number.
|
|
||||||
|
|
||||||
An explicit port is bound as asked and a failure is the caller's. Port 0 asks for any free port, and the
|
|
||||||
kernel picks the TCP one without knowing whether another process holds that number for UDP, so a
|
|
||||||
UDP collision retries with a fresh TCP port instead of failing the start. Both sockets are bound
|
|
||||||
before any thread starts, so a failed start leaves nothing running or open.
|
|
||||||
"""
|
|
||||||
attempts = _EPHEMERAL_PAIR_ATTEMPTS if self.port == 0 and self.enable_rudp else 1
|
|
||||||
for attempt in range(attempts):
|
|
||||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
rudp_socket = None
|
|
||||||
try:
|
|
||||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
server.bind((self.host, self.port))
|
|
||||||
server.listen()
|
|
||||||
if self.enable_rudp:
|
|
||||||
rudp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
rudp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
rudp_socket.bind((self.host, int(server.getsockname()[1])))
|
|
||||||
return server, rudp_socket
|
|
||||||
except OSError as error:
|
|
||||||
server.close()
|
|
||||||
if rudp_socket is not None:
|
|
||||||
rudp_socket.close()
|
|
||||||
if error.errno != errno.EADDRINUSE or attempt == attempts - 1:
|
|
||||||
raise
|
|
||||||
raise AssertionError("unreachable")
|
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._stop.set()
|
self._stop.set()
|
||||||
if self._rudp_socket is not None:
|
if self._rudp_socket is not None:
|
||||||
|
|
@ -288,15 +238,14 @@ class CultMeshLocalServer:
|
||||||
if server is None:
|
if server is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
client, address = server.accept()
|
client, _ = server.accept()
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
continue
|
continue
|
||||||
except OSError:
|
except OSError:
|
||||||
return
|
return
|
||||||
peer = CultMeshServerPeer(f"{address[0]}:{address[1]}", "tcp")
|
threading.Thread(target=self._handle_connection, args=(client,), daemon=True).start()
|
||||||
threading.Thread(target=self._handle_connection, args=(client, peer), daemon=True).start()
|
|
||||||
|
|
||||||
def _handle_connection(self, client: socket.socket, peer: CultMeshServerPeer) -> None:
|
def _handle_connection(self, client: socket.socket) -> None:
|
||||||
with client:
|
with client:
|
||||||
stream = client.makefile("rwb")
|
stream = client.makefile("rwb")
|
||||||
transport = TcpFramedTransportConnection(
|
transport = TcpFramedTransportConnection(
|
||||||
|
|
@ -318,7 +267,7 @@ class CultMeshLocalServer:
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
responses = self._handle_connection_message(message, subscriptions, peer=peer)
|
responses = self._handle_connection_message(message, subscriptions)
|
||||||
except Exception as error: # noqa: BLE001 - one message never ends the connection
|
except Exception as error: # noqa: BLE001 - one message never ends the connection
|
||||||
# Whatever a peer's message makes the handling raise is that peer's failure:
|
# Whatever a peer's message makes the handling raise is that peer's failure:
|
||||||
# it is refused with fixed text and the connection serves on. The log names
|
# it is refused with fixed text and the connection serves on. The log names
|
||||||
|
|
@ -360,7 +309,6 @@ class CultMeshLocalServer:
|
||||||
if packet.connection_id != self.rudp_connection_id:
|
if packet.connection_id != self.rudp_connection_id:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._end_answered_peers(rudp_socket, peers)
|
|
||||||
now_ms = _now_ms()
|
now_ms = _now_ms()
|
||||||
peer = peers.get(remote_addr)
|
peer = peers.get(remote_addr)
|
||||||
if packet.packet_type == CultNetRudpPacketType.CONNECT:
|
if packet.packet_type == CultNetRudpPacketType.CONNECT:
|
||||||
|
|
@ -409,31 +357,13 @@ class CultMeshLocalServer:
|
||||||
for delivered in result.delivered:
|
for delivered in result.delivered:
|
||||||
if delivered.channel_id != "schema":
|
if delivered.channel_id != "schema":
|
||||||
continue
|
continue
|
||||||
if peer.ending_deadline_ms is not None:
|
|
||||||
# The capped frame was answered; nothing after it is served while the answer is delivered.
|
|
||||||
break
|
|
||||||
try:
|
try:
|
||||||
message = msgpack.unpackb(delivered.payload, raw=False)
|
message = msgpack.unpackb(delivered.payload, raw=False)
|
||||||
except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError):
|
except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError):
|
||||||
message = None
|
continue
|
||||||
if isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
peer.undecodable_frames_in_a_row = 0
|
continue
|
||||||
responses = self._handle_connection_message(
|
responses = self._handle_connection_message(message, peer.subscriptions, over_rudp=True)
|
||||||
message,
|
|
||||||
peer.subscriptions,
|
|
||||||
over_rudp=True,
|
|
||||||
peer=CultMeshServerPeer(f"{remote_addr[0]}:{remote_addr[1]}", "rudp"),
|
|
||||||
)
|
|
||||||
refused_delete = message.get("schemaVersion") == "cultnet.document_delete.v0" and any(
|
|
||||||
response.get("code") == "write_not_authorized" for response in responses
|
|
||||||
)
|
|
||||||
peer.refused_deletes_in_a_row = peer.refused_deletes_in_a_row + 1 if refused_delete else 0
|
|
||||||
refused, run = refused_delete, peer.refused_deletes_in_a_row
|
|
||||||
else:
|
|
||||||
# A frame that is no CultNet message is answered, not dropped, and the session kept.
|
|
||||||
peer.undecodable_frames_in_a_row += 1
|
|
||||||
refused, run = True, peer.undecodable_frames_in_a_row
|
|
||||||
responses = [self._message_invalid({}, "message", "CultMesh RUDP server could not read the message.")]
|
|
||||||
for response in responses:
|
for response in responses:
|
||||||
if unsendable is None:
|
if unsendable is None:
|
||||||
unsendable = self._send_rudp_schema_frame(
|
unsendable = self._send_rudp_schema_frame(
|
||||||
|
|
@ -443,11 +373,6 @@ class CultMeshLocalServer:
|
||||||
msgpack.packb(response, use_bin_type=True),
|
msgpack.packb(response, use_bin_type=True),
|
||||||
message_id=str(response.get("messageId") or ""),
|
message_id=str(response.get("messageId") or ""),
|
||||||
)
|
)
|
||||||
if refused and run >= MAX_CONSECUTIVE_REFUSED_FRAMES:
|
|
||||||
# The capped frame is answered like any other, reliably. The session ends once the peer
|
|
||||||
# has acknowledged every answer (or the grace runs out): see _end_answered_peers.
|
|
||||||
peer.ending_deadline_ms = now_ms + _RUDP_CAPPED_ANSWER_GRACE_MS
|
|
||||||
break
|
|
||||||
except Exception as error: # noqa: BLE001 - one peer's message never ends the thread
|
except Exception as error: # noqa: BLE001 - one peer's message never ends the thread
|
||||||
# Whatever a peer's message makes the handling raise is that peer's failure.
|
# Whatever a peer's message makes the handling raise is that peer's failure.
|
||||||
# The session recorded the frame's sequence, so it cannot be kept: a retransmit
|
# The session recorded the frame's sequence, so it cannot be kept: a retransmit
|
||||||
|
|
@ -478,26 +403,11 @@ class CultMeshLocalServer:
|
||||||
if unsendable is not None:
|
if unsendable is not None:
|
||||||
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
|
self._end_unsendable_peer(rudp_socket, peers, remote_addr, unsendable)
|
||||||
|
|
||||||
def _end_answered_peers(
|
|
||||||
self,
|
|
||||||
rudp_socket: socket.socket,
|
|
||||||
peers: dict[tuple[str, int], _RudpPeerConnection],
|
|
||||||
) -> None:
|
|
||||||
"""Ends each session whose capped frame was answered once the peer has acknowledged every answer (a lost
|
|
||||||
answer is resent first), or when the grace has run out."""
|
|
||||||
now_ms = _now_ms()
|
|
||||||
for remote_addr, peer in list(peers.items()):
|
|
||||||
if peer.ending_deadline_ms is None:
|
|
||||||
continue
|
|
||||||
if peer.session.outstanding_reliable_packet_count == 0 or now_ms >= peer.ending_deadline_ms:
|
|
||||||
self._end_refused_peer(rudp_socket, peers, remote_addr)
|
|
||||||
|
|
||||||
def _poll_rudp_resends(
|
def _poll_rudp_resends(
|
||||||
self,
|
self,
|
||||||
rudp_socket: socket.socket,
|
rudp_socket: socket.socket,
|
||||||
peers: dict[tuple[str, int], _RudpPeerConnection],
|
peers: dict[tuple[str, int], _RudpPeerConnection],
|
||||||
) -> None:
|
) -> None:
|
||||||
self._end_answered_peers(rudp_socket, peers)
|
|
||||||
now_ms = _now_ms()
|
now_ms = _now_ms()
|
||||||
for remote_addr, peer in list(peers.items()):
|
for remote_addr, peer in list(peers.items()):
|
||||||
for packet in peer.session.due_resends(now_ms):
|
for packet in peer.session.due_resends(now_ms):
|
||||||
|
|
@ -609,7 +519,6 @@ class CultMeshLocalServer:
|
||||||
subscriptions: dict[str, _DatabaseSubscription],
|
subscriptions: dict[str, _DatabaseSubscription],
|
||||||
*,
|
*,
|
||||||
over_rudp: bool = False,
|
over_rudp: bool = False,
|
||||||
peer: CultMeshServerPeer | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
schema_version = message.get("schemaVersion")
|
schema_version = message.get("schemaVersion")
|
||||||
if schema_version == "cultnet.database_subscribe.v0":
|
if schema_version == "cultnet.database_subscribe.v0":
|
||||||
|
|
@ -633,9 +542,9 @@ class CultMeshLocalServer:
|
||||||
subscriptions.pop(str(message.get("subscriptionId") or ""), None)
|
subscriptions.pop(str(message.get("subscriptionId") or ""), None)
|
||||||
return []
|
return []
|
||||||
if schema_version == "cultnet.document_put_raw.v0":
|
if schema_version == "cultnet.document_put_raw.v0":
|
||||||
return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp, peer=peer)
|
return self._handle_raw_put(message, subscriptions, over_rudp=over_rudp)
|
||||||
if schema_version == "cultnet.document_delete.v0":
|
if schema_version == "cultnet.document_delete.v0":
|
||||||
return self._handle_raw_delete(message, subscriptions, peer=peer)
|
return self._handle_raw_delete(message, subscriptions)
|
||||||
if schema_version == "cultnet.simulation_observation.v0":
|
if schema_version == "cultnet.simulation_observation.v0":
|
||||||
return self._candidate_responses(message)
|
return self._candidate_responses(message)
|
||||||
response = self.handle_message(message)
|
response = self.handle_message(message)
|
||||||
|
|
@ -647,27 +556,14 @@ class CultMeshLocalServer:
|
||||||
subscriptions: dict[str, _DatabaseSubscription],
|
subscriptions: dict[str, _DatabaseSubscription],
|
||||||
*,
|
*,
|
||||||
over_rudp: bool = False,
|
over_rudp: bool = False,
|
||||||
peer: CultMeshServerPeer | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
document_record = message.get("document")
|
document_record = message.get("document")
|
||||||
if not isinstance(document_record, dict):
|
if not isinstance(document_record, dict):
|
||||||
return [self._message_invalid(message, "document", "Raw put messages must contain a document map.")]
|
return [self._error_response(
|
||||||
malformed_shard = self._malformed_shard_fields(message)
|
"Raw put messages must contain a document map.",
|
||||||
if malformed_shard is not None:
|
message_id=str(message.get("messageId") or ""),
|
||||||
return [malformed_shard]
|
code="malformed_document_put",
|
||||||
# Refusal precedes every other judgment of the put. The node resolves the schema first, because the
|
)]
|
||||||
# authority must be asked about the canonical schema id (a payload-inferred alias included), as C#
|
|
||||||
# does; only a payload of the wire shape is resolved, so nothing of an ungranted peer's is
|
|
||||||
# allocated. A put the node cannot resolve is asked about under the ids as sent. Residual: the
|
|
||||||
# refusal is the same either way, but an ungranted peer can still tell registered from unregistered
|
|
||||||
# schemas by timing.
|
|
||||||
target = self.node.database.raw_put_target(message) or (
|
|
||||||
str(document_record.get("schemaId") or ""),
|
|
||||||
str(document_record.get("recordKey") or ""),
|
|
||||||
)
|
|
||||||
refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.PUT, target, message)
|
|
||||||
if refusal is not None:
|
|
||||||
return [refusal]
|
|
||||||
unservable = self._unservable_put(message, over_rudp=over_rudp)
|
unservable = self._unservable_put(message, over_rudp=over_rudp)
|
||||||
if unservable is not None:
|
if unservable is not None:
|
||||||
return [unservable]
|
return [unservable]
|
||||||
|
|
@ -679,7 +575,11 @@ class CultMeshLocalServer:
|
||||||
# raises. It is refused to the peer that sent it; no thread and no other peer is
|
# raises. It is refused to the peer that sent it; no thread and no other peer is
|
||||||
# affected. Neither the refusal nor the log quotes the error, which can quote the put.
|
# affected. Neither the refusal nor the log quotes the error, which can quote the put.
|
||||||
_LOGGER.warning("CultMesh server refused a raw put the store could not take (%s).", type(error).__name__)
|
_LOGGER.warning("CultMesh server refused a raw put the store could not take (%s).", type(error).__name__)
|
||||||
return [self._message_invalid(message, "document", "Raw put document could not be stored.")]
|
return [self._error_response(
|
||||||
|
"Raw put document could not be stored.",
|
||||||
|
message_id=str(message.get("messageId") or ""),
|
||||||
|
code="malformed_document_put",
|
||||||
|
)]
|
||||||
if change is None:
|
if change is None:
|
||||||
return [self._error_response(
|
return [self._error_response(
|
||||||
"Raw put message did not apply to a registered document.",
|
"Raw put message did not apply to a registered document.",
|
||||||
|
|
@ -698,22 +598,15 @@ class CultMeshLocalServer:
|
||||||
self,
|
self,
|
||||||
message: dict[str, Any],
|
message: dict[str, Any],
|
||||||
subscriptions: dict[str, _DatabaseSubscription],
|
subscriptions: dict[str, _DatabaseSubscription],
|
||||||
*,
|
|
||||||
peer: CultMeshServerPeer | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
schema_id = str(message.get("schemaId") or "")
|
schema_id = str(message.get("schemaId") or "")
|
||||||
record_key = str(message.get("recordKey") or "")
|
record_key = str(message.get("recordKey") or "")
|
||||||
if not schema_id or not record_key:
|
if not schema_id or not record_key:
|
||||||
return [self._message_invalid(
|
return [self._error_response(
|
||||||
message, "schemaId" if not schema_id else "recordKey", "Raw delete messages must contain schemaId and recordKey.",
|
"Raw delete messages must contain schemaId and recordKey.",
|
||||||
|
message_id=str(message.get("messageId") or ""),
|
||||||
|
code="malformed_document_delete",
|
||||||
)]
|
)]
|
||||||
malformed_shard = self._malformed_shard_fields(message)
|
|
||||||
if malformed_shard is not None:
|
|
||||||
return [malformed_shard]
|
|
||||||
target = self.node.database.raw_delete_target(message) or (schema_id, record_key)
|
|
||||||
refusal = self._refuse_unauthorized_write(peer, CultMeshRemoteWriteOperation.DELETE, target, message)
|
|
||||||
if refusal is not None:
|
|
||||||
return [refusal]
|
|
||||||
change = self.node.database.apply_raw_delete_message(message)
|
change = self.node.database.apply_raw_delete_message(message)
|
||||||
if change is None:
|
if change is None:
|
||||||
return [self._error_response(
|
return [self._error_response(
|
||||||
|
|
@ -723,70 +616,6 @@ class CultMeshLocalServer:
|
||||||
)]
|
)]
|
||||||
return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id)
|
return self._database_delete_notifications(message, subscriptions, resolved_schema_id=change.schema_id)
|
||||||
|
|
||||||
def _message_invalid(self, message: dict[str, Any], field: str, reason: str) -> dict[str, Any]:
|
|
||||||
"""The typed `message_invalid` refusal naming the request field at fault, under the request's messageId."""
|
|
||||||
response = message_invalid_error(field, reason).to_wire()
|
|
||||||
response["messageId"] = str(message.get("messageId") or "")
|
|
||||||
return response
|
|
||||||
|
|
||||||
def _malformed_shard_fields(self, message: dict[str, Any]) -> dict[str, Any] | None:
|
|
||||||
"""The protocol error for a shardId that is not a string or a shardEpoch that is not an int (a bool is
|
|
||||||
not), else None. It precedes the gate, so the authority only ever judges a write whose shard it can
|
|
||||||
read, and the store never files a write under a shard the authority did not see."""
|
|
||||||
shard_id = message.get("shardId")
|
|
||||||
shard_epoch = message.get("shardEpoch")
|
|
||||||
if (shard_id is None or isinstance(shard_id, str)) and (
|
|
||||||
shard_epoch is None or (isinstance(shard_epoch, int) and not isinstance(shard_epoch, bool))
|
|
||||||
):
|
|
||||||
return None
|
|
||||||
return self._message_invalid(
|
|
||||||
message,
|
|
||||||
"shardId" if not (shard_id is None or isinstance(shard_id, str)) else "shardEpoch",
|
|
||||||
"shardId must be a string and shardEpoch an integer.",
|
|
||||||
)
|
|
||||||
|
|
||||||
def _refuse_unauthorized_write(
|
|
||||||
self,
|
|
||||||
peer: CultMeshServerPeer | None,
|
|
||||||
operation: CultMeshRemoteWriteOperation,
|
|
||||||
target: tuple[str, str],
|
|
||||||
message: dict[str, Any],
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""None when the write may proceed, else the typed refusal for the sending peer.
|
|
||||||
|
|
||||||
No authority configured refuses; an authority that raises refuses too (fail closed) and is logged.
|
|
||||||
"""
|
|
||||||
schema_id, record_key = target
|
|
||||||
reason: str | None
|
|
||||||
authority = self.write_authority
|
|
||||||
if authority is None:
|
|
||||||
reason = "no remote write authority is configured"
|
|
||||||
else:
|
|
||||||
request = CultMeshRemoteWriteRequest(
|
|
||||||
peer=peer or CultMeshServerPeer("", "unknown"),
|
|
||||||
operation=operation,
|
|
||||||
schema_id=schema_id,
|
|
||||||
record_key=record_key,
|
|
||||||
shard_id=message.get("shardId"),
|
|
||||||
shard_epoch=message.get("shardEpoch"),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
decision = authority.decide(request)
|
|
||||||
except Exception as error: # noqa: BLE001 - a failing authority refuses
|
|
||||||
_LOGGER.error("CultMesh remote write authority failed (%s); the write was refused.", type(error).__name__)
|
|
||||||
decision = None
|
|
||||||
reason = "the write authority failed"
|
|
||||||
else:
|
|
||||||
reason = getattr(decision, "reason", None)
|
|
||||||
if decision is not None and getattr(decision, "is_granted", False) is True:
|
|
||||||
return None
|
|
||||||
_LOGGER.warning("CultMesh %s of %s refused: write not authorized.", operation.value, schema_id)
|
|
||||||
return error_message(
|
|
||||||
f"write_not_authorized: {reason}" if reason and reason.strip() else "write_not_authorized",
|
|
||||||
code="write_not_authorized",
|
|
||||||
details={"field": "recordKey", "value": record_key},
|
|
||||||
).to_wire()
|
|
||||||
|
|
||||||
def _database_change_notifications(
|
def _database_change_notifications(
|
||||||
self,
|
self,
|
||||||
message: dict[str, Any],
|
message: dict[str, Any],
|
||||||
|
|
@ -974,7 +803,11 @@ class CultMeshLocalServer:
|
||||||
and isinstance(document_record.get("recordKey"), str)
|
and isinstance(document_record.get("recordKey"), str)
|
||||||
and isinstance(document_record.get("payload"), (bytes, bytearray))
|
and isinstance(document_record.get("payload"), (bytes, bytearray))
|
||||||
):
|
):
|
||||||
return self._message_invalid(message, "document", "Raw put documents must carry a string recordKey and a binary payload.")
|
return self._error_response(
|
||||||
|
"Raw put documents must carry a string recordKey and a binary payload.",
|
||||||
|
message_id=str(message.get("messageId") or ""),
|
||||||
|
code="malformed_document_put",
|
||||||
|
)
|
||||||
if self.max_snapshot_bytes is None and not over_rudp:
|
if self.max_snapshot_bytes is None and not over_rudp:
|
||||||
return None
|
return None
|
||||||
alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID)
|
alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMes
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .server import CultMeshLocalServer
|
from .server import CultMeshLocalServer
|
||||||
from .write_authority import CultMeshRemoteWriteAuthority
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -84,7 +83,6 @@ class CultMeshGameSession:
|
||||||
def serve(
|
def serve(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
write_authority: CultMeshRemoteWriteAuthority | None = None,
|
|
||||||
host: str = "127.0.0.1",
|
host: str = "127.0.0.1",
|
||||||
port: int = 0,
|
port: int = 0,
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
|
|
@ -98,7 +96,6 @@ class CultMeshGameSession:
|
||||||
verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(),
|
verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(),
|
||||||
peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(),
|
peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(),
|
||||||
observation_hub=self.observation_hub if self._serve_simulation_observations else None,
|
observation_hub=self.observation_hub if self._serve_simulation_observations else None,
|
||||||
write_authority=write_authority,
|
|
||||||
host=host,
|
host=host,
|
||||||
port=port,
|
port=port,
|
||||||
display_name=display_name,
|
display_name=display_name,
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
"""The one question a CultMesh server asks before it applies a peer's write.
|
|
||||||
|
|
||||||
Mirrors C# ``ICultNetRemoteWriteAuthority``: a server with no authority refuses every peer put and
|
|
||||||
delete, so serving a node never lets a connected peer overwrite its documents by default.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Protocol
|
|
||||||
|
|
||||||
|
|
||||||
class CultMeshRemoteWriteOperation(Enum):
|
|
||||||
PUT = "put"
|
|
||||||
DELETE = "delete"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CultMeshServerPeer:
|
|
||||||
"""The connection a write arrived on: its endpoint and transport, nothing the peer claimed."""
|
|
||||||
|
|
||||||
endpoint: str
|
|
||||||
transport: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CultMeshRemoteWriteRequest:
|
|
||||||
"""One peer write as the authority sees it.
|
|
||||||
|
|
||||||
``schema_id`` is the canonical id the node resolved for the write, never an alias the peer sent.
|
|
||||||
``shard_id`` and ``shard_epoch`` are as the peer sent them.
|
|
||||||
"""
|
|
||||||
|
|
||||||
peer: CultMeshServerPeer
|
|
||||||
operation: CultMeshRemoteWriteOperation
|
|
||||||
schema_id: str
|
|
||||||
record_key: str
|
|
||||||
shard_id: str | None = None
|
|
||||||
shard_epoch: int | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class CultMeshRemoteWriteDecision:
|
|
||||||
is_granted: bool
|
|
||||||
reason: str | None = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def grant() -> CultMeshRemoteWriteDecision:
|
|
||||||
return CultMeshRemoteWriteDecision(True)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def refuse(reason: str | None = None) -> CultMeshRemoteWriteDecision:
|
|
||||||
return CultMeshRemoteWriteDecision(False, reason)
|
|
||||||
|
|
||||||
|
|
||||||
class CultMeshRemoteWriteAuthority(Protocol):
|
|
||||||
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision: ...
|
|
||||||
|
|
||||||
|
|
||||||
class CultMeshGrantAllWrites:
|
|
||||||
"""Grants every peer write. For a host that has already authenticated its peers (or a test rig); it is
|
|
||||||
an explicit choice because serving without an authority refuses writes."""
|
|
||||||
|
|
||||||
def decide(self, request: CultMeshRemoteWriteRequest) -> CultMeshRemoteWriteDecision:
|
|
||||||
return CultMeshRemoteWriteDecision.grant()
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
"""Heimdall's invariant: no client treats a reliable-transport ack as write admission. A put completes only on
|
|
||||||
its result frame (write_not_authorized, or the success reply).
|
|
||||||
|
|
||||||
The Python write path is a subscription over RUDP: `send` queues the put, `read_next` returns the result frame
|
|
||||||
and raises the peer's refusal. The server here refuses the put; its reliable ack of the put arrives (the test
|
|
||||||
waits for it) and the client still reports the refusal, never success. The transport's `receive` yields data
|
|
||||||
frames only, so the order of the ack against the refusal cannot change what `read_next` reports.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from cultcache_py import define_database_entry_type
|
|
||||||
from cultmesh_py import CultMesh, create_node
|
|
||||||
from cultmesh_py.server import CultMeshLocalServer
|
|
||||||
from cultnet_py import CultNetDatabaseSubscription, CultNetPeerError, create_rudp_schema_transport
|
|
||||||
|
|
||||||
from test_remote_write_authority import KEY_A, NOTE, SCHEMA_ID, put_of
|
|
||||||
|
|
||||||
|
|
||||||
class ClientWriteCompletionTests(unittest.TestCase):
|
|
||||||
def test_a_put_the_server_refused_is_reported_refused_after_its_ack_arrived(self) -> None:
|
|
||||||
node = create_node(runtime_id="completion-server")
|
|
||||||
node.database.register_document(NOTE)
|
|
||||||
server = CultMeshLocalServer(node=node, port=0, write_authority=None)
|
|
||||||
server.start()
|
|
||||||
try:
|
|
||||||
subscription = CultNetDatabaseSubscription(
|
|
||||||
host="127.0.0.1",
|
|
||||||
port=server.port,
|
|
||||||
timeout_seconds=4.0,
|
|
||||||
subscription_id="completion",
|
|
||||||
schema_ids=[SCHEMA_ID],
|
|
||||||
include_snapshot=False,
|
|
||||||
create_transport=lambda: create_rudp_schema_transport(
|
|
||||||
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
|
|
||||||
),
|
|
||||||
)
|
|
||||||
with subscription as sub:
|
|
||||||
sub.send(put_of(KEY_A, "peer"))
|
|
||||||
sub._transport.flush_reliable(2.0) # the put's reliable ack has arrived
|
|
||||||
with self.assertRaises(CultNetPeerError) as raised:
|
|
||||||
sub.read_next()
|
|
||||||
finally:
|
|
||||||
server.stop()
|
|
||||||
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
|
|
||||||
self.assertIsNone(server.node.cache.get(NOTE, KEY_A))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -24,9 +24,7 @@ from cultcache_py import (
|
||||||
define_document_type,
|
define_document_type,
|
||||||
)
|
)
|
||||||
from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document
|
from cultcache_py.interop import INTEROP_SCHEMA_VERSION, interop_note_document
|
||||||
from cultmesh_py.server import MAX_CONSECUTIVE_REFUSED_FRAMES
|
|
||||||
from cultmesh_py.verify import verify
|
from cultmesh_py.verify import verify
|
||||||
from cultmesh_py.write_authority import CultMeshRemoteWriteDecision
|
|
||||||
from cultnet_py import (
|
from cultnet_py import (
|
||||||
compute_simulation_claim_hash,
|
compute_simulation_claim_hash,
|
||||||
CultNetClientAuthorityScope,
|
CultNetClientAuthorityScope,
|
||||||
|
|
@ -70,7 +68,7 @@ from cultnet_py import (
|
||||||
snapshot_request,
|
snapshot_request,
|
||||||
)
|
)
|
||||||
from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST
|
from cultnet_py.cultmesh_contracts import VERSE_CATALOG_REQUEST
|
||||||
from cultmesh_py import CultMeshGrantAllWrites, create_node
|
from cultmesh_py import create_node
|
||||||
from cultmesh_py import (
|
from cultmesh_py import (
|
||||||
CultMesh,
|
CultMesh,
|
||||||
CultMeshDatabase,
|
CultMeshDatabase,
|
||||||
|
|
@ -1633,7 +1631,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
primary = CultMesh.create_node(runtime_id="forward-primary")
|
primary = CultMesh.create_node(runtime_id="forward-primary")
|
||||||
primary.database.register_document(document)
|
primary.database.register_document(document)
|
||||||
server = CultMesh.serve_node(primary, write_authority=CultMeshGrantAllWrites())
|
server = CultMesh.serve_node(primary)
|
||||||
try:
|
try:
|
||||||
shard = CultNetShardDescriptor(
|
shard = CultNetShardDescriptor(
|
||||||
shard_id="forward",
|
shard_id="forward",
|
||||||
|
|
@ -3017,7 +3015,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
source.database.put_raw_message(document, "note:live", CanonicalNote("initial"))
|
source.database.put_raw_message(document, "note:live", CanonicalNote("initial"))
|
||||||
target = CultMesh.create_node(runtime_id="subscription-alias-target")
|
target = CultMesh.create_node(runtime_id="subscription-alias-target")
|
||||||
target.database.register_document(document)
|
target.database.register_document(document)
|
||||||
server = CultMesh.serve_node(source, write_authority=CultMeshGrantAllWrites())
|
server = CultMesh.serve_node(source)
|
||||||
try:
|
try:
|
||||||
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
||||||
with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription:
|
with CultMesh.subscribe_document(target, raw_client, alias, "note:live") as subscription:
|
||||||
|
|
@ -3535,7 +3533,6 @@ class CultMeshTests(unittest.TestCase):
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
"cultmesh_py.daemon",
|
"cultmesh_py.daemon",
|
||||||
"--allow-peer-writes",
|
|
||||||
"--runtime-id",
|
"--runtime-id",
|
||||||
"live-daemon-peer",
|
"live-daemon-peer",
|
||||||
"--port",
|
"--port",
|
||||||
|
|
@ -3726,7 +3723,6 @@ class CultMeshTests(unittest.TestCase):
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
"cultmesh_py.daemon",
|
"cultmesh_py.daemon",
|
||||||
"--allow-peer-writes",
|
|
||||||
"--runtime-id",
|
"--runtime-id",
|
||||||
"forwarder-daemon-peer",
|
"forwarder-daemon-peer",
|
||||||
"--port",
|
"--port",
|
||||||
|
|
@ -4002,7 +3998,6 @@ class CultMeshTests(unittest.TestCase):
|
||||||
server = CultMesh.serve_node(
|
server = CultMesh.serve_node(
|
||||||
node,
|
node,
|
||||||
verse_catalog=verses,
|
verse_catalog=verses,
|
||||||
write_authority=CultMeshGrantAllWrites(),
|
|
||||||
peer_catalog=peers,
|
peer_catalog=peers,
|
||||||
observation_hub=observation_hub,
|
observation_hub=observation_hub,
|
||||||
display_name="Mesh Server",
|
display_name="Mesh Server",
|
||||||
|
|
@ -4279,7 +4274,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
node = CultMesh.create_node(runtime_id="mesh-alias-sub-server")
|
node = CultMesh.create_node(runtime_id="mesh-alias-sub-server")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
|
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
server = CultMesh.serve_node(node)
|
||||||
try:
|
try:
|
||||||
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
||||||
with raw_client.subscribe_database(
|
with raw_client.subscribe_database(
|
||||||
|
|
@ -4328,7 +4323,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
shard_epoch=1,
|
shard_epoch=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
server = CultMesh.serve_node(node)
|
||||||
try:
|
try:
|
||||||
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
||||||
with raw_client.subscribe_database(
|
with raw_client.subscribe_database(
|
||||||
|
|
@ -4425,7 +4420,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
def served_alone_bytes(key: str, body_length: int) -> int:
|
def served_alone_bytes(key: str, body_length: int) -> int:
|
||||||
node = CultMesh.create_node(runtime_id="mesh-served-bound-probe")
|
node = CultMesh.create_node(runtime_id="mesh-served-bound-probe")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), enable_rudp=False)
|
server = CultMesh.serve_node(node, enable_rudp=False)
|
||||||
try:
|
try:
|
||||||
response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot)
|
response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot)
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -4439,7 +4434,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
|
|
||||||
node = CultMesh.create_node(runtime_id="mesh-served-bound")
|
node = CultMesh.create_node(runtime_id="mesh-served-bound")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), max_snapshot_bytes=limit, enable_rudp=False)
|
server = CultMesh.serve_node(node, max_snapshot_bytes=limit, enable_rudp=False)
|
||||||
try:
|
try:
|
||||||
refused, _ = exchange(server, put("put-over", "note:over", 101))
|
refused, _ = exchange(server, put("put-over", "note:over", 101))
|
||||||
stored_after_refusal = node.database.get(document, "note:over")
|
stored_after_refusal = node.database.get(document, "note:over")
|
||||||
|
|
@ -4503,7 +4498,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
document = self._rudp_bound_note()
|
document = self._rudp_bound_note()
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-large")
|
node = CultMesh.create_node(runtime_id="mesh-rudp-large")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
server = CultMesh.serve_node(node)
|
||||||
try:
|
try:
|
||||||
served, payload = self._rudp_exchange(
|
served, payload = self._rudp_exchange(
|
||||||
server,
|
server,
|
||||||
|
|
@ -4529,7 +4524,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
def served_alone_bytes(body_length: int) -> int:
|
def served_alone_bytes(body_length: int) -> int:
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe")
|
node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
server = CultMesh.serve_node(node)
|
||||||
try:
|
try:
|
||||||
_, payload = self._rudp_exchange(
|
_, payload = self._rudp_exchange(
|
||||||
server, self._rudp_note_put(document, "probe", key, body_length),
|
server, self._rudp_note_put(document, "probe", key, body_length),
|
||||||
|
|
@ -4559,7 +4554,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
|
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-bound")
|
node = CultMesh.create_node(runtime_id="mesh-rudp-bound")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8)
|
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=100, rudp_max_pending_reliable_packets=8)
|
||||||
try:
|
try:
|
||||||
refusals = [
|
refusals = [
|
||||||
self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0]
|
self._rudp_exchange(server, self._rudp_note_put(document, f"put-{served}", "note:q", body_for(served)))[0]
|
||||||
|
|
@ -4797,7 +4792,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
body_for = self._rudp_body_for(document, "note:w")
|
body_for = self._rudp_body_for(document, "note:w")
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-wide")
|
node = CultMesh.create_node(runtime_id="mesh-rudp-wide")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites(), rudp_max_fragment_bytes=1)
|
server = CultMesh.serve_node(node, rudp_max_fragment_bytes=1)
|
||||||
try:
|
try:
|
||||||
transport = create_rudp_schema_transport(
|
transport = create_rudp_schema_transport(
|
||||||
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id,
|
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id,
|
||||||
|
|
@ -4862,7 +4857,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
document = self._rudp_bound_note()
|
document = self._rudp_bound_note()
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-malformed")
|
node = CultMesh.create_node(runtime_id="mesh-rudp-malformed")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
server = CultMesh.serve_node(node)
|
||||||
try:
|
try:
|
||||||
refusals = []
|
refusals = []
|
||||||
for field_name, value in (
|
for field_name, value in (
|
||||||
|
|
@ -4885,146 +4880,11 @@ class CultMeshTests(unittest.TestCase):
|
||||||
for message_id, refusal in refusals:
|
for message_id, refusal in refusals:
|
||||||
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
||||||
self.assertEqual(refusal["messageId"], message_id)
|
self.assertEqual(refusal["messageId"], message_id)
|
||||||
self.assertEqual(refusal["code"], "message_invalid", message_id)
|
self.assertEqual(refusal["code"], "malformed_document_put", message_id)
|
||||||
self.assertEqual(refusal["details"]["field"], "document", message_id)
|
|
||||||
self.assertEqual(after["messageId"], "after")
|
self.assertEqual(after["messageId"], "after")
|
||||||
self.assertEqual(after["documents"], [])
|
self.assertEqual(after["documents"], [])
|
||||||
self.assertTrue(alive)
|
self.assertTrue(alive)
|
||||||
|
|
||||||
def _rudp_open(self, server: Any) -> Any:
|
|
||||||
return create_rudp_schema_transport(
|
|
||||||
host="127.0.0.1",
|
|
||||||
port=server.port,
|
|
||||||
connection_id=server.rudp_connection_id,
|
|
||||||
timeout_seconds=4.0,
|
|
||||||
max_fragment_bytes=1200,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _rudp_answer(self, transport: Any, payload: bytes) -> dict[str, Any]:
|
|
||||||
transport.send("schema", payload)
|
|
||||||
return msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
|
||||||
|
|
||||||
def _assert_rudp_session_ended(self, transport: Any, server: Any) -> None:
|
|
||||||
transport.send("schema", msgpack.packb(snapshot_request(message_id="after-cap").to_wire(), use_bin_type=True))
|
|
||||||
with self.assertRaises(Exception):
|
|
||||||
transport.receive(timeout_seconds=1.0)
|
|
||||||
self.assertTrue(server._rudp_thread.is_alive())
|
|
||||||
|
|
||||||
def test_cultmesh_local_server_answers_unreadable_frames_and_ends_the_session_at_eight_in_a_row(self) -> None:
|
|
||||||
# An undecodable frame (not a msgpack map) is answered message_invalid and the session kept;
|
|
||||||
# eight in a row end it (the eighth is answered too); any decoded frame resets the run.
|
|
||||||
self.assertEqual(MAX_CONSECUTIVE_REFUSED_FRAMES, 8)
|
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-unreadable")
|
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
|
||||||
garbage = b"\xc1"
|
|
||||||
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
|
|
||||||
transport = self._rudp_open(server)
|
|
||||||
try:
|
|
||||||
for _ in range(2):
|
|
||||||
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
|
|
||||||
reply = self._rudp_answer(transport, garbage)
|
|
||||||
self.assertEqual(reply["code"], "message_invalid")
|
|
||||||
self.assertEqual(reply["details"]["field"], "message")
|
|
||||||
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
|
|
||||||
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
|
|
||||||
self._rudp_answer(transport, garbage)
|
|
||||||
transport.send("schema", garbage)
|
|
||||||
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
|
||||||
self.assertEqual(eighth["code"], "message_invalid")
|
|
||||||
self._assert_rudp_session_ended(transport, server)
|
|
||||||
finally:
|
|
||||||
transport.close()
|
|
||||||
server.stop()
|
|
||||||
|
|
||||||
def test_cultmesh_local_server_resends_a_lost_capped_answer_before_it_ends_the_session(self) -> None:
|
|
||||||
# The eighth refusal is answered reliably: the datagram carrying it is lost on the wire, and the
|
|
||||||
# peer still gets the answer (by resend) before the goodbye. An unreliable final answer, or a
|
|
||||||
# goodbye sent at once, would leave the peer without it.
|
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-lost-answer")
|
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
|
||||||
send = server._send_rudp_packet
|
|
||||||
dropped: list[Any] = []
|
|
||||||
armed = threading.Event()
|
|
||||||
|
|
||||||
def lossy(rudp_socket: Any, remote_addr: Any, packet: Any) -> Any:
|
|
||||||
if armed.is_set() and not dropped and packet.packet_type == CultNetRudpPacketType.DATA and b"message_invalid" in (packet.payload or b""):
|
|
||||||
dropped.append(packet)
|
|
||||||
return None
|
|
||||||
return send(rudp_socket, remote_addr, packet)
|
|
||||||
|
|
||||||
server._send_rudp_packet = lossy
|
|
||||||
transport = self._rudp_open(server)
|
|
||||||
try:
|
|
||||||
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
|
|
||||||
self._rudp_answer(transport, b"\xc1")
|
|
||||||
armed.set()
|
|
||||||
reply = self._rudp_answer(transport, b"\xc1")
|
|
||||||
self.assertEqual(len(dropped), 1, "fixture: the first transmission of the last answer was lost")
|
|
||||||
self.assertEqual(reply["code"], "message_invalid")
|
|
||||||
self._assert_rudp_session_ended(transport, server)
|
|
||||||
finally:
|
|
||||||
transport.close()
|
|
||||||
server.stop()
|
|
||||||
|
|
||||||
def test_cultmesh_local_server_serves_nothing_after_the_capped_frame(self) -> None:
|
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-after-cap")
|
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
|
||||||
transport = self._rudp_open(server)
|
|
||||||
try:
|
|
||||||
for _ in range(MAX_CONSECUTIVE_REFUSED_FRAMES - 1):
|
|
||||||
self._rudp_answer(transport, b"\xc1")
|
|
||||||
transport.send("schema", b"\xc1")
|
|
||||||
transport.send("schema", msgpack.packb(snapshot_request(message_id="behind-the-cap").to_wire(), use_bin_type=True))
|
|
||||||
first = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
|
||||||
self.assertEqual(first["code"], "message_invalid")
|
|
||||||
with self.assertRaises(Exception):
|
|
||||||
transport.receive(timeout_seconds=1.0)
|
|
||||||
finally:
|
|
||||||
transport.close()
|
|
||||||
server.stop()
|
|
||||||
|
|
||||||
def test_cultmesh_local_server_ends_the_session_at_eight_refused_deletes_in_a_row(self) -> None:
|
|
||||||
class GrantOnlyKeyB:
|
|
||||||
def decide(self, request: Any) -> Any:
|
|
||||||
return CultMeshRemoteWriteDecision.grant() if request.record_key == "note:b" else CultMeshRemoteWriteDecision.refuse("no")
|
|
||||||
|
|
||||||
document = self._rudp_bound_note()
|
|
||||||
node = CultMesh.create_node(runtime_id="mesh-rudp-deletes")
|
|
||||||
node.database.register_document(document)
|
|
||||||
server = CultMesh.serve_node(node, write_authority=GrantOnlyKeyB())
|
|
||||||
|
|
||||||
def delete(key: str) -> bytes:
|
|
||||||
return msgpack.packb({
|
|
||||||
"schemaVersion": "cultnet.document_delete.v0",
|
|
||||||
"messageId": f"del-{key}",
|
|
||||||
"schemaId": document.schema_id,
|
|
||||||
"recordKey": key,
|
|
||||||
}, use_bin_type=True)
|
|
||||||
|
|
||||||
snapshot = msgpack.packb(snapshot_request(message_id="ok").to_wire(), use_bin_type=True)
|
|
||||||
transport = self._rudp_open(server)
|
|
||||||
try:
|
|
||||||
def refused_run(count: int) -> None:
|
|
||||||
for _ in range(count):
|
|
||||||
reply = self._rudp_answer(transport, delete("note:a"))
|
|
||||||
self.assertEqual(reply["code"], "write_not_authorized")
|
|
||||||
self.assertEqual(reply["details"]["value"], "note:a")
|
|
||||||
|
|
||||||
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
|
|
||||||
# A granted delete resets the run (nothing is stored under it, so the store answers that).
|
|
||||||
self.assertEqual(self._rudp_answer(transport, delete("note:b"))["code"], "unregistered_document_delete")
|
|
||||||
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
|
|
||||||
# So does any decoded non-delete frame.
|
|
||||||
self.assertEqual(self._rudp_answer(transport, snapshot)["messageId"], "ok")
|
|
||||||
refused_run(MAX_CONSECUTIVE_REFUSED_FRAMES - 1)
|
|
||||||
transport.send("schema", delete("note:a"))
|
|
||||||
eighth = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
|
||||||
self.assertEqual(eighth["code"], "write_not_authorized")
|
|
||||||
self._assert_rudp_session_ended(transport, server)
|
|
||||||
finally:
|
|
||||||
transport.close()
|
|
||||||
server.stop()
|
|
||||||
|
|
||||||
def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None:
|
def test_cultmesh_local_server_refuses_a_put_its_store_cannot_take_and_serves_on(self) -> None:
|
||||||
# Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a
|
# Well-formed puts the store refuses: a blank key, a payload that is not MessagePack, a
|
||||||
# payload its schema cannot decode, a global document under another key. Each is refused
|
# payload its schema cannot decode, a global document under another key. Each is refused
|
||||||
|
|
@ -5057,7 +4917,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
node = CultMesh.create_node(runtime_id="mesh-unstorable-put")
|
node = CultMesh.create_node(runtime_id="mesh-unstorable-put")
|
||||||
node.database.register_document(document)
|
node.database.register_document(document)
|
||||||
node.database.register_document(global_document)
|
node.database.register_document(global_document)
|
||||||
server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites())
|
server = CultMesh.serve_node(node)
|
||||||
try:
|
try:
|
||||||
rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()]
|
rudp_refusals = [(put["messageId"], self._rudp_exchange(server, put)[0]) for put in cases()]
|
||||||
rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire())
|
rudp_after, _ = self._rudp_exchange(server, hello(runtime_id="rudp-after").to_wire())
|
||||||
|
|
@ -5079,8 +4939,7 @@ class CultMeshTests(unittest.TestCase):
|
||||||
for message_id, refusal in rudp_refusals + tcp_refusals:
|
for message_id, refusal in rudp_refusals + tcp_refusals:
|
||||||
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id)
|
||||||
self.assertEqual(refusal["messageId"], message_id)
|
self.assertEqual(refusal["messageId"], message_id)
|
||||||
self.assertEqual(refusal["code"], "message_invalid", message_id)
|
self.assertEqual(refusal["code"], "malformed_document_put", message_id)
|
||||||
self.assertEqual(refusal["details"]["field"], "document", message_id)
|
|
||||||
self.assertNotIn("note:ok", refusal["error"], message_id)
|
self.assertNotIn("note:ok", refusal["error"], message_id)
|
||||||
self.assertEqual(len(tcp_refusals), 5)
|
self.assertEqual(len(tcp_refusals), 5)
|
||||||
self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0")
|
self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0")
|
||||||
|
|
@ -5194,11 +5053,9 @@ class CultMeshTests(unittest.TestCase):
|
||||||
self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0")
|
self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0")
|
||||||
self.assertIn("unsupported_schema_version", unsupported["error"])
|
self.assertIn("unsupported_schema_version", unsupported["error"])
|
||||||
self.assertEqual(malformed_put["messageId"], "bad-put")
|
self.assertEqual(malformed_put["messageId"], "bad-put")
|
||||||
self.assertEqual(malformed_put["code"], "message_invalid")
|
self.assertEqual(malformed_put["code"], "malformed_document_put")
|
||||||
self.assertEqual(malformed_put["details"]["field"], "document")
|
|
||||||
self.assertEqual(malformed_delete["messageId"], "bad-delete")
|
self.assertEqual(malformed_delete["messageId"], "bad-delete")
|
||||||
self.assertEqual(malformed_delete["code"], "message_invalid")
|
self.assertEqual(malformed_delete["code"], "malformed_document_delete")
|
||||||
self.assertEqual(malformed_delete["details"]["field"], "recordKey")
|
|
||||||
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
|
self.assertEqual(unsupported_v1["code"], "unsupported_schema_version")
|
||||||
self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1")
|
self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
"""A peer's put or delete is applied only when the server's write authority grants it.
|
|
||||||
|
|
||||||
Ports the behaviours of C# RemoteWriteAuthorityTests. The C# forwarding behaviour (a refused write is never
|
|
||||||
forwarded) has no counterpart: this server does not forward non-primary writes. The handlers are reached with
|
|
||||||
the peer the connection loop would pass, and one test drives the same path over a real TCP connection.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import msgpack
|
|
||||||
from cultcache_py import define_database_entry_type
|
|
||||||
from cultmesh_py import (
|
|
||||||
CultMesh,
|
|
||||||
CultMeshGrantAllWrites,
|
|
||||||
CultMeshRemoteWriteDecision,
|
|
||||||
CultMeshRemoteWriteOperation,
|
|
||||||
CultMeshRemoteWriteRequest,
|
|
||||||
CultMeshServerPeer,
|
|
||||||
create_node,
|
|
||||||
)
|
|
||||||
from cultmesh_py.server import CultMeshLocalServer
|
|
||||||
from cultnet_py import CultNetPeerError, create_rudp_schema_transport, document_delete, document_put_raw, error_message
|
|
||||||
|
|
||||||
SCHEMA_ID = "sha256:authority-note"
|
|
||||||
ALIAS = "runtime.generated.authority-note.ui.42"
|
|
||||||
SHARD_ID = "authority-shard"
|
|
||||||
KEY_A = "tests:authority:a"
|
|
||||||
KEY_B = "tests:authority:b"
|
|
||||||
PEER = CultMeshServerPeer("127.0.0.1:50000", "tcp")
|
|
||||||
|
|
||||||
_REPO = Path(__file__).resolve().parents[3]
|
|
||||||
_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"]
|
|
||||||
|
|
||||||
NOTE = define_database_entry_type(
|
|
||||||
"tests.authority_note",
|
|
||||||
[("schema_version", 0), ("text", 1)],
|
|
||||||
schema_id=SCHEMA_ID,
|
|
||||||
schema_name="tests.authority_note",
|
|
||||||
schema_version="tests.authority_note.v1",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Recording:
|
|
||||||
def __init__(self, decide: Any) -> None:
|
|
||||||
self._decide = decide
|
|
||||||
self.requests: list[CultMeshRemoteWriteRequest] = []
|
|
||||||
|
|
||||||
def decide(self, request: CultMeshRemoteWriteRequest) -> Any:
|
|
||||||
self.requests.append(request)
|
|
||||||
return self._decide(request)
|
|
||||||
|
|
||||||
|
|
||||||
def put_of(key: str, text: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]:
|
|
||||||
return document_put_raw(
|
|
||||||
message_id="wire",
|
|
||||||
key=key,
|
|
||||||
schema_id=schema_id,
|
|
||||||
stored_at="2026-11-07T00:00:00Z",
|
|
||||||
payload=NOTE.encode_payload({"schema_version": "tests.authority_note.v1", "text": text}),
|
|
||||||
shard_id=SHARD_ID,
|
|
||||||
shard_epoch=1,
|
|
||||||
).to_wire()
|
|
||||||
|
|
||||||
|
|
||||||
def delete_of(key: str, *, schema_id: str = SCHEMA_ID) -> dict[str, Any]:
|
|
||||||
return document_delete(
|
|
||||||
message_id="wire-delete", schema_id=schema_id, record_key=key, shard_id=SHARD_ID, shard_epoch=1
|
|
||||||
).to_wire()
|
|
||||||
|
|
||||||
|
|
||||||
class RemoteWriteAuthorityTests(unittest.TestCase):
|
|
||||||
def build(self, authority: Any = None) -> CultMeshLocalServer:
|
|
||||||
node = create_node(runtime_id="authority-server")
|
|
||||||
node.database.register_document(NOTE)
|
|
||||||
return CultMeshLocalServer(node=node, port=0, write_authority=authority)
|
|
||||||
|
|
||||||
def text_of(self, server: CultMeshLocalServer, key: str) -> str | None:
|
|
||||||
value = server.node.cache.get(NOTE, key)
|
|
||||||
return None if value is None else value["text"]
|
|
||||||
|
|
||||||
def send(self, server: CultMeshLocalServer, message: dict[str, Any]) -> list[dict[str, Any]]:
|
|
||||||
subscriptions: dict[str, Any] = {}
|
|
||||||
return server._handle_connection_message(message, subscriptions, peer=PEER)
|
|
||||||
|
|
||||||
def assert_refused(self, replies: list[dict[str, Any]], key: str, *, error: str | None = None) -> None:
|
|
||||||
self.assertEqual(len(replies), 1)
|
|
||||||
reply = replies[0]
|
|
||||||
self.assertEqual(reply["schemaVersion"], "cultnet.error.v0")
|
|
||||||
self.assertEqual(reply["code"], "write_not_authorized")
|
|
||||||
self.assertTrue(reply["error"].startswith("write_not_authorized"))
|
|
||||||
self.assertEqual(reply["details"]["field"], "recordKey")
|
|
||||||
self.assertEqual(reply["details"]["value"], key)
|
|
||||||
if error is not None:
|
|
||||||
self.assertEqual(reply["error"], error)
|
|
||||||
|
|
||||||
def seed(self, server: CultMeshLocalServer, key: str, text: str) -> None:
|
|
||||||
# The host writes its own record through its own door, which asks no remote authority.
|
|
||||||
self.assertEqual(server.node.database.apply_raw_put_message(put_of(key, text)).change_kind, "added")
|
|
||||||
|
|
||||||
def test_with_no_authority_a_peers_put_and_delete_are_refused_and_change_nothing(self) -> None:
|
|
||||||
server = self.build(None)
|
|
||||||
self.seed(server, KEY_A, "server")
|
|
||||||
|
|
||||||
with self.assertLogs("cultmesh_py.server", level="WARNING") as logs:
|
|
||||||
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
|
|
||||||
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
|
|
||||||
self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B)
|
|
||||||
|
|
||||||
self.assertEqual(self.text_of(server, KEY_A), "server")
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_B))
|
|
||||||
self.assertEqual(sum("write not authorized" in line for line in logs.output), 3)
|
|
||||||
|
|
||||||
def test_a_refusal_publishes_no_change_to_a_subscriber(self) -> None:
|
|
||||||
server = self.build(None)
|
|
||||||
server.start()
|
|
||||||
try:
|
|
||||||
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
||||||
with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub:
|
|
||||||
with self.assertRaises(CultNetPeerError) as raised:
|
|
||||||
sub.send(put_of(KEY_A, "peer"))
|
|
||||||
sub.read_next()
|
|
||||||
finally:
|
|
||||||
server.stop()
|
|
||||||
self.assertEqual(raised.exception.response["code"], "write_not_authorized")
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_A))
|
|
||||||
|
|
||||||
def test_a_granted_put_and_delete_commit_and_the_authority_saw_this_peer(self) -> None:
|
|
||||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
|
|
||||||
server = self.build(authority)
|
|
||||||
|
|
||||||
put_replies = self.send(server, put_of(KEY_A, "peer"))
|
|
||||||
self.assertEqual(self.text_of(server, KEY_A), "peer")
|
|
||||||
self.assertEqual(put_replies, [])
|
|
||||||
self.send(server, delete_of(KEY_A))
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_A))
|
|
||||||
|
|
||||||
self.assertEqual(len(authority.requests), 2)
|
|
||||||
self.assertTrue(all(request.peer is PEER for request in authority.requests))
|
|
||||||
|
|
||||||
def test_the_request_carries_the_resolved_write_and_scoped_grants_refuse_their_neighbours(self) -> None:
|
|
||||||
authority = Recording(
|
|
||||||
lambda request: CultMeshRemoteWriteDecision.grant()
|
|
||||||
if request.operation is CultMeshRemoteWriteOperation.PUT and request.record_key == KEY_A
|
|
||||||
else CultMeshRemoteWriteDecision.refuse("scoped")
|
|
||||||
)
|
|
||||||
server = self.build(authority)
|
|
||||||
self.seed(server, KEY_A, "server")
|
|
||||||
|
|
||||||
self.send(server, put_of(KEY_A, "aliased", schema_id=ALIAS))
|
|
||||||
|
|
||||||
request = authority.requests[0]
|
|
||||||
self.assertEqual(request.schema_id, SCHEMA_ID, "the authority sees the canonical id, not the alias as sent")
|
|
||||||
self.assertIs(request.operation, CultMeshRemoteWriteOperation.PUT)
|
|
||||||
self.assertEqual(request.record_key, KEY_A)
|
|
||||||
self.assertEqual(request.shard_id, SHARD_ID)
|
|
||||||
self.assertEqual(request.shard_epoch, 1)
|
|
||||||
self.assertEqual(self.text_of(server, KEY_A), "aliased")
|
|
||||||
|
|
||||||
self.assert_refused(self.send(server, put_of(KEY_B, "other-key")), KEY_B)
|
|
||||||
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_B), "a grant scoped to key a refuses key b")
|
|
||||||
self.assertEqual(self.text_of(server, KEY_A), "aliased", "a grant scoped to put refuses delete")
|
|
||||||
self.assertIs(authority.requests[-1].operation, CultMeshRemoteWriteOperation.DELETE)
|
|
||||||
self.assertEqual(authority.requests[-1].schema_id, SCHEMA_ID)
|
|
||||||
|
|
||||||
def test_refusal_precedes_every_other_judgment_of_the_put(self) -> None:
|
|
||||||
refusing = self.build(None)
|
|
||||||
unowned = put_of("unrelated:authority", "x", schema_id="sha256:nobody-owns-this")
|
|
||||||
unowned["document"]["payload"] = msgpack.packb(["nobody.owns.this.v1", "x"], use_bin_type=True)
|
|
||||||
self.assert_refused(self.send(refusing, unowned), "unrelated:authority")
|
|
||||||
blank_key = put_of("", "x")
|
|
||||||
self.assert_refused(self.send(refusing, blank_key), "")
|
|
||||||
|
|
||||||
granting = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.grant()))
|
|
||||||
reply = self.send(granting, unowned)
|
|
||||||
self.assertEqual(reply[0]["code"], "unregistered_document_put", "a granted put still meets the store's refusal")
|
|
||||||
|
|
||||||
def test_an_authority_that_raises_refuses_and_is_logged_as_an_error(self) -> None:
|
|
||||||
def explode(_: Any) -> Any:
|
|
||||||
raise RuntimeError("authority exploded")
|
|
||||||
|
|
||||||
server = self.build(Recording(explode))
|
|
||||||
self.seed(server, KEY_A, "server")
|
|
||||||
|
|
||||||
with self.assertLogs("cultmesh_py.server", level="ERROR") as logs:
|
|
||||||
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
|
|
||||||
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
|
|
||||||
|
|
||||||
self.assertEqual(self.text_of(server, KEY_A), "server", "fail closed")
|
|
||||||
self.assertTrue(any("RuntimeError" in line for line in logs.output))
|
|
||||||
|
|
||||||
def test_a_refusal_with_no_reason_still_says_why(self) -> None:
|
|
||||||
server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse()))
|
|
||||||
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A, error="write_not_authorized")
|
|
||||||
|
|
||||||
def test_the_refusal_names_the_key_and_the_code_in_the_pinned_bytes(self) -> None:
|
|
||||||
vector = next(v for v in _VECTORS if v["name"] == "write_not_authorized")
|
|
||||||
message = error_message(
|
|
||||||
"write_not_authorized: not granted",
|
|
||||||
code="write_not_authorized",
|
|
||||||
details={"field": "recordKey", "value": "some:key"},
|
|
||||||
)
|
|
||||||
self.assertEqual(message.to_bytes(), bytes.fromhex(vector["messagePackHex"]))
|
|
||||||
|
|
||||||
server = self.build(Recording(lambda _: CultMeshRemoteWriteDecision.refuse("not granted")))
|
|
||||||
reply = self.send(server, put_of("some:key", "peer"))[0]
|
|
||||||
self.assertEqual(msgpack.packb(reply, use_bin_type=True), bytes.fromhex(vector["messagePackHex"]))
|
|
||||||
|
|
||||||
def test_the_peer_receives_the_typed_refusal_over_tcp_and_a_grant_sends_nothing_back(self) -> None:
|
|
||||||
authority = Recording(
|
|
||||||
lambda request: CultMeshRemoteWriteDecision.grant()
|
|
||||||
if request.record_key == KEY_A
|
|
||||||
else CultMeshRemoteWriteDecision.refuse("only a")
|
|
||||||
)
|
|
||||||
server = self.build(authority)
|
|
||||||
server.start()
|
|
||||||
try:
|
|
||||||
client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0)
|
|
||||||
with client.subscribe_database(subscription_id="s", schema_ids=[SCHEMA_ID], include_snapshot=False) as sub:
|
|
||||||
sub.send(put_of(KEY_A, "peer"))
|
|
||||||
self.assertEqual(sub.read_next_change().record_key, KEY_A, "the granted write was answered by its change, not an error")
|
|
||||||
sub.send(put_of(KEY_B, "peer"))
|
|
||||||
with self.assertRaises(CultNetPeerError) as raised:
|
|
||||||
sub.read_next()
|
|
||||||
finally:
|
|
||||||
server.stop()
|
|
||||||
|
|
||||||
reply = raised.exception.response
|
|
||||||
self.assertEqual(reply["error"], "write_not_authorized: only a")
|
|
||||||
self.assertEqual(reply["details"]["value"], KEY_B)
|
|
||||||
self.assertTrue(authority.requests[0].peer.endpoint.startswith("127.0.0.1:"))
|
|
||||||
self.assertEqual(authority.requests[0].peer.transport, "tcp")
|
|
||||||
self.assertEqual(self.text_of(server, KEY_A), "peer")
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_B))
|
|
||||||
|
|
||||||
def test_a_delete_sent_under_a_schema_alias_asks_the_authority_with_the_canonical_id(self) -> None:
|
|
||||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
|
|
||||||
server = self.build(authority)
|
|
||||||
self.seed(server, KEY_A, "server")
|
|
||||||
|
|
||||||
self.send(server, delete_of(KEY_A, schema_id="tests.authority_note.v1"))
|
|
||||||
|
|
||||||
request = authority.requests[0]
|
|
||||||
self.assertIs(request.operation, CultMeshRemoteWriteOperation.DELETE)
|
|
||||||
self.assertEqual(request.schema_id, SCHEMA_ID)
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_A), "the aliased delete was granted and applied")
|
|
||||||
|
|
||||||
def test_a_missing_decision_refuses_put_and_delete(self) -> None:
|
|
||||||
server = self.build(Recording(lambda _: None))
|
|
||||||
self.seed(server, KEY_A, "server")
|
|
||||||
|
|
||||||
self.assert_refused(self.send(server, put_of(KEY_A, "peer")), KEY_A)
|
|
||||||
self.assert_refused(self.send(server, delete_of(KEY_A)), KEY_A)
|
|
||||||
self.assert_refused(self.send(server, put_of(KEY_B, "peer")), KEY_B)
|
|
||||||
|
|
||||||
self.assertEqual(self.text_of(server, KEY_A), "server")
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_B))
|
|
||||||
|
|
||||||
def test_a_peer_over_rudp_is_refused_with_its_rudp_endpoint_and_the_server_serves_on(self) -> None:
|
|
||||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.refuse("rudp no"))
|
|
||||||
server = self.build(authority)
|
|
||||||
server.start()
|
|
||||||
try:
|
|
||||||
transport = create_rudp_schema_transport(
|
|
||||||
host="127.0.0.1", port=server.port, connection_id=server.rudp_connection_id, timeout_seconds=4.0
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
transport.send("schema", msgpack.packb(put_of(KEY_A, "peer"), use_bin_type=True))
|
|
||||||
reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
|
||||||
transport.send("schema", msgpack.packb(delete_of(KEY_A), use_bin_type=True))
|
|
||||||
delete_reply = msgpack.unpackb(transport.receive(timeout_seconds=4.0).payload, raw=False)
|
|
||||||
finally:
|
|
||||||
transport.close()
|
|
||||||
finally:
|
|
||||||
server.stop()
|
|
||||||
self.assertEqual(reply["error"], "write_not_authorized: rudp no")
|
|
||||||
self.assertEqual(delete_reply["code"], "write_not_authorized")
|
|
||||||
self.assertEqual([r.peer.transport for r in authority.requests], ["rudp", "rudp"])
|
|
||||||
self.assertTrue(all(r.peer.endpoint.startswith("127.0.0.1:") for r in authority.requests))
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_A))
|
|
||||||
|
|
||||||
def test_a_put_with_an_absurd_payload_is_refused_by_the_gate_not_crashed_on(self) -> None:
|
|
||||||
server = self.build(None)
|
|
||||||
absurd = put_of(KEY_A, "x")
|
|
||||||
absurd["document"]["payload"] = 2**70
|
|
||||||
self.assert_refused(self.send(server, absurd), KEY_A)
|
|
||||||
|
|
||||||
def test_an_ungranted_peers_huge_payload_is_refused_without_allocating_it(self) -> None:
|
|
||||||
import tracemalloc
|
|
||||||
|
|
||||||
server = self.build(None)
|
|
||||||
huge = put_of(KEY_A, "x")
|
|
||||||
huge["document"]["payload"] = 10**9 # bytes(10**9) would allocate a gigabyte
|
|
||||||
tracemalloc.start()
|
|
||||||
try:
|
|
||||||
replies = self.send(server, huge)
|
|
||||||
_, peak = tracemalloc.get_traced_memory()
|
|
||||||
finally:
|
|
||||||
tracemalloc.stop()
|
|
||||||
self.assert_refused(replies, KEY_A)
|
|
||||||
self.assertLess(peak, 10_000_000)
|
|
||||||
|
|
||||||
def test_wrong_typed_shard_fields_are_refused_before_the_gate_and_write_nothing(self) -> None:
|
|
||||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
|
|
||||||
server = self.build(authority)
|
|
||||||
self.seed(server, KEY_B, "server")
|
|
||||||
shards_before = server.node.database.shard_ids()
|
|
||||||
for field, value in (("shardId", {"not": "a string"}), ("shardId", 5), ("shardEpoch", "7"), ("shardEpoch", True), ("shardEpoch", 1.5)):
|
|
||||||
for operation in ("put", "delete"):
|
|
||||||
with self.subTest(field=field, value=value, operation=operation):
|
|
||||||
message = put_of(KEY_A, "x") if operation == "put" else delete_of(KEY_B)
|
|
||||||
message[field] = value
|
|
||||||
replies = self.send(server, message)
|
|
||||||
self.assertEqual([(reply["code"], reply["details"]["field"]) for reply in replies], [("message_invalid", field)])
|
|
||||||
self.assertEqual(authority.requests, [], "the authority was never asked")
|
|
||||||
self.assertIsNone(self.text_of(server, KEY_A), "nothing was written")
|
|
||||||
self.assertEqual(self.text_of(server, KEY_B), "server", "nothing was deleted")
|
|
||||||
self.assertEqual(server.node.database.shard_ids(), shards_before, "no shard or log entry was filed")
|
|
||||||
|
|
||||||
def test_well_typed_shard_fields_reach_the_authority_as_sent(self) -> None:
|
|
||||||
authority = Recording(lambda _: CultMeshRemoteWriteDecision.grant())
|
|
||||||
server = self.build(authority)
|
|
||||||
self.send(server, put_of(KEY_A, "x"))
|
|
||||||
self.assertEqual((authority.requests[0].shard_id, authority.requests[0].shard_epoch), (SHARD_ID, 1))
|
|
||||||
|
|
||||||
def test_grant_all_is_an_explicit_choice_that_grants_every_write(self) -> None:
|
|
||||||
server = self.build(CultMeshGrantAllWrites())
|
|
||||||
self.send(server, put_of(KEY_A, "peer"))
|
|
||||||
self.assertEqual(self.text_of(server, KEY_A), "peer")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -10,7 +10,7 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
import msgpack
|
import msgpack
|
||||||
from cultcache_py import define_database_entry_type
|
from cultcache_py import define_database_entry_type
|
||||||
from cultmesh_py import CultMeshGrantAllWrites, create_node
|
from cultmesh_py import create_node
|
||||||
from cultmesh_py.server import CultMeshLocalServer
|
from cultmesh_py.server import CultMeshLocalServer
|
||||||
from cultnet_py import (
|
from cultnet_py import (
|
||||||
CultNetRudpSendOptions,
|
CultNetRudpSendOptions,
|
||||||
|
|
@ -49,7 +49,7 @@ class RudpServerGenerationTests(unittest.TestCase):
|
||||||
node = create_node(runtime_id="rudp-generation")
|
node = create_node(runtime_id="rudp-generation")
|
||||||
node.database.register_document(NOTE)
|
node.database.register_document(NOTE)
|
||||||
# Replies stay whole datagrams here, so each server packet the pump reads is one message.
|
# Replies stay whole datagrams here, so each server packet the pump reads is one message.
|
||||||
self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000, write_authority=CultMeshGrantAllWrites())
|
self.server = CultMeshLocalServer(node=node, rudp_connection_id=CONNECTION_ID, rudp_max_fragment_bytes=60_000)
|
||||||
self.server.start()
|
self.server.start()
|
||||||
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
self.sock.bind(("127.0.0.1", 0))
|
self.sock.bind(("127.0.0.1", 0))
|
||||||
|
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
"""A server asked for any port finds a TCP+UDP pair; one asked for a port gets that port or an honest failure."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import errno
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import unittest
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from cultmesh_py import create_node
|
|
||||||
from cultmesh_py.server import CultMeshLocalServer
|
|
||||||
|
|
||||||
|
|
||||||
def server(**kwargs) -> CultMeshLocalServer:
|
|
||||||
return CultMeshLocalServer(node=create_node(runtime_id="bind-test"), host="127.0.0.1", **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class ServerBindTests(unittest.TestCase):
|
|
||||||
def test_port_zero_retries_with_a_fresh_port_when_another_process_holds_the_udp_number(self) -> None:
|
|
||||||
real_bind = socket.socket.bind
|
|
||||||
refused: list[int] = []
|
|
||||||
|
|
||||||
def bind(sock: socket.socket, address) -> None:
|
|
||||||
# The first two UDP binds find the number taken, as a UDP listener of another process would.
|
|
||||||
if sock.type == socket.SOCK_DGRAM and len(refused) < 2:
|
|
||||||
refused.append(address[1])
|
|
||||||
raise OSError(errno.EADDRINUSE, "Address already in use")
|
|
||||||
real_bind(sock, address)
|
|
||||||
|
|
||||||
with patch.object(socket.socket, "bind", bind):
|
|
||||||
started = server().start()
|
|
||||||
try:
|
|
||||||
self.assertEqual(len(refused), 2)
|
|
||||||
self.assertNotIn(started.port, refused[:1], "the retry used a fresh TCP port")
|
|
||||||
probe = socket.create_connection(("127.0.0.1", started.port), timeout=2.0)
|
|
||||||
probe.close()
|
|
||||||
finally:
|
|
||||||
started.stop()
|
|
||||||
|
|
||||||
def test_an_explicit_port_whose_udp_number_is_taken_fails_and_leaves_nothing_running(self) -> None:
|
|
||||||
holder = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
||||||
holder.bind(("127.0.0.1", 0))
|
|
||||||
port = holder.getsockname()[1]
|
|
||||||
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
try:
|
|
||||||
free.bind(("127.0.0.1", port)) # the TCP number is free: only the UDP one is taken
|
|
||||||
finally:
|
|
||||||
free.close()
|
|
||||||
threads_before = threading.active_count()
|
|
||||||
try:
|
|
||||||
failing = server(port=port)
|
|
||||||
with self.assertRaises(OSError) as raised:
|
|
||||||
failing.start()
|
|
||||||
self.assertEqual(raised.exception.errno, errno.EADDRINUSE)
|
|
||||||
self.assertIsNone(failing._socket)
|
|
||||||
self.assertEqual(threading.active_count(), threads_before, "no accept thread was left running")
|
|
||||||
# The TCP number was released, not left listening.
|
|
||||||
free = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
try:
|
|
||||||
free.bind(("127.0.0.1", port))
|
|
||||||
finally:
|
|
||||||
free.close()
|
|
||||||
finally:
|
|
||||||
holder.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -82,10 +82,7 @@ pub struct CultMeshRudpDocumentPublishOptions {
|
||||||
pub target: SocketAddr,
|
pub target: SocketAddr,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub connect_timeout: Duration,
|
pub connect_timeout: Duration,
|
||||||
/// How long the acknowledgement of every put may take.
|
|
||||||
pub flush_timeout: Duration,
|
pub flush_timeout: Duration,
|
||||||
/// How long after the acknowledgement a refusal may still arrive; a success frame ends the wait early.
|
|
||||||
pub result_window: Duration,
|
|
||||||
pub poll_interval: Duration,
|
pub poll_interval: Duration,
|
||||||
pub resend_delay_ms: u64,
|
pub resend_delay_ms: u64,
|
||||||
pub source_agent_id: Option<String>,
|
pub source_agent_id: Option<String>,
|
||||||
|
|
@ -152,7 +149,6 @@ impl Default for CultMeshRudpDocumentPublishOptions {
|
||||||
runtime_id: "cultmesh-rudp-document-publisher".to_string(),
|
runtime_id: "cultmesh-rudp-document-publisher".to_string(),
|
||||||
connect_timeout: Duration::from_secs(3),
|
connect_timeout: Duration::from_secs(3),
|
||||||
flush_timeout: Duration::from_millis(300),
|
flush_timeout: Duration::from_millis(300),
|
||||||
result_window: Duration::from_millis(500),
|
|
||||||
poll_interval: Duration::from_millis(10),
|
poll_interval: Duration::from_millis(10),
|
||||||
resend_delay_ms: 50,
|
resend_delay_ms: 50,
|
||||||
source_agent_id: None,
|
source_agent_id: None,
|
||||||
|
|
@ -869,7 +865,6 @@ pub fn request_raw_snapshot_from_rudp_catalog(
|
||||||
/// fixed refusals a CultMesh catalog sends; any other text could carry
|
/// fixed refusals a CultMesh catalog sends; any other text could carry
|
||||||
/// anything, so it is left out.
|
/// anything, so it is left out.
|
||||||
fn recognised_refusal(text: &str) -> String {
|
fn recognised_refusal(text: &str) -> String {
|
||||||
let text = text.strip_prefix("write_not_authorized: ").unwrap_or(text);
|
|
||||||
if REFUSAL_TEXTS.contains(&text) {
|
if REFUSAL_TEXTS.contains(&text) {
|
||||||
format!(": {text}")
|
format!(": {text}")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -886,30 +881,77 @@ fn unix_millis() -> u128 {
|
||||||
|
|
||||||
/// Publish one raw CultNet schema message to a RUDP catalog.
|
/// Publish one raw CultNet schema message to a RUDP catalog.
|
||||||
///
|
///
|
||||||
/// A put completes on the catalog's result frame, never on the transport
|
/// Each call owns a fresh connection epoch and returns only after the catalog
|
||||||
/// acknowledgement: an ack proves delivery, not admission. A refusal (a
|
/// acknowledges reliable application admission. Against a Rust CultMesh
|
||||||
/// `cultnet.error.v0` frame) returns an error in either order against the ack,
|
/// catalog, which never acknowledges a message it refuses, a refusal is
|
||||||
/// naming the refusal when it is one of the fixed texts a catalog sends. A
|
/// returned as an error, naming the refusal when it is one of the fixed texts
|
||||||
/// catalog that admits a put sends no success frame, so after the ack the call
|
/// such a catalog sends. A catalog that acknowledges a message on receipt and
|
||||||
/// waits `result_window` for a refusal and reports the put as admitted when
|
/// refuses it afterwards (the TypeScript document server does) is reported as
|
||||||
/// none comes (interim until the catalog sends a success frame; a success
|
/// having admitted it: the acknowledgement arrives first.
|
||||||
/// frame ends the wait early). Each call owns a fresh connection epoch.
|
|
||||||
pub fn publish_cultnet_message_to_rudp_catalog(
|
pub fn publish_cultnet_message_to_rudp_catalog(
|
||||||
message: &cultnet_rs::CultNetMessage,
|
message: &cultnet_rs::CultNetMessage,
|
||||||
options: CultMeshRudpDocumentPublishOptions,
|
options: CultMeshRudpDocumentPublishOptions,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
publish_cultnet_messages_to_rudp_catalog(std::slice::from_ref(message), options)
|
if options.runtime_id.trim().is_empty() {
|
||||||
|
anyhow::bail!("runtime_id must be non-empty");
|
||||||
|
}
|
||||||
|
let mut client = connect_rudp_client(
|
||||||
|
options.target,
|
||||||
|
&options.runtime_id,
|
||||||
|
options.connect_timeout,
|
||||||
|
options.poll_interval,
|
||||||
|
options.resend_delay_ms,
|
||||||
|
"cultmesh-rudp-document-publisher",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
|
||||||
|
let receipt = client.send_reliable("schema", payload)?;
|
||||||
|
let flush_deadline = Instant::now() + options.flush_timeout;
|
||||||
|
while Instant::now() < flush_deadline {
|
||||||
|
if let Some(frame) = client.receive_once()? {
|
||||||
|
if frame.channel_id == "schema" {
|
||||||
|
if let Ok(cultnet_rs::CultNetMessage::Error { error, .. }) =
|
||||||
|
decode_cultnet_message_from_slice(
|
||||||
|
&frame.payload,
|
||||||
|
CultNetWireContract::CultNetSchemaV0,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
let _ = client.disconnect(b"document-refused".to_vec());
|
||||||
|
anyhow::bail!(
|
||||||
|
"CultMesh RUDP catalog {} refused the put{}",
|
||||||
|
options.target,
|
||||||
|
recognised_refusal(&error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match client.reliable_send_status(&receipt) {
|
||||||
|
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {
|
||||||
|
let _ = client.disconnect(b"document-acknowledged".to_vec());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
|
||||||
|
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
|
||||||
|
}
|
||||||
|
cultnet_rs::CultNetRudpReliableSendStatus::Pending => {}
|
||||||
|
}
|
||||||
|
client.poll_resends()?;
|
||||||
|
thread::sleep(options.poll_interval);
|
||||||
|
}
|
||||||
|
let _ = client.disconnect(b"document-unacknowledged".to_vec());
|
||||||
|
anyhow::bail!(
|
||||||
|
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
|
||||||
|
options.target
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publish a batch of raw CultNet schema messages to a RUDP catalog on one
|
/// Publish a batch of raw CultNet schema messages to a RUDP catalog.
|
||||||
/// connection.
|
|
||||||
///
|
///
|
||||||
/// Completes under the same rule as [`publish_cultnet_message_to_rudp_catalog`]:
|
/// Unlike [`publish_cultnet_message_to_rudp_catalog`], which polls for
|
||||||
/// every message acknowledged, then no refusal frame within `result_window`
|
/// per-message application-level acknowledgement, this sends every message on
|
||||||
/// (or one success frame per message). A refusal of any message in the batch
|
/// one connection and waits only for a transport-level `flush_reliable`. It
|
||||||
/// fails the whole call; the batch cannot hide it. The catalog's answers do not
|
/// trades per-receipt confirmation for batch throughput; callers that need a
|
||||||
/// name the message they answer, so a failed batch does not say which message
|
/// stronger delivery guarantee should use the singular publish path instead.
|
||||||
/// was refused or whether the others were admitted.
|
|
||||||
pub fn publish_cultnet_messages_to_rudp_catalog(
|
pub fn publish_cultnet_messages_to_rudp_catalog(
|
||||||
messages: &[cultnet_rs::CultNetMessage],
|
messages: &[cultnet_rs::CultNetMessage],
|
||||||
options: CultMeshRudpDocumentPublishOptions,
|
options: CultMeshRudpDocumentPublishOptions,
|
||||||
|
|
@ -926,62 +968,11 @@ pub fn publish_cultnet_messages_to_rudp_catalog(
|
||||||
"cultmesh-rudp-document-publisher",
|
"cultmesh-rudp-document-publisher",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut receipts = Vec::with_capacity(messages.len());
|
|
||||||
for message in messages {
|
for message in messages {
|
||||||
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
|
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
|
||||||
receipts.push(client.send_reliable("schema", payload)?);
|
client.send("schema", payload)?;
|
||||||
}
|
|
||||||
let ack_deadline = Instant::now() + options.flush_timeout;
|
|
||||||
let mut acknowledged_at: Option<Instant> = None;
|
|
||||||
let mut successes = 0;
|
|
||||||
loop {
|
|
||||||
while let Some(frame) = client.receive_once()? {
|
|
||||||
if frame.channel_id != "schema" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0) {
|
|
||||||
Ok(cultnet_rs::CultNetMessage::Error { error, .. }) => {
|
|
||||||
let _ = client.disconnect(b"document-refused".to_vec());
|
|
||||||
anyhow::bail!(
|
|
||||||
"CultMesh RUDP catalog {} refused the put{}",
|
|
||||||
options.target,
|
|
||||||
recognised_refusal(&error)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(_) => successes += 1,
|
|
||||||
Err(_) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if acknowledged_at.is_none() {
|
|
||||||
let mut all_acknowledged = true;
|
|
||||||
for receipt in &receipts {
|
|
||||||
match client.reliable_send_status(receipt) {
|
|
||||||
cultnet_rs::CultNetRudpReliableSendStatus::Acknowledged => {}
|
|
||||||
cultnet_rs::CultNetRudpReliableSendStatus::Invalidated => {
|
|
||||||
anyhow::bail!("CultMesh RUDP document receipt was invalidated")
|
|
||||||
}
|
|
||||||
cultnet_rs::CultNetRudpReliableSendStatus::Pending => all_acknowledged = false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if all_acknowledged {
|
|
||||||
acknowledged_at = Some(Instant::now());
|
|
||||||
} else if Instant::now() >= ack_deadline {
|
|
||||||
let _ = client.disconnect(b"document-unacknowledged".to_vec());
|
|
||||||
anyhow::bail!(
|
|
||||||
"timed out waiting for reliable acknowledgement from CultMesh RUDP catalog {}",
|
|
||||||
options.target
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(acknowledged_at) = acknowledged_at {
|
|
||||||
if successes >= messages.len() || acknowledged_at.elapsed() >= options.result_window {
|
|
||||||
let _ = client.disconnect(b"document-completed".to_vec());
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
client.poll_resends()?;
|
|
||||||
thread::sleep(options.poll_interval);
|
|
||||||
}
|
}
|
||||||
|
client.flush_reliable(options.flush_timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn connect_rudp_client(
|
fn connect_rudp_client(
|
||||||
|
|
@ -1459,141 +1450,6 @@ mod tests {
|
||||||
Ok((target, stop, catalog))
|
Ok((target, stop, catalog))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A catalog that acknowledges every reliable message it receives and refuses it with a typed
|
|
||||||
/// write_not_authorized, the acknowledgement either before the refusal or after it (the second packet
|
|
||||||
/// follows the first by 100 ms). The refusal carries no acknowledgement of its own.
|
|
||||||
/// What the stub catalog sends for a put, and in which order against the transport ack.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum CatalogReply {
|
|
||||||
RefusalThenAck,
|
|
||||||
AckThenRefusal,
|
|
||||||
AckThenSuccess,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn acknowledging_refusing_catalog(
|
|
||||||
reply: CatalogReply,
|
|
||||||
) -> Result<(
|
|
||||||
SocketAddr,
|
|
||||||
std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
||||||
thread::JoinHandle<Result<()>>,
|
|
||||||
)> {
|
|
||||||
use cultnet_rs::{
|
|
||||||
CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession,
|
|
||||||
CultNetRudpSessionOptions, decode_rudp_packet, encode_rudp_packet,
|
|
||||||
};
|
|
||||||
let socket = UdpSocket::bind("127.0.0.1:0")?;
|
|
||||||
socket.set_read_timeout(Some(Duration::from_millis(5)))?;
|
|
||||||
let target = socket.local_addr()?;
|
|
||||||
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let catalog_stop = stop.clone();
|
|
||||||
let catalog = thread::spawn(move || -> Result<()> {
|
|
||||||
let mut session: Option<CultNetRudpSession> = None;
|
|
||||||
let mut wire = vec![0_u8; 65_535];
|
|
||||||
while !catalog_stop.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
let Ok((bytes, peer)) = socket.recv_from(&mut wire) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(packet) = decode_rudp_packet(&wire[..bytes]) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if packet.packet_type == CultNetRudpPacketType::Connect {
|
|
||||||
let mut accepted = CultNetRudpSession::new(CultNetRudpSessionOptions {
|
|
||||||
connection_id: packet.connection_id,
|
|
||||||
initial_sequence: None,
|
|
||||||
resend_delay_ms: 10_000,
|
|
||||||
max_pending_reliable_packets: Some(64),
|
|
||||||
});
|
|
||||||
let accept = accepted.accept_connect(&packet, 0, Vec::new())?;
|
|
||||||
socket.send_to(&encode_rudp_packet(&accept)?, peer)?;
|
|
||||||
session = Some(accepted);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(session) = session.as_mut() else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Ok(received) = session.receive(&packet, 1) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if !packet.reliable || received.delivered.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let ack = session.create_ack_for_received(packet.sequence);
|
|
||||||
let refusal = encode_cultnet_message_to_vec(
|
|
||||||
&if reply == CatalogReply::AckThenSuccess {
|
|
||||||
cultnet_rs::CultNetMessage::SnapshotResponseRaw {
|
|
||||||
message_id: "completion".into(),
|
|
||||||
documents: Vec::new(),
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
cultnet_rs::CultNetMessage::Error {
|
|
||||||
error: "write_not_authorized: the catalog refused the document".into(),
|
|
||||||
code: Some(cultnet_rs::CultNetErrorCode::WriteNotAuthorized),
|
|
||||||
details: None,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
CultNetWireContract::CultNetSchemaV0,
|
|
||||||
)?;
|
|
||||||
let options = CultNetRudpSendOptions {
|
|
||||||
reliable: false,
|
|
||||||
ordered: false,
|
|
||||||
sequenced: false,
|
|
||||||
now_ms: 2,
|
|
||||||
reliable_expire_after_ms: None,
|
|
||||||
};
|
|
||||||
let mut refusals = session.send_many("schema", refusal, options, Some(1200))?;
|
|
||||||
for refusal in &mut refusals {
|
|
||||||
refusal.ack = 0;
|
|
||||||
refusal.ack_mask = 0;
|
|
||||||
}
|
|
||||||
let send_refusals = |socket: &UdpSocket| -> Result<()> {
|
|
||||||
for refusal in &refusals {
|
|
||||||
socket.send_to(&encode_rudp_packet(refusal)?, peer)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
};
|
|
||||||
if reply == CatalogReply::RefusalThenAck {
|
|
||||||
send_refusals(&socket)?;
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
|
||||||
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
|
|
||||||
} else {
|
|
||||||
socket.send_to(&encode_rudp_packet(&ack)?, peer)?;
|
|
||||||
thread::sleep(Duration::from_millis(100));
|
|
||||||
send_refusals(&socket)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
});
|
|
||||||
Ok((target, stop, catalog))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn publish_options(target: SocketAddr) -> CultMeshRudpDocumentPublishOptions {
|
|
||||||
CultMeshRudpDocumentPublishOptions {
|
|
||||||
target,
|
|
||||||
connect_timeout: Duration::from_secs(2),
|
|
||||||
flush_timeout: Duration::from_secs(3),
|
|
||||||
poll_interval: Duration::from_millis(2),
|
|
||||||
resend_delay_ms: 5,
|
|
||||||
..CultMeshRudpDocumentPublishOptions::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refused_note() -> cultnet_rs::CultNetMessage {
|
|
||||||
cultnet_rs::CultNetMessage::DocumentPutRaw {
|
|
||||||
message_id: "completion".into(),
|
|
||||||
document: cultnet_rs::CultNetRawDocumentRecord {
|
|
||||||
schema_id: "cultmesh.test.raw.v0".into(),
|
|
||||||
record_key: "tests:completion:a".into(),
|
|
||||||
stored_at: "2026-11-08T00:00:00Z".into(),
|
|
||||||
payload_encoding: cultnet_rs::CultNetRawPayloadEncoding::Messagepack,
|
|
||||||
payload: vec![0xc0],
|
|
||||||
source_runtime_id: None,
|
|
||||||
source_agent_id: None,
|
|
||||||
source_role: None,
|
|
||||||
tags: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A catalog's refusal text is quoted only when it is one of the fixed
|
/// A catalog's refusal text is quoted only when it is one of the fixed
|
||||||
/// refusals a CultMesh catalog sends. Any other text, which could carry
|
/// refusals a CultMesh catalog sends. Any other text, which could carry
|
||||||
/// anything, stays out of the publisher's error.
|
/// anything, stays out of the publisher's error.
|
||||||
|
|
@ -2044,63 +1900,4 @@ mod tests {
|
||||||
assert_eq!(lease.bytes(), &[1, 2, 3]);
|
assert_eq!(lease.bytes(), &[1, 2, 3]);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heimdall's invariant: no client treats a reliable-transport ack as write admission. The catalog here
|
|
||||||
// acknowledges the put and refuses it, the refusal arriving before or after the ack.
|
|
||||||
#[test]
|
|
||||||
fn a_publish_whose_refusal_arrives_before_the_ack_is_refused() -> Result<()> {
|
|
||||||
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::RefusalThenAck)?;
|
|
||||||
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
|
|
||||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
catalog.join().expect("catalog thread should not panic")?;
|
|
||||||
let error = result.expect_err("a refused put is never reported as admitted");
|
|
||||||
assert!(error.to_string().contains("refused the put"), "{error:#}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_publish_whose_ack_arrives_before_the_refusal_is_refused() -> Result<()> {
|
|
||||||
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenRefusal)?;
|
|
||||||
let result = publish_cultnet_message_to_rudp_catalog(&refused_note(), publish_options(target));
|
|
||||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
catalog.join().expect("catalog thread should not panic")?;
|
|
||||||
let error = result.expect_err("a refused put is never reported as admitted");
|
|
||||||
assert!(error.to_string().contains("refused the put"), "{error:#}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_batch_publish_is_not_complete_on_the_ack_of_refused_puts() -> Result<()> {
|
|
||||||
for reply in [CatalogReply::AckThenRefusal, CatalogReply::RefusalThenAck] {
|
|
||||||
let (target, stop, catalog) = acknowledging_refusing_catalog(reply)?;
|
|
||||||
let result = publish_cultnet_messages_to_rudp_catalog(&[refused_note()], publish_options(target));
|
|
||||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
catalog.join().expect("catalog thread should not panic")?;
|
|
||||||
assert!(result.is_err(), "a refused put was reported as published");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_success_frame_completes_the_publish_without_waiting_out_the_result_window() -> Result<()> {
|
|
||||||
for batch in [false, true] {
|
|
||||||
let (target, stop, catalog) = acknowledging_refusing_catalog(CatalogReply::AckThenSuccess)?;
|
|
||||||
let options = CultMeshRudpDocumentPublishOptions {
|
|
||||||
result_window: Duration::from_secs(30),
|
|
||||||
..publish_options(target)
|
|
||||||
};
|
|
||||||
let started = Instant::now();
|
|
||||||
let result = if batch {
|
|
||||||
publish_cultnet_messages_to_rudp_catalog(&[refused_note()], options)
|
|
||||||
} else {
|
|
||||||
publish_cultnet_message_to_rudp_catalog(&refused_note(), options)
|
|
||||||
};
|
|
||||||
stop.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
catalog.join().expect("catalog thread should not panic")?;
|
|
||||||
result?;
|
|
||||||
assert!(started.elapsed() < Duration::from_secs(10), "batch={batch}: waited out the window");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use cultnet_rs::{
|
use cultnet_rs::{
|
||||||
CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType,
|
CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType,
|
||||||
CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract,
|
CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract,
|
||||||
decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec,
|
decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec,
|
||||||
encode_rudp_packet, is_permanent_send_error, send_error_code,
|
encode_rudp_packet, is_permanent_send_error, send_error_code,
|
||||||
|
|
@ -19,16 +19,6 @@ const MAX_FRAGMENTS_PER_MESSAGE: usize = u16::MAX as usize;
|
||||||
/// can carry no shorter id, so no response to it can be smaller.
|
/// can carry no shorter id, so no response to it can be smaller.
|
||||||
const SHORTEST_MESSAGE_ID: &str = "0";
|
const SHORTEST_MESSAGE_ID: &str = "0";
|
||||||
|
|
||||||
/// How many consecutive refused frames of one kind a session may send before it is ended: undecodable
|
|
||||||
/// frames (reset by any frame that decodes) and refused deletes (reset by any decoded non-delete).
|
|
||||||
/// Fewer are answered with a typed refusal and the session is kept; the cap that reaches this number
|
|
||||||
/// is answered too, then the session ends. The same number is the cap in every runtime.
|
|
||||||
pub const MAX_CONSECUTIVE_REFUSED_FRAMES: u32 = 8;
|
|
||||||
|
|
||||||
/// How long a session whose capped frame was answered waits for the peer to acknowledge the answer
|
|
||||||
/// before it ends anyway.
|
|
||||||
const CAPPED_ANSWER_GRACE_MILLIS: u64 = 2_000;
|
|
||||||
|
|
||||||
/// The transport identity of one remote CultNet RUDP session.
|
/// The transport identity of one remote CultNet RUDP session.
|
||||||
///
|
///
|
||||||
/// Connection identifiers are scoped to a remote socket address. Two clients
|
/// Connection identifiers are scoped to a remote socket address. Two clients
|
||||||
|
|
@ -206,10 +196,7 @@ pub struct CultMeshRudpMaintenance {
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum CultMeshRudpApplicationOperation {
|
pub enum CultMeshRudpApplicationOperation {
|
||||||
DocumentPutRaw,
|
DocumentPutRaw,
|
||||||
DocumentDelete,
|
|
||||||
SnapshotRequest,
|
SnapshotRequest,
|
||||||
/// A schema frame that did not decode as any CultNet message; there is no message id to name.
|
|
||||||
Unreadable,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Why the server refused an application message.
|
/// Why the server refused an application message.
|
||||||
|
|
@ -254,18 +241,12 @@ pub enum CultMeshRudpRejectionReason {
|
||||||
/// (`send_error_code`). The session has ended with a goodbye carrying the
|
/// (`send_error_code`). The session has ended with a goodbye carrying the
|
||||||
/// same name, and no refusal: the server knows the peer cannot be reached.
|
/// same name, and no refusal: the server knows the peer cannot be reached.
|
||||||
ResponseSendFailed(&'static str),
|
ResponseSendFailed(&'static str),
|
||||||
/// The server has no delete path, so every delete is refused. Nothing reached the sink.
|
|
||||||
DeleteNotSupported,
|
|
||||||
/// A schema frame did not decode as a CultNet message. Carries the decoder's error text.
|
|
||||||
MessageUnreadable(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for CultMeshRudpRejectionReason {
|
impl std::fmt::Display for CultMeshRudpRejectionReason {
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::SinkRefused(error) | Self::SnapshotSourceFailed(error) => f.write_str(error),
|
Self::SinkRefused(error) | Self::SnapshotSourceFailed(error) => f.write_str(error),
|
||||||
Self::DeleteNotSupported => f.write_str("the server does not take deletes"),
|
|
||||||
Self::MessageUnreadable(error) => write!(f, "message could not be read: {error}"),
|
|
||||||
Self::DocumentUnservable {
|
Self::DocumentUnservable {
|
||||||
response_bytes,
|
response_bytes,
|
||||||
max_snapshot_response_bytes,
|
max_snapshot_response_bytes,
|
||||||
|
|
@ -306,7 +287,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason {
|
||||||
/// The texts a refused peer is sent, one per rejection variant. They are fixed:
|
/// The texts a refused peer is sent, one per rejection variant. They are fixed:
|
||||||
/// a sink's or source's error text can quote what a peer sent, so it stays with
|
/// a sink's or source's error text can quote what a peer sent, so it stays with
|
||||||
/// the caller that receives the rejection and never reaches the wire.
|
/// the caller that receives the rejection and never reaches the wire.
|
||||||
pub(crate) const REFUSAL_TEXTS: [&str; 11] = [
|
pub(crate) const REFUSAL_TEXTS: [&str; 9] = [
|
||||||
"the catalog refused the document",
|
"the catalog refused the document",
|
||||||
"the document can never be served",
|
"the document can never be served",
|
||||||
"the snapshot source failed",
|
"the snapshot source failed",
|
||||||
|
|
@ -316,8 +297,6 @@ pub(crate) const REFUSAL_TEXTS: [&str; 11] = [
|
||||||
"the retained payload budget is full",
|
"the retained payload budget is full",
|
||||||
"the snapshot response could not be queued",
|
"the snapshot response could not be queued",
|
||||||
"the snapshot response could not be sent",
|
"the snapshot response could not be sent",
|
||||||
"the server does not take deletes",
|
|
||||||
"the message could not be read",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
impl CultMeshRudpRejectionReason {
|
impl CultMeshRudpRejectionReason {
|
||||||
|
|
@ -333,77 +312,18 @@ impl CultMeshRudpRejectionReason {
|
||||||
Self::PayloadBudgetFull => 6,
|
Self::PayloadBudgetFull => 6,
|
||||||
Self::ResponseQueueFailed(_) => 7,
|
Self::ResponseQueueFailed(_) => 7,
|
||||||
Self::ResponseSendFailed(_) => 8,
|
Self::ResponseSendFailed(_) => 8,
|
||||||
Self::DeleteNotSupported => 9,
|
|
||||||
Self::MessageUnreadable(_) => 10,
|
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The typed `write_not_authorized` refusal for a put whose record is `record_key`, as C#
|
|
||||||
/// `CultNetErrorMessage.ForRemoteWriteRefused` builds it: code, and `details` naming the record.
|
|
||||||
pub(crate) fn write_refused_message(record_key: &str, reason: &str) -> CultNetMessage {
|
|
||||||
CultNetMessage::Error {
|
|
||||||
error: format!("write_not_authorized: {reason}"),
|
|
||||||
code: Some(CultNetErrorCode::WriteNotAuthorized),
|
|
||||||
details: Some(CultNetErrorDetails {
|
|
||||||
field: Some("recordKey".into()),
|
|
||||||
value: Some(record_key.into()),
|
|
||||||
as_of: None,
|
|
||||||
current: None,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The typed `message_invalid` refusal naming the request `field` at fault, as C#
|
|
||||||
/// `CultNetErrorMessage.ForMessageInvalid` builds it.
|
|
||||||
pub(crate) fn message_invalid_message(field: &str, reason: &str) -> CultNetMessage {
|
|
||||||
CultNetMessage::Error {
|
|
||||||
error: format!("message_invalid: {reason}"),
|
|
||||||
code: Some(CultNetErrorCode::MessageInvalid),
|
|
||||||
details: Some(CultNetErrorDetails {
|
|
||||||
field: Some(field.into()),
|
|
||||||
value: None,
|
|
||||||
as_of: None,
|
|
||||||
current: None,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub struct CultMeshRudpApplicationRejection {
|
pub struct CultMeshRudpApplicationRejection {
|
||||||
pub session: CultMeshRudpSessionKey,
|
pub session: CultMeshRudpSessionKey,
|
||||||
pub operation: CultMeshRudpApplicationOperation,
|
pub operation: CultMeshRudpApplicationOperation,
|
||||||
pub message_id: String,
|
pub message_id: String,
|
||||||
/// The record a rejected put or delete concerned; `None` for a snapshot request or an unreadable frame.
|
|
||||||
/// A sink or delete refusal names it to the peer.
|
|
||||||
pub record_key: Option<String>,
|
|
||||||
pub reason: CultMeshRudpRejectionReason,
|
pub reason: CultMeshRudpRejectionReason,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CultMeshRudpApplicationRejection {
|
|
||||||
/// What the refused peer is sent: a refused put or delete is a typed `write_not_authorized` naming
|
|
||||||
/// the record, an unreadable frame a typed `message_invalid` naming `message`; every other rejection
|
|
||||||
/// is the variant's fixed text, untyped. None carries the sink's, source's or decoder's own text.
|
|
||||||
fn refusal_message(&self) -> CultNetMessage {
|
|
||||||
let text = self.reason.refusal_text();
|
|
||||||
match (&self.reason, &self.record_key) {
|
|
||||||
(
|
|
||||||
CultMeshRudpRejectionReason::SinkRefused(_)
|
|
||||||
| CultMeshRudpRejectionReason::DeleteNotSupported,
|
|
||||||
Some(record_key),
|
|
||||||
) => write_refused_message(record_key, text),
|
|
||||||
(CultMeshRudpRejectionReason::MessageUnreadable(_), _) => {
|
|
||||||
message_invalid_message("message", text)
|
|
||||||
}
|
|
||||||
_ => CultNetMessage::Error {
|
|
||||||
error: text.into(),
|
|
||||||
code: None,
|
|
||||||
details: None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
pub enum CultMeshRudpPollOutcome {
|
pub enum CultMeshRudpPollOutcome {
|
||||||
Idle,
|
Idle,
|
||||||
|
|
@ -415,8 +335,8 @@ pub enum CultMeshRudpPollOutcome {
|
||||||
enum SessionEnd {
|
enum SessionEnd {
|
||||||
/// The session refused a packet.
|
/// The session refused a packet.
|
||||||
Refused,
|
Refused,
|
||||||
/// The server rejected a peer's message; the peer is sent this refusal.
|
/// The server rejected a peer's message; the peer is sent this fixed text.
|
||||||
Rejected(CultNetMessage),
|
Rejected(&'static str),
|
||||||
/// The session owes its peer a packet that can never be sent as built.
|
/// The session owes its peer a packet that can never be sent as built.
|
||||||
Unsendable(std::io::Error),
|
Unsendable(std::io::Error),
|
||||||
}
|
}
|
||||||
|
|
@ -426,11 +346,6 @@ struct SessionEntry {
|
||||||
created_at_monotonic_millis: u64,
|
created_at_monotonic_millis: u64,
|
||||||
last_activity_monotonic_millis: u64,
|
last_activity_monotonic_millis: u64,
|
||||||
admitted_payload_bytes: usize,
|
admitted_payload_bytes: usize,
|
||||||
undecodable_frames_in_a_row: u32,
|
|
||||||
refused_deletes_in_a_row: u32,
|
|
||||||
/// Set once the peer's capped frame has been answered: the session ends when the answer is acknowledged,
|
|
||||||
/// or at this time.
|
|
||||||
ending_deadline_monotonic_millis: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A synchronous, multi-session CultNet RUDP raw-document server.
|
/// A synchronous, multi-session CultNet RUDP raw-document server.
|
||||||
|
|
@ -605,18 +520,7 @@ where
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut unsendable = match result.reply {
|
let mut unsendable = match result.reply {
|
||||||
Some(mut reply) => {
|
Some(reply) => self.send_packet(key.remote_addr, &reply)?,
|
||||||
// A reply (a Pong) in the grace after the capped frame acknowledges nothing either.
|
|
||||||
if self
|
|
||||||
.sessions
|
|
||||||
.get(&key)
|
|
||||||
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some())
|
|
||||||
{
|
|
||||||
reply.ack = 0;
|
|
||||||
reply.ack_mask = 0;
|
|
||||||
}
|
|
||||||
self.send_packet(key.remote_addr, &reply)?
|
|
||||||
}
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -625,125 +529,24 @@ where
|
||||||
return Ok(CultMeshRudpPollOutcome::Handled);
|
return Ok(CultMeshRudpPollOutcome::Handled);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut kept_rejection = None;
|
|
||||||
for frame in result.delivered {
|
for frame in result.delivered {
|
||||||
if frame.channel_id != "schema" {
|
if frame.channel_id != "schema" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// The capped frame was answered: nothing after it is served while the answer is delivered.
|
let message = match decode_cultnet_message_from_slice(
|
||||||
if self
|
|
||||||
.sessions
|
|
||||||
.get(&key)
|
|
||||||
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some())
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let decoded = decode_cultnet_message_from_slice(
|
|
||||||
&frame.payload,
|
&frame.payload,
|
||||||
CultNetWireContract::CultNetSchemaV0,
|
CultNetWireContract::CultNetSchemaV0,
|
||||||
);
|
) {
|
||||||
// Runs of refusals this frame extends or breaks. A session is kept through a short run and
|
Ok(message) => message,
|
||||||
// ended by `MAX_CONSECUTIVE_REFUSED_FRAMES` in a row.
|
Err(_) => continue,
|
||||||
let (is_unreadable, is_delete) = (
|
|
||||||
decoded.is_err(),
|
|
||||||
matches!(decoded, Ok(CultNetMessage::DocumentDelete { .. })),
|
|
||||||
);
|
|
||||||
let run = self.sessions.get_mut(&key).map(|entry| {
|
|
||||||
entry.undecodable_frames_in_a_row = if is_unreadable {
|
|
||||||
entry.undecodable_frames_in_a_row + 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
if is_delete {
|
|
||||||
entry.refused_deletes_in_a_row += 1;
|
|
||||||
} else if !is_unreadable {
|
|
||||||
entry.refused_deletes_in_a_row = 0;
|
|
||||||
}
|
|
||||||
if is_unreadable {
|
|
||||||
entry.undecodable_frames_in_a_row
|
|
||||||
} else {
|
|
||||||
entry.refused_deletes_in_a_row
|
|
||||||
}
|
|
||||||
});
|
|
||||||
let rejection = match decoded {
|
|
||||||
Ok(message) => self.deliver_application_message(
|
|
||||||
key,
|
|
||||||
frame.sequence,
|
|
||||||
message,
|
|
||||||
now_unix,
|
|
||||||
now_monotonic,
|
|
||||||
)?,
|
|
||||||
Err(error) => Some(CultMeshRudpApplicationRejection {
|
|
||||||
session: key,
|
|
||||||
operation: CultMeshRudpApplicationOperation::Unreadable,
|
|
||||||
message_id: String::new(),
|
|
||||||
record_key: None,
|
|
||||||
reason: CultMeshRudpRejectionReason::MessageUnreadable(format!("{error:#}")),
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
if let Some(rejection) = rejection {
|
if let Some(rejection) = self.deliver_application_message(
|
||||||
// A refused delete or unreadable frame below the cap is answered on the live session,
|
key,
|
||||||
// which goes on: the peer keeps the connection a stray frame did not break.
|
frame.sequence,
|
||||||
if (is_unreadable || is_delete)
|
message,
|
||||||
&& run.is_some_and(|run| run < MAX_CONSECUTIVE_REFUSED_FRAMES)
|
now_unix,
|
||||||
{
|
now_monotonic,
|
||||||
let refusal = rejection.refusal_message();
|
)? {
|
||||||
let packets = match self.sessions.remove(&key) {
|
|
||||||
Some(mut entry) => {
|
|
||||||
let packets = self.refusal_packets(&mut entry.session, &refusal, false);
|
|
||||||
self.sessions.insert(key, entry);
|
|
||||||
packets
|
|
||||||
}
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
for packet in &packets {
|
|
||||||
if let Some(error) = self.send_packet(key.remote_addr, packet)? {
|
|
||||||
unsendable = Some(error);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kept_rejection = Some(rejection);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// The capped frame is answered reliably, so a lost datagram is resent; the session ends once
|
|
||||||
// the peer has acknowledged the answer or the grace runs out (`end_answered_sessions`). When
|
|
||||||
// the answer cannot be queued, the session ends at once with the refusal as before.
|
|
||||||
if (is_unreadable || is_delete)
|
|
||||||
&& run.is_some()
|
|
||||||
&& !matches!(
|
|
||||||
rejection.reason,
|
|
||||||
CultMeshRudpRejectionReason::ResponseSendFailed(_)
|
|
||||||
)
|
|
||||||
{
|
|
||||||
let refusal = rejection.refusal_message();
|
|
||||||
let packets = match self.sessions.remove(&key) {
|
|
||||||
Some(mut entry) => {
|
|
||||||
let packets = self.refusal_packets(&mut entry.session, &refusal, true);
|
|
||||||
if !packets.is_empty() {
|
|
||||||
entry.ending_deadline_monotonic_millis =
|
|
||||||
Some(now_monotonic.saturating_add(CAPPED_ANSWER_GRACE_MILLIS));
|
|
||||||
}
|
|
||||||
self.sessions.insert(key, entry);
|
|
||||||
packets
|
|
||||||
}
|
|
||||||
None => Vec::new(),
|
|
||||||
};
|
|
||||||
if packets.is_empty() {
|
|
||||||
self.end_session(key, SessionEnd::Rejected(refusal))?;
|
|
||||||
return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection));
|
|
||||||
}
|
|
||||||
for mut packet in packets {
|
|
||||||
// Acknowledges nothing, as every refusal: the refused frame was not admitted.
|
|
||||||
packet.ack = 0;
|
|
||||||
packet.ack_mask = 0;
|
|
||||||
if let Some(error) = self.send_packet(key.remote_addr, &packet)? {
|
|
||||||
unsendable = Some(error);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
kept_rejection = Some(rejection);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// The only reply `receive` returns is a Pong, which delivers no
|
// The only reply `receive` returns is a Pong, which delivers no
|
||||||
// frame, so no earlier send of this poll can precede a rejection.
|
// frame, so no earlier send of this poll can precede a rejection.
|
||||||
// A response that could not be sent ended its session where the
|
// A response that could not be sent ended its session where the
|
||||||
|
|
@ -752,17 +555,13 @@ where
|
||||||
rejection.reason,
|
rejection.reason,
|
||||||
CultMeshRudpRejectionReason::ResponseSendFailed(_)
|
CultMeshRudpRejectionReason::ResponseSendFailed(_)
|
||||||
) {
|
) {
|
||||||
self.end_session(key, SessionEnd::Rejected(rejection.refusal_message()))?;
|
self.end_session(key, SessionEnd::Rejected(rejection.reason.refusal_text()))?;
|
||||||
}
|
}
|
||||||
return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection));
|
return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let answering_capped_frame = self
|
if packet.reliable && unsendable.is_none() {
|
||||||
.sessions
|
|
||||||
.get(&key)
|
|
||||||
.is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some());
|
|
||||||
if packet.reliable && unsendable.is_none() && !answering_capped_frame {
|
|
||||||
let ack = self
|
let ack = self
|
||||||
.sessions
|
.sessions
|
||||||
.get_mut(&key)
|
.get_mut(&key)
|
||||||
|
|
@ -774,11 +573,7 @@ where
|
||||||
if let Some(error) = unsendable {
|
if let Some(error) = unsendable {
|
||||||
self.end_session(key, SessionEnd::Unsendable(error))?;
|
self.end_session(key, SessionEnd::Unsendable(error))?;
|
||||||
}
|
}
|
||||||
self.end_answered_sessions()?;
|
Ok(CultMeshRudpPollOutcome::Handled)
|
||||||
Ok(match kept_rejection {
|
|
||||||
Some(rejection) => CultMeshRudpPollOutcome::ApplicationRejected(rejection),
|
|
||||||
None => CultMeshRudpPollOutcome::Handled,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expire idle sessions and resend reliable packets whose deadline passed.
|
/// Expire idle sessions and resend reliable packets whose deadline passed.
|
||||||
|
|
@ -792,16 +587,10 @@ where
|
||||||
&& now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms
|
&& now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms
|
||||||
});
|
});
|
||||||
let expired = before - self.sessions.len();
|
let expired = before - self.sessions.len();
|
||||||
self.end_answered_sessions()?;
|
|
||||||
|
|
||||||
let mut resends = Vec::new();
|
let mut resends = Vec::new();
|
||||||
for (key, entry) in &mut self.sessions {
|
for (key, entry) in &mut self.sessions {
|
||||||
for mut packet in entry.session.due_resends(now) {
|
for packet in entry.session.due_resends(now) {
|
||||||
if entry.ending_deadline_monotonic_millis.is_some() {
|
|
||||||
// The resent answer to a capped frame acknowledges nothing either.
|
|
||||||
packet.ack = 0;
|
|
||||||
packet.ack_mask = 0;
|
|
||||||
}
|
|
||||||
resends.push((*key, packet));
|
resends.push((*key, packet));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -820,26 +609,6 @@ where
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ends each session whose capped frame was answered once the peer has acknowledged every answer (a lost
|
|
||||||
/// answer is resent first), or when the grace has run out.
|
|
||||||
fn end_answered_sessions(&mut self) -> Result<()> {
|
|
||||||
let now = self.clock.now_monotonic_millis();
|
|
||||||
let due: Vec<CultMeshRudpSessionKey> = self
|
|
||||||
.sessions
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, entry)| {
|
|
||||||
entry.ending_deadline_monotonic_millis.is_some_and(|deadline| {
|
|
||||||
entry.session.outstanding_reliable_packet_count() == 0 || now >= deadline
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.map(|(key, _)| *key)
|
|
||||||
.collect();
|
|
||||||
for key in due {
|
|
||||||
self.end_session(key, SessionEnd::Refused)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `Ok(false)` when the Connect was refused because the session cap is full;
|
/// `Ok(false)` when the Connect was refused because the session cap is full;
|
||||||
/// only a socket failure is an `Err`.
|
/// only a socket failure is an `Err`.
|
||||||
fn accept_connection(
|
fn accept_connection(
|
||||||
|
|
@ -889,9 +658,6 @@ where
|
||||||
created_at_monotonic_millis: now,
|
created_at_monotonic_millis: now,
|
||||||
last_activity_monotonic_millis: now,
|
last_activity_monotonic_millis: now,
|
||||||
admitted_payload_bytes: 0,
|
admitted_payload_bytes: 0,
|
||||||
undecodable_frames_in_a_row: 0,
|
|
||||||
refused_deletes_in_a_row: 0,
|
|
||||||
ending_deadline_monotonic_millis: None,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
accept
|
accept
|
||||||
|
|
@ -917,13 +683,11 @@ where
|
||||||
message_id,
|
message_id,
|
||||||
document,
|
document,
|
||||||
} => {
|
} => {
|
||||||
let record_key = document.record_key.clone();
|
|
||||||
let reject = |reason| {
|
let reject = |reason| {
|
||||||
Ok(Some(CultMeshRudpApplicationRejection {
|
Ok(Some(CultMeshRudpApplicationRejection {
|
||||||
session: key,
|
session: key,
|
||||||
operation: CultMeshRudpApplicationOperation::DocumentPutRaw,
|
operation: CultMeshRudpApplicationOperation::DocumentPutRaw,
|
||||||
message_id: message_id.clone(),
|
message_id: message_id.clone(),
|
||||||
record_key: Some(record_key.clone()),
|
|
||||||
reason,
|
reason,
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
@ -994,7 +758,6 @@ where
|
||||||
session: key,
|
session: key,
|
||||||
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
||||||
message_id,
|
message_id,
|
||||||
record_key: None,
|
|
||||||
reason: CultMeshRudpRejectionReason::SnapshotSourceFailed(format!(
|
reason: CultMeshRudpRejectionReason::SnapshotSourceFailed(format!(
|
||||||
"{error:#}"
|
"{error:#}"
|
||||||
)),
|
)),
|
||||||
|
|
@ -1006,7 +769,6 @@ where
|
||||||
session: key,
|
session: key,
|
||||||
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
||||||
message_id,
|
message_id,
|
||||||
record_key: None,
|
|
||||||
reason: CultMeshRudpRejectionReason::SnapshotTooManyDocuments {
|
reason: CultMeshRudpRejectionReason::SnapshotTooManyDocuments {
|
||||||
documents: documents.len(),
|
documents: documents.len(),
|
||||||
max_snapshot_documents: self.options.max_snapshot_documents,
|
max_snapshot_documents: self.options.max_snapshot_documents,
|
||||||
|
|
@ -1024,7 +786,6 @@ where
|
||||||
session: key,
|
session: key,
|
||||||
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
||||||
message_id,
|
message_id,
|
||||||
record_key: None,
|
|
||||||
reason: CultMeshRudpRejectionReason::ResponseEncodingFailed(error),
|
reason: CultMeshRudpRejectionReason::ResponseEncodingFailed(error),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -1034,7 +795,6 @@ where
|
||||||
session: key,
|
session: key,
|
||||||
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
||||||
message_id,
|
message_id,
|
||||||
record_key: None,
|
|
||||||
reason: CultMeshRudpRejectionReason::SnapshotResponseTooLarge {
|
reason: CultMeshRudpRejectionReason::SnapshotResponseTooLarge {
|
||||||
response_bytes: payload.len(),
|
response_bytes: payload.len(),
|
||||||
max_snapshot_response_bytes: self.options.max_snapshot_response_bytes,
|
max_snapshot_response_bytes: self.options.max_snapshot_response_bytes,
|
||||||
|
|
@ -1046,7 +806,6 @@ where
|
||||||
session: key,
|
session: key,
|
||||||
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
||||||
message_id,
|
message_id,
|
||||||
record_key: None,
|
|
||||||
reason: CultMeshRudpRejectionReason::PayloadBudgetFull,
|
reason: CultMeshRudpRejectionReason::PayloadBudgetFull,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -1073,7 +832,6 @@ where
|
||||||
session: key,
|
session: key,
|
||||||
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
||||||
message_id,
|
message_id,
|
||||||
record_key: None,
|
|
||||||
reason: CultMeshRudpRejectionReason::ResponseQueueFailed(format!(
|
reason: CultMeshRudpRejectionReason::ResponseQueueFailed(format!(
|
||||||
"{error:#}"
|
"{error:#}"
|
||||||
)),
|
)),
|
||||||
|
|
@ -1090,25 +848,11 @@ where
|
||||||
session: key,
|
session: key,
|
||||||
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
operation: CultMeshRudpApplicationOperation::SnapshotRequest,
|
||||||
message_id,
|
message_id,
|
||||||
record_key: None,
|
|
||||||
reason: CultMeshRudpRejectionReason::ResponseSendFailed(code),
|
reason: CultMeshRudpRejectionReason::ResponseSendFailed(code),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CultNetMessage::DocumentDelete {
|
|
||||||
message_id,
|
|
||||||
record_key,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
return Ok(Some(CultMeshRudpApplicationRejection {
|
|
||||||
session: key,
|
|
||||||
operation: CultMeshRudpApplicationOperation::DocumentDelete,
|
|
||||||
message_id,
|
|
||||||
record_key: Some(record_key),
|
|
||||||
reason: CultMeshRudpRejectionReason::DeleteNotSupported,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
|
|
@ -1131,7 +875,7 @@ where
|
||||||
};
|
};
|
||||||
let (refusal, mut unsendable) = match end {
|
let (refusal, mut unsendable) = match end {
|
||||||
SessionEnd::Refused => (Vec::new(), None),
|
SessionEnd::Refused => (Vec::new(), None),
|
||||||
SessionEnd::Rejected(refusal) => (self.refusal_packets(&mut entry.session, &refusal, false), None),
|
SessionEnd::Rejected(text) => (self.refusal_packets(&mut entry.session, text), None),
|
||||||
SessionEnd::Unsendable(error) => (Vec::new(), Some(error)),
|
SessionEnd::Unsendable(error) => (Vec::new(), Some(error)),
|
||||||
};
|
};
|
||||||
entry.session.reset_peer_state();
|
entry.session.reset_peer_state();
|
||||||
|
|
@ -1154,21 +898,25 @@ where
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The refusal a rejected peer is sent: unreliable and unordered, or (`reliable`) queued like any response.
|
/// The refusal a rejected peer is sent, as unreliable, unordered packets.
|
||||||
fn refusal_packets(
|
fn refusal_packets(
|
||||||
&self,
|
&self,
|
||||||
session: &mut CultNetRudpSession,
|
session: &mut CultNetRudpSession,
|
||||||
refusal: &CultNetMessage,
|
text: &str,
|
||||||
reliable: bool,
|
|
||||||
) -> Vec<CultNetRudpPacket> {
|
) -> Vec<CultNetRudpPacket> {
|
||||||
encode_cultnet_message_to_vec(refusal, CultNetWireContract::CultNetSchemaV0)
|
let refusal = CultNetMessage::Error {
|
||||||
|
error: text.into(),
|
||||||
|
code: None,
|
||||||
|
details: None,
|
||||||
|
};
|
||||||
|
encode_cultnet_message_to_vec(&refusal, CultNetWireContract::CultNetSchemaV0)
|
||||||
.and_then(|payload| {
|
.and_then(|payload| {
|
||||||
session.send_many(
|
session.send_many(
|
||||||
"schema",
|
"schema",
|
||||||
payload,
|
payload,
|
||||||
CultNetRudpSendOptions {
|
CultNetRudpSendOptions {
|
||||||
reliable,
|
reliable: false,
|
||||||
ordered: reliable,
|
ordered: false,
|
||||||
sequenced: false,
|
sequenced: false,
|
||||||
now_ms: self.clock.now_monotonic_millis(),
|
now_ms: self.clock.now_monotonic_millis(),
|
||||||
reliable_expire_after_ms: None,
|
reliable_expire_after_ms: None,
|
||||||
|
|
@ -1332,39 +1080,3 @@ fn encode_snapshot_response(response: &CultNetMessage) -> std::result::Result<Ve
|
||||||
fn duration_millis(duration: Duration) -> u64 {
|
fn duration_millis(duration: Duration) -> u64 {
|
||||||
duration.as_millis().try_into().unwrap_or(u64::MAX)
|
duration.as_millis().try_into().unwrap_or(u64::MAX)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
// contracts/cultnet/interop/error-vectors.json, `write_not_authorized`: bytes captured from C# MessagePack
|
|
||||||
// (`ForRemoteWriteRefused(request for "some:key", "not granted")`) and pinned by C#, TS and Python too.
|
|
||||||
const WRITE_NOT_AUTHORIZED_HEX: &str = "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0";
|
|
||||||
|
|
||||||
// The same file's `message_invalid` vector: field `document`, reason "Raw put messages must contain a document map."
|
|
||||||
const MESSAGE_INVALID_HEX: &str = "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0";
|
|
||||||
|
|
||||||
fn hex(bytes: &[u8]) -> String {
|
|
||||||
bytes.iter().map(|byte| format!("{byte:02X}")).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_sink_refusal_is_the_pinned_write_not_authorized_bytes() {
|
|
||||||
let bytes = encode_cultnet_message_to_vec(
|
|
||||||
&write_refused_message("some:key", "not granted"),
|
|
||||||
CultNetWireContract::CultNetSchemaV0,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(hex(&bytes), WRITE_NOT_AUTHORIZED_HEX);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_unreadable_refusal_is_the_pinned_message_invalid_bytes() {
|
|
||||||
let bytes = encode_cultnet_message_to_vec(
|
|
||||||
&message_invalid_message("document", "Raw put messages must contain a document map."),
|
|
||||||
CultNetWireContract::CultNetSchemaV0,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(hex(&bytes), MESSAGE_INVALID_HEX);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use cultmesh_rs::{
|
||||||
CultMeshRudpSnapshotQuery, CultMeshRudpSnapshotSource,
|
CultMeshRudpSnapshotQuery, CultMeshRudpSnapshotSource,
|
||||||
};
|
};
|
||||||
use cultnet_rs::{
|
use cultnet_rs::{
|
||||||
CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding,
|
CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding,
|
||||||
CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode,
|
CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode,
|
||||||
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract,
|
CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract,
|
||||||
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
|
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
|
||||||
|
|
@ -343,18 +343,6 @@ fn refusal_seen_by(
|
||||||
client: &mut CultNetRudpSocketTransportConnection,
|
client: &mut CultNetRudpSocketTransportConnection,
|
||||||
receipt: &CultNetRudpReliableSendReceipt,
|
receipt: &CultNetRudpReliableSendReceipt,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let (error, code, details) = typed_refusal_seen_by(client, receipt, None)?;
|
|
||||||
assert_eq!((code, details), (None, None));
|
|
||||||
Ok(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `server` is polled while the goodbye is awaited when the refusal ends its session only once the peer has
|
|
||||||
/// acknowledged it (the capped frame's answer).
|
|
||||||
fn typed_refusal_seen_by(
|
|
||||||
client: &mut CultNetRudpSocketTransportConnection,
|
|
||||||
receipt: &CultNetRudpReliableSendReceipt,
|
|
||||||
mut server: Option<&mut Server>,
|
|
||||||
) -> Result<(String, Option<CultNetErrorCode>, Option<CultNetErrorDetails>)> {
|
|
||||||
let mut refusal = None;
|
let mut refusal = None;
|
||||||
for _ in 0..2_000 {
|
for _ in 0..2_000 {
|
||||||
if let Some(frame) = client.receive_once()? {
|
if let Some(frame) = client.receive_once()? {
|
||||||
|
|
@ -374,6 +362,7 @@ fn typed_refusal_seen_by(
|
||||||
else {
|
else {
|
||||||
panic!("the refused peer was sent no refusal: {refusal:?}");
|
panic!("the refused peer was sent no refusal: {refusal:?}");
|
||||||
};
|
};
|
||||||
|
assert_eq!((code, details), (None, None));
|
||||||
assert!(
|
assert!(
|
||||||
!error.contains("CANARY"),
|
!error.contains("CANARY"),
|
||||||
"a sink's or source's text reached the peer: {error}"
|
"a sink's or source's text reached the peer: {error}"
|
||||||
|
|
@ -384,9 +373,6 @@ fn typed_refusal_seen_by(
|
||||||
"a refusal must not acknowledge the refused message"
|
"a refusal must not acknowledge the refused message"
|
||||||
);
|
);
|
||||||
for _ in 0..2_000 {
|
for _ in 0..2_000 {
|
||||||
if let Some(server) = server.as_deref_mut() {
|
|
||||||
server.poll_once()?;
|
|
||||||
}
|
|
||||||
client.receive_once()?;
|
client.receive_once()?;
|
||||||
if !client.connected() {
|
if !client.connected() {
|
||||||
break;
|
break;
|
||||||
|
|
@ -398,7 +384,7 @@ fn typed_refusal_seen_by(
|
||||||
client.reliable_send_status(receipt),
|
client.reliable_send_status(receipt),
|
||||||
CultNetRudpReliableSendStatus::Invalidated
|
CultNetRudpReliableSendStatus::Invalidated
|
||||||
);
|
);
|
||||||
Ok((error, code, details))
|
Ok(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads every datagram waiting at the server, such as a refused peer's
|
/// Reads every datagram waiting at the server, such as a refused peer's
|
||||||
|
|
@ -685,21 +671,10 @@ fn application_rejection_is_nonfatal_peer_scoped_refused_to_the_peer_and_unackno
|
||||||
);
|
);
|
||||||
assert_eq!(server.session_count(), 2);
|
assert_eq!(server.session_count(), 2);
|
||||||
// The caller keeps the sink's text; the peer is sent fixed text, never it.
|
// The caller keeps the sink's text; the peer is sent fixed text, never it.
|
||||||
// A refused put is typed write_not_authorized and names the record; nothing of it reached the sink.
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
typed_refusal_seen_by(&mut publisher, &publish_receipt, None)?,
|
refusal_seen_by(&mut publisher, &publish_receipt)?,
|
||||||
(
|
"the catalog refused the document"
|
||||||
"write_not_authorized: the catalog refused the document".to_string(),
|
|
||||||
Some(CultNetErrorCode::WriteNotAuthorized),
|
|
||||||
Some(CultNetErrorDetails {
|
|
||||||
field: Some("recordKey".into()),
|
|
||||||
value: Some(document("rejected", vec![0, 255, 1]).record_key),
|
|
||||||
as_of: None,
|
|
||||||
current: None,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "a refused put changes nothing");
|
|
||||||
drain_until_idle(&mut server)?;
|
drain_until_idle(&mut server)?;
|
||||||
|
|
||||||
let snapshot_receipt = send_reliable(
|
let snapshot_receipt = send_reliable(
|
||||||
|
|
@ -757,173 +732,6 @@ fn application_rejection_is_nonfatal_peer_scoped_refused_to_the_peer_and_unackno
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sends `payload` reliably, polls until the server refuses it, and returns the typed refusal the
|
|
||||||
/// peer is sent. The session is not asserted here: the caller says whether it lives.
|
|
||||||
fn refusal_on_live_session(
|
|
||||||
server: &mut CultMeshRudpDocumentServer<Sink, Source, Clock>,
|
|
||||||
client: &mut CultNetRudpSocketTransportConnection,
|
|
||||||
payload: Vec<u8>,
|
|
||||||
) -> Result<(Option<CultNetErrorCode>, Option<CultNetErrorDetails>, String)> {
|
|
||||||
client.send_reliable("schema", payload)?;
|
|
||||||
let mut rejected = false;
|
|
||||||
for _ in 0..2_000 {
|
|
||||||
if !rejected {
|
|
||||||
rejected = matches!(server.poll_once()?, CultMeshRudpPollOutcome::ApplicationRejected(_));
|
|
||||||
}
|
|
||||||
if let Some(frame) = client.receive_once()? {
|
|
||||||
if let CultNetMessage::Error { error, code, details } = decode_cultnet_message_from_slice(
|
|
||||||
&frame.payload,
|
|
||||||
CultNetWireContract::CultNetSchemaV0,
|
|
||||||
)? {
|
|
||||||
assert!(rejected, "the refusal preceded the server's own outcome");
|
|
||||||
return Ok((code, details, error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(1));
|
|
||||||
}
|
|
||||||
anyhow::bail!("no typed refusal reached the peer")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn garbage() -> Vec<u8> {
|
|
||||||
let mut payload = vec![0xAE];
|
|
||||||
payload.extend_from_slice(b"CANARY-garbage");
|
|
||||||
payload
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete_of(key: &str) -> Vec<u8> {
|
|
||||||
encode_cultnet_message_to_vec(
|
|
||||||
&CultNetMessage::DocumentDelete {
|
|
||||||
message_id: "del".into(),
|
|
||||||
schema_id: "sha256:note".into(),
|
|
||||||
record_key: key.into(),
|
|
||||||
},
|
|
||||||
CultNetWireContract::CultNetSchemaV0,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn valid_put(key: &str) -> Vec<u8> {
|
|
||||||
encode_cultnet_message_to_vec(
|
|
||||||
&CultNetMessage::DocumentPutRaw {
|
|
||||||
message_id: format!("put-{key}"),
|
|
||||||
document: document(key, vec![1, 2, 3]),
|
|
||||||
},
|
|
||||||
CultNetWireContract::CultNetSchemaV0,
|
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stored_after_poll(
|
|
||||||
server: &mut CultMeshRudpDocumentServer<Sink, Source, Clock>,
|
|
||||||
client: &mut CultNetRudpSocketTransportConnection,
|
|
||||||
sink: &Sink,
|
|
||||||
receipts: usize,
|
|
||||||
) -> Result<()> {
|
|
||||||
client.send_reliable("schema", valid_put(&format!("k{receipts}")))?;
|
|
||||||
for _ in 0..2_000 {
|
|
||||||
server.poll_once()?;
|
|
||||||
client.receive_once()?;
|
|
||||||
if sink.0.lock().unwrap().receipts.len() >= receipts {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(1));
|
|
||||||
}
|
|
||||||
anyhow::bail!("a valid put on the kept session was not stored")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn an_unreadable_frame_is_refused_typed_and_the_session_kept_until_eight_in_a_row() -> Result<()> {
|
|
||||||
let sink = Sink::default();
|
|
||||||
let mut server = server(Default::default(), Clock::new(49_000), sink.clone(), Source::default())?;
|
|
||||||
let target = server.local_addr()?;
|
|
||||||
let mut garbler = client(target, 111)?;
|
|
||||||
let mut survivor = client(target, 113)?;
|
|
||||||
connect(&mut server, &mut [&mut garbler, &mut survivor])?;
|
|
||||||
let message_invalid = (
|
|
||||||
Some(CultNetErrorCode::MessageInvalid),
|
|
||||||
Some(CultNetErrorDetails {
|
|
||||||
field: Some("message".into()),
|
|
||||||
value: None,
|
|
||||||
as_of: None,
|
|
||||||
current: None,
|
|
||||||
}),
|
|
||||||
"message_invalid: the message could not be read".to_string(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Seven in a row are answered and the session lives, a valid frame resets the run, and seven more
|
|
||||||
// are answered again: a counter that never resets, or a cap below eight, ends the session early.
|
|
||||||
for _ in 0..7 {
|
|
||||||
assert_eq!(refusal_on_live_session(&mut server, &mut garbler, garbage())?, message_invalid);
|
|
||||||
assert!(garbler.connected());
|
|
||||||
}
|
|
||||||
stored_after_poll(&mut server, &mut garbler, &sink, 1)?;
|
|
||||||
for _ in 0..7 {
|
|
||||||
assert_eq!(refusal_on_live_session(&mut server, &mut garbler, garbage())?, message_invalid);
|
|
||||||
assert!(garbler.connected());
|
|
||||||
}
|
|
||||||
assert_eq!(server.session_count(), 2);
|
|
||||||
|
|
||||||
// The eighth consecutive one is answered too, then the session ends with a goodbye.
|
|
||||||
let receipt = garbler.send_reliable("schema", garbage())?;
|
|
||||||
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else {
|
|
||||||
panic!("the eighth consecutive unreadable frame must be refused");
|
|
||||||
};
|
|
||||||
assert_eq!(rejection.operation, CultMeshRudpApplicationOperation::Unreadable);
|
|
||||||
let (error, code, _) = typed_refusal_seen_by(&mut garbler, &receipt, Some(&mut server))?;
|
|
||||||
assert_eq!((error.as_str(), code), (message_invalid.2.as_str(), message_invalid.0));
|
|
||||||
drain_until_idle(&mut server)?;
|
|
||||||
assert_eq!(server.session_count(), 1, "only the capped peer's session ended");
|
|
||||||
assert_eq!(sink.0.lock().unwrap().receipts.len(), 1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_refused_delete_keeps_the_session_until_eight_in_a_row() -> Result<()> {
|
|
||||||
let sink = Sink::default();
|
|
||||||
let mut server = server(Default::default(), Clock::new(50_000), sink.clone(), Source::default())?;
|
|
||||||
let target = server.local_addr()?;
|
|
||||||
let mut deleter = client(target, 112)?;
|
|
||||||
connect(&mut server, &mut [&mut deleter])?;
|
|
||||||
let refused = |key: &str| {
|
|
||||||
(
|
|
||||||
Some(CultNetErrorCode::WriteNotAuthorized),
|
|
||||||
Some(CultNetErrorDetails {
|
|
||||||
field: Some("recordKey".into()),
|
|
||||||
value: Some(key.into()),
|
|
||||||
as_of: None,
|
|
||||||
current: None,
|
|
||||||
}),
|
|
||||||
"write_not_authorized: the server does not take deletes".to_string(),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
for _ in 0..7 {
|
|
||||||
assert_eq!(refusal_on_live_session(&mut server, &mut deleter, delete_of("note:victim"))?, refused("note:victim"));
|
|
||||||
assert!(deleter.connected());
|
|
||||||
}
|
|
||||||
// A decoded non-delete frame resets the run.
|
|
||||||
stored_after_poll(&mut server, &mut deleter, &sink, 1)?;
|
|
||||||
for _ in 0..7 {
|
|
||||||
assert_eq!(refusal_on_live_session(&mut server, &mut deleter, delete_of("note:victim"))?, refused("note:victim"));
|
|
||||||
}
|
|
||||||
let receipt = deleter.send_reliable("schema", delete_of("note:victim"))?;
|
|
||||||
let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = server.poll_once()? else {
|
|
||||||
panic!("the eighth consecutive delete must be refused");
|
|
||||||
};
|
|
||||||
assert_eq!(rejection.operation, CultMeshRudpApplicationOperation::DocumentDelete);
|
|
||||||
assert_eq!(rejection.reason, CultMeshRudpRejectionReason::DeleteNotSupported);
|
|
||||||
let (error, code, details) = typed_refusal_seen_by(&mut deleter, &receipt, Some(&mut server))?;
|
|
||||||
assert_eq!(
|
|
||||||
(error, code, details),
|
|
||||||
{
|
|
||||||
let (c, d, e) = refused("note:victim");
|
|
||||||
(e, c, d)
|
|
||||||
}
|
|
||||||
);
|
|
||||||
drain_until_idle(&mut server)?;
|
|
||||||
assert_eq!(server.session_count(), 0);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_response_resends_until_acknowledged() -> Result<()> {
|
fn snapshot_response_resends_until_acknowledged() -> Result<()> {
|
||||||
let clock = Clock::new(51_000);
|
let clock = Clock::new(51_000);
|
||||||
|
|
@ -1629,11 +1437,6 @@ impl RawPeer {
|
||||||
/// Sends `message` reliably and in order; returns its sequences.
|
/// Sends `message` reliably and in order; returns its sequences.
|
||||||
fn send(&mut self, message: &CultNetMessage) -> Result<Vec<u32>> {
|
fn send(&mut self, message: &CultNetMessage) -> Result<Vec<u32>> {
|
||||||
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
|
let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?;
|
||||||
self.send_payload(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sends `payload` as one reliable, ordered schema frame; returns its sequences.
|
|
||||||
fn send_payload(&mut self, payload: Vec<u8>) -> Result<Vec<u32>> {
|
|
||||||
let options = cultnet_rs::CultNetRudpSendOptions {
|
let options = cultnet_rs::CultNetRudpSendOptions {
|
||||||
reliable: true,
|
reliable: true,
|
||||||
ordered: true,
|
ordered: true,
|
||||||
|
|
@ -1722,14 +1525,9 @@ fn a_refusal_reaches_a_peer_missing_an_earlier_packet() -> Result<()> {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
refusals,
|
refusals,
|
||||||
vec![CultNetMessage::Error {
|
vec![CultNetMessage::Error {
|
||||||
error: "write_not_authorized: the catalog refused the document".into(),
|
error: "the catalog refused the document".into(),
|
||||||
code: Some(CultNetErrorCode::WriteNotAuthorized),
|
code: None,
|
||||||
details: Some(CultNetErrorDetails {
|
details: None,
|
||||||
field: Some("recordKey".into()),
|
|
||||||
value: Some(document("refused", vec![4, 5, 6]).record_key),
|
|
||||||
as_of: None,
|
|
||||||
current: None,
|
|
||||||
}),
|
|
||||||
}]
|
}]
|
||||||
);
|
);
|
||||||
assert!(disconnected, "the refusal is followed by a goodbye");
|
assert!(disconnected, "the refusal is followed by a goodbye");
|
||||||
|
|
@ -1921,138 +1719,3 @@ fn a_response_that_is_too_large_to_send_is_named_emsgsize() -> Result<()> {
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A peer on `server` that has sent seven unreadable frames, each answered and processed, and then the
|
|
||||||
/// eighth, whose answer is waiting at its socket. Returns the peer and the eighth frame's sequence.
|
|
||||||
fn peer_at_the_cap(server: &mut Server, connection_id: u32) -> Result<(RawPeer, u32)> {
|
|
||||||
let mut peer = RawPeer::connect(server, connection_id)?;
|
|
||||||
for _ in 0..7 {
|
|
||||||
peer.send_payload(garbage())?;
|
|
||||||
assert!(matches!(
|
|
||||||
server.poll_once()?,
|
|
||||||
CultMeshRudpPollOutcome::ApplicationRejected(_)
|
|
||||||
));
|
|
||||||
for packet in peer.drain() {
|
|
||||||
peer.session.receive(&packet, 3)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let capped = peer.send_payload(garbage())?[0];
|
|
||||||
assert!(matches!(
|
|
||||||
server.poll_once()?,
|
|
||||||
CultMeshRudpPollOutcome::ApplicationRejected(_)
|
|
||||||
));
|
|
||||||
Ok((peer, capped))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_goodbye(packet: &cultnet_rs::CultNetRudpPacket) -> bool {
|
|
||||||
packet.packet_type == cultnet_rs::CultNetRudpPacketType::Disconnect
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_answer(packet: &cultnet_rs::CultNetRudpPacket) -> bool {
|
|
||||||
packet.packet_type == cultnet_rs::CultNetRudpPacketType::Data && packet.reliable
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The capped frame's answer is reliable: its datagram is lost, the server resends it, and only once the
|
|
||||||
/// peer has acknowledged it does the goodbye follow. Neither transmission acknowledges the capped frame.
|
|
||||||
#[test]
|
|
||||||
fn a_lost_capped_answer_is_resent_and_the_goodbye_waits_for_its_acknowledgement() -> Result<()> {
|
|
||||||
let clock = Clock::new(81_000);
|
|
||||||
let mut server = server(Default::default(), clock.clone(), Sink::default(), Source::default())?;
|
|
||||||
let (mut peer, capped) = peer_at_the_cap(&mut server, 81)?;
|
|
||||||
|
|
||||||
let first = peer.drain();
|
|
||||||
assert!(first.iter().any(is_answer), "fixture: the answer was sent, and is lost here");
|
|
||||||
assert!(!first.iter().any(is_goodbye), "no goodbye before the answer is acknowledged");
|
|
||||||
assert!(
|
|
||||||
!first.iter().any(|packet| acknowledges(packet, capped)),
|
|
||||||
"nothing sent for the capped frame acknowledges it"
|
|
||||||
);
|
|
||||||
assert_eq!(server.session_count(), 1);
|
|
||||||
|
|
||||||
clock.set_monotonic(81_000 + 60);
|
|
||||||
server.maintain()?;
|
|
||||||
let resent = peer.drain();
|
|
||||||
let answer = resent.iter().find(|packet| is_answer(packet)).expect("the lost answer is resent");
|
|
||||||
assert!(!acknowledges(answer, capped), "the resent answer acknowledges nothing the peer sent");
|
|
||||||
assert!(!resent.iter().any(is_goodbye), "still no goodbye");
|
|
||||||
let delivered = peer.session.receive(answer, 4)?.delivered;
|
|
||||||
let Ok(CultNetMessage::Error { code, .. }) =
|
|
||||||
decode_cultnet_message_from_slice(&delivered[0].payload, CultNetWireContract::CultNetSchemaV0)
|
|
||||||
else {
|
|
||||||
panic!("the resent answer is a typed refusal");
|
|
||||||
};
|
|
||||||
assert_eq!(code, Some(CultNetErrorCode::MessageInvalid));
|
|
||||||
|
|
||||||
peer.send_packet(&peer.session.create_ack_for(answer.sequence))?;
|
|
||||||
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
|
|
||||||
assert!(peer.drain().iter().any(is_goodbye), "acknowledged: the goodbye follows");
|
|
||||||
assert_eq!(server.session_count(), 0);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A peer that never acknowledges the answer is not kept for ever: the session ends after the grace.
|
|
||||||
#[test]
|
|
||||||
fn a_capped_session_whose_answer_is_never_acknowledged_ends_after_the_grace() -> Result<()> {
|
|
||||||
let clock = Clock::new(82_000);
|
|
||||||
let mut server = server(Default::default(), clock.clone(), Sink::default(), Source::default())?;
|
|
||||||
let (peer, _) = peer_at_the_cap(&mut server, 82)?;
|
|
||||||
peer.drain();
|
|
||||||
|
|
||||||
clock.set_monotonic(82_000 + 1_999);
|
|
||||||
server.maintain()?;
|
|
||||||
assert!(!peer.drain().iter().any(is_goodbye), "inside the grace");
|
|
||||||
assert_eq!(server.session_count(), 1);
|
|
||||||
clock.set_monotonic(82_000 + 2_000);
|
|
||||||
server.maintain()?;
|
|
||||||
assert!(peer.drain().iter().any(is_goodbye), "the grace ran out");
|
|
||||||
assert_eq!(server.session_count(), 0);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Nothing behind the capped frame is served while its answer is delivered.
|
|
||||||
#[test]
|
|
||||||
fn nothing_behind_the_capped_frame_is_served() -> Result<()> {
|
|
||||||
let sink = Sink::default();
|
|
||||||
let mut server = server(Default::default(), Clock::new(83_000), sink.clone(), Source::default())?;
|
|
||||||
let (mut peer, _) = peer_at_the_cap(&mut server, 83)?;
|
|
||||||
peer.send_payload(valid_put("behind-the-cap"))?;
|
|
||||||
for _ in 0..3 {
|
|
||||||
server.poll_once()?;
|
|
||||||
}
|
|
||||||
assert!(sink.0.lock().unwrap().receipts.is_empty(), "the put behind the cap was not stored");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A Ping inside the grace is answered with a Pong that acknowledges nothing: its ack fields are built from
|
|
||||||
/// what the session received, which includes the capped frame.
|
|
||||||
#[test]
|
|
||||||
fn a_pong_in_the_grace_after_the_cap_acknowledges_nothing() -> Result<()> {
|
|
||||||
let mut server = server(Default::default(), Clock::new(84_000), Sink::default(), Source::default())?;
|
|
||||||
let (mut peer, capped) = peer_at_the_cap(&mut server, 84)?;
|
|
||||||
peer.drain();
|
|
||||||
|
|
||||||
let ping = peer.session.create_ping(b"alive".to_vec());
|
|
||||||
peer.send_packet(&ping)?;
|
|
||||||
assert_eq!(server.poll_once()?, CultMeshRudpPollOutcome::Handled);
|
|
||||||
let pong = peer
|
|
||||||
.drain()
|
|
||||||
.into_iter()
|
|
||||||
.find(|packet| packet.packet_type == cultnet_rs::CultNetRudpPacketType::Pong)
|
|
||||||
.expect("the ping is answered");
|
|
||||||
assert_eq!(pong.payload, b"alive");
|
|
||||||
assert!(!acknowledges(&pong, capped), "the Pong acknowledges the capped frame");
|
|
||||||
assert_eq!((pong.ack, pong.ack_mask), (0, 0));
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The grace deadline saturates: at the end of the clock the answer is not given up on at once.
|
|
||||||
#[test]
|
|
||||||
fn the_grace_deadline_does_not_wrap_at_the_end_of_the_clock() -> Result<()> {
|
|
||||||
let mut server = server(Default::default(), Clock::new(u64::MAX - 10), Sink::default(), Source::default())?;
|
|
||||||
let (peer, _) = peer_at_the_cap(&mut server, 85)?;
|
|
||||||
peer.drain();
|
|
||||||
server.maintain()?;
|
|
||||||
assert!(!peer.drain().iter().any(is_goodbye), "the deadline wrapped and the session ended before its grace");
|
|
||||||
assert_eq!(server.session_count(), 1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,18 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
|
All notable changes to `cultmesh-ts` are recorded here, following
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`.
|
|
||||||
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
|
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
|
||||||
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
|
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
|
||||||
- `createRudpDocumentServer` takes `writeAuthorityTimeoutMs` (default `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`, 5000; a value outside 1..2147483647 throws `RangeError`): a write authority whose `decide()` has not settled by then refuses the write like one that throws (`write_not_authorized`, and `onError` hears of it), so a hung authority no longer holds up the session's later frames.
|
|
||||||
- `createRudpDocumentServer` refuses every peer put and delete unless it is given a `writeAuthority`. A refused write is answered with the typed `write_not_authorized` naming the record key (the bytes pinned in `error-vectors.json`), and `onDocumentPutRaw` is never called; pass `writeAuthority: new CultMeshGrantAllWrites()` to keep the old accept-everything behaviour. The authority is asked before the put is sized or decoded and before any handler runs, with the canonical schema id and the peer's address (the schema id is resolved to its canonical form first, which is the timing residual below); one that throws, rejects or answers anything but `granted: true` refuses. A frame the server cannot read is judged as the write it looks like (a put when it does not decode at all), so an ungranted peer gets `write_not_authorized` for it too; a granted peer gets `onError` and a typed `message_invalid` refusal (`details.field` is `message`). A put carrying a wrong-typed `shardId` or `shardEpoch` is judged with null shard fields and, once granted, refused with `message_invalid` naming that field. A refusal's record key and reason are clipped (64 and 96 UTF-8 bytes) so the refusal is at most 309 bytes; a `maxPayloadBytes` below that cannot carry it, and the peer then hears nothing (the failure goes to `onError`). Residual: ungranted peers can still distinguish registered from unregistered schemas by timing. A peer `document_delete` now reaches the new `onDocumentDeleteRaw` once granted (without it, a text refusal) instead of the unsupported-request error.
|
|
||||||
- Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`.
|
- Adding a document or collection to a catalog under a schema id or schema name another type already holds is refused with `SchemaConflictError`.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `createRudpDocumentServer` ends a session after `CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES` (8) consecutive undecodable frames or consecutive deletes the write authority refuses; fewer are answered and the session is kept. Any decoded frame resets the undecodable run, any decoded non-refused-delete frame resets the delete run. The same cap holds in every runtime.
|
|
||||||
- `CultMeshCatalogValue<TDocument, TSchema>`, exported.
|
- `CultMeshCatalogValue<TDocument, TSchema>`, exported.
|
||||||
- `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type.
|
- `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# CultMesh TS
|
# CultMesh TS
|
||||||
|
|
||||||
`@gamecult/cultmesh-ts` is the TypeScript projection of CultMesh contracts for local
|
`cultmesh-ts` is the TypeScript projection of CultMesh contracts for local
|
||||||
tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the
|
tools and browser-adjacent runtimes. The C# `GameCult.Mesh` package owns the
|
||||||
native CultMesh stream implementation used by Mimir and Fensalir; this package
|
native CultMesh stream implementation used by Mimir and Fensalir; this package
|
||||||
mirrors the typed declaration and negotiation shapes without owning the hot
|
mirrors the typed declaration and negotiation shapes without owning the hot
|
||||||
|
|
@ -21,7 +21,7 @@ WASM route. Runtime code can ask for a whole XY rect, then adapt it to the
|
||||||
current wire schema at the edge:
|
current wire schema at the edge:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { CultMesh } from "@gamecult/cultmesh-ts";
|
import { CultMesh } from "cultmesh-ts";
|
||||||
|
|
||||||
const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300);
|
const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300);
|
||||||
const request = CultMesh.viewportRequest(viewport, [3, 8]);
|
const request = CultMesh.viewportRequest(viewport, [3, 8]);
|
||||||
|
|
@ -215,7 +215,7 @@ const stopFeed = viewportFeed.watch(request, "browser-starfire", render);
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { CultMesh } from "@gamecult/cultmesh-ts";
|
import { CultMesh } from "cultmesh-ts";
|
||||||
|
|
||||||
const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>();
|
const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>();
|
||||||
const stopWatching = verses.watch((verse) => {
|
const stopWatching = verses.watch((verse) => {
|
||||||
|
|
@ -274,7 +274,7 @@ const noteShards = shards.list({
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Schema and shard catalog factories delegate to `@gamecult/cultnet-ts`; the branded
|
Schema and shard catalog factories delegate to `cultnet-ts`; the branded
|
||||||
`CultMesh` entrypoint does not create a second owner for schema discovery or
|
`CultMesh` entrypoint does not create a second owner for schema discovery or
|
||||||
topology truth.
|
topology truth.
|
||||||
|
|
||||||
|
|
@ -290,7 +290,7 @@ command transactions; the injected transport owns CultNet wire details.
|
||||||
import {
|
import {
|
||||||
CultMeshProviderRudpTransport,
|
CultMeshProviderRudpTransport,
|
||||||
CultMeshProviderSession,
|
CultMeshProviderSession,
|
||||||
} from "@gamecult/cultmesh-ts";
|
} from "cultmesh-ts";
|
||||||
|
|
||||||
const odinTransport = new CultMeshProviderRudpTransport({
|
const odinTransport = new CultMeshProviderRudpTransport({
|
||||||
endpoint: "rudp://127.0.0.1:17871",
|
endpoint: "rudp://127.0.0.1:17871",
|
||||||
|
|
@ -345,11 +345,9 @@ evidence for the requested provider identity.
|
||||||
|
|
||||||
## RUDP Helpers
|
## RUDP Helpers
|
||||||
|
|
||||||
`createRudpDocumentServer` refuses every peer put and delete unless given a `writeAuthority` (`new CultMeshGrantAllWrites()` accepts all). The authority is asked before a write's payload is sized or decoded and before any handler runs, and a refusal is the typed `write_not_authorized`. Residual: ungranted peers can still distinguish registered from unregistered schemas by timing.
|
|
||||||
|
|
||||||
Node runtimes can build the shared CultNet reliable-UDP transport from the
|
Node runtimes can build the shared CultNet reliable-UDP transport from the
|
||||||
branded CultMesh entrypoint while the transport semantics stay owned by
|
branded CultMesh entrypoint while the transport semantics stay owned by
|
||||||
`@gamecult/cultnet-ts`:
|
`cultnet-ts`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040);
|
const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040);
|
||||||
|
|
@ -474,7 +472,7 @@ The split is deliberate:
|
||||||
The mirrored API shape is `CultMeshStreamCatalog`:
|
The mirrored API shape is `CultMeshStreamCatalog`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { CultMesh } from "@gamecult/cultmesh-ts";
|
import { CultMesh } from "cultmesh-ts";
|
||||||
|
|
||||||
const streams = CultMesh.createStreamCatalog();
|
const streams = CultMesh.createStreamCatalog();
|
||||||
const stopWatchingStreams = streams.watch((stream) => {
|
const stopWatchingStreams = streams.watch((stream) => {
|
||||||
|
|
@ -535,7 +533,7 @@ nonlocal fallback.
|
||||||
|
|
||||||
## Content Chunks over HTTP(S)
|
## Content Chunks over HTTP(S)
|
||||||
|
|
||||||
`@gamecult/cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD`
|
`cultmesh-ts/content` answers the C# HTTPS connector's request, `GET` or `HEAD`
|
||||||
`<mount>?chunkHash=<hex>&recordKey=<key>`. The mount path is the host's; the
|
`<mount>?chunkHash=<hex>&recordKey=<key>`. The mount path is the host's; the
|
||||||
handler reads only the query. Every chunk is hashed against its name before a
|
handler reads only the query. Every chunk is hashed against its name before a
|
||||||
byte is sent, so a corrupt or swapped file answers 500 and never leaves the
|
byte is sent, so a corrupt or swapped file answers 500 and never leaves the
|
||||||
|
|
@ -552,7 +550,7 @@ host store may reuse its buffer). `onServerFault` reports every 500 (`hash-misma
|
||||||
```ts
|
```ts
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { createServer } from "node:https";
|
import { createServer } from "node:https";
|
||||||
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "@gamecult/cultmesh-ts/content";
|
import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener } from "cultmesh-ts/content";
|
||||||
|
|
||||||
const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), {
|
const handler = createContentChunkHandler(createDirectoryChunkStore("/srv/content"), {
|
||||||
onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash),
|
onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@gamecult/cultmesh-ts",
|
"name": "cultmesh-ts",
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.",
|
"description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
@ -48,8 +48,8 @@
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gamecult/cultcache-ts": "^0.15.0",
|
"@gamecult/cultcache-ts": "^0.14.0",
|
||||||
"@gamecult/cultnet-ts": "^0.3.0",
|
"cultnet-ts": "^0.3.0",
|
||||||
"koffi": "3.3.0"
|
"koffi": "3.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { constants } from "node:fs";
|
||||||
import { open } from "node:fs/promises";
|
import { open } from "node:fs/promises";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "@gamecult/cultnet-ts/content";
|
import { DEFAULT_CHUNK_SIZE_BYTES, EMPTY_SHA256, chunkRecordKey, isNullOrWhiteSpaceCSharp, parseSha256Hex, sha256Hex } from "cultnet-ts/content";
|
||||||
|
|
||||||
/** What the provider reads of a request. The path and the headers are never read: the route is the query alone. */
|
/** What the provider reads of a request. The path and the headers are never read: the route is the query alone. */
|
||||||
export interface ContentChunkRequest { method: string; url: string; }
|
export interface ContentChunkRequest { method: string; url: string; }
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createSocket, type Socket } from "node:dgram";
|
import { createSocket, type Socket } from "node:dgram";
|
||||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CultCache,
|
CultCache,
|
||||||
|
|
@ -20,9 +20,6 @@ import {
|
||||||
CultNetRudpSocketTransportConnection,
|
CultNetRudpSocketTransportConnection,
|
||||||
rudpClientBindHost,
|
rudpClientBindHost,
|
||||||
CultNetSchemaCatalog,
|
CultNetSchemaCatalog,
|
||||||
cultNetMessageInvalidError,
|
|
||||||
cultNetTextError,
|
|
||||||
cultNetWriteNotAuthorizedError,
|
|
||||||
CultNetShardCatalog,
|
CultNetShardCatalog,
|
||||||
cultNetBuiltinSchemaRegistry,
|
cultNetBuiltinSchemaRegistry,
|
||||||
decodeRudpPacket,
|
decodeRudpPacket,
|
||||||
|
|
@ -34,7 +31,6 @@ import {
|
||||||
sendRudpDatagram,
|
sendRudpDatagram,
|
||||||
type CultNetErrorMessage,
|
type CultNetErrorMessage,
|
||||||
type CultNetDocumentBinding,
|
type CultNetDocumentBinding,
|
||||||
type CultNetDocumentDeleteMessage,
|
|
||||||
type CultNetMessage,
|
type CultNetMessage,
|
||||||
type CultNetOperationRequestMessage,
|
type CultNetOperationRequestMessage,
|
||||||
type CultNetOperationResponseMessage,
|
type CultNetOperationResponseMessage,
|
||||||
|
|
@ -44,7 +40,7 @@ import {
|
||||||
type CultNetSchemaCatalogOptions,
|
type CultNetSchemaCatalogOptions,
|
||||||
type CultNetSnapshotResponseRawMessage,
|
type CultNetSnapshotResponseRawMessage,
|
||||||
type CultNetWireContract,
|
type CultNetWireContract,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CultMeshQuicRealtimeConnector,
|
CultMeshQuicRealtimeConnector,
|
||||||
|
|
@ -3995,63 +3991,6 @@ export type CultMeshRudpDocumentPut = {
|
||||||
| { readonly decoded: false; readonly payload: DocumentPayload }
|
| { readonly decoded: false; readonly payload: DocumentPayload }
|
||||||
);
|
);
|
||||||
|
|
||||||
/** The peer a write arrived from: the datagram's source, nothing the peer claimed. */
|
|
||||||
export interface CultMeshRemoteWritePeer {
|
|
||||||
address: string;
|
|
||||||
family: string;
|
|
||||||
port: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type CultMeshRemoteWriteOperation = "put" | "delete";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One peer write as the authority sees it. `schemaId` is the canonical id the registry resolved for the write (the id
|
|
||||||
* as sent when no binding takes it), never an alias the peer used when a binding resolves it. `shardId` and
|
|
||||||
* `shardEpoch` are as a put carried them, `null` when it sent none (always `null` for a delete, which has none); a
|
|
||||||
* put carrying either with the wrong type is refused before any authority is asked.
|
|
||||||
*/
|
|
||||||
export interface CultMeshRemoteWriteRequest {
|
|
||||||
peer: CultMeshRemoteWritePeer;
|
|
||||||
operation: CultMeshRemoteWriteOperation;
|
|
||||||
schemaId: string;
|
|
||||||
recordKey: string;
|
|
||||||
shardId: string | null;
|
|
||||||
shardEpoch: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CultMeshRemoteWriteDecision {
|
|
||||||
granted: boolean;
|
|
||||||
/** Told to the refused peer after `write_not_authorized:`; keep it free of anything secret. */
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The one question a document server asks before it applies a peer's put or delete (C# `ICultNetRemoteWriteAuthority`).
|
|
||||||
* A server with no authority refuses every peer write; one whose authority throws, rejects or answers anything but
|
|
||||||
* `granted: true` refuses too.
|
|
||||||
*/
|
|
||||||
export interface CultMeshRemoteWriteAuthority {
|
|
||||||
decide(request: CultMeshRemoteWriteRequest): CultMeshRemoteWriteDecision | Promise<CultMeshRemoteWriteDecision>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grants every peer write. For a host that has already authenticated its peers, or a test rig: it is an explicit
|
|
||||||
* choice because serving without an authority refuses writes.
|
|
||||||
*/
|
|
||||||
export class CultMeshGrantAllWrites implements CultMeshRemoteWriteAuthority {
|
|
||||||
decide(): CultMeshRemoteWriteDecision {
|
|
||||||
return { granted: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A delete as `onDocumentDeleteRaw` receives it, after the authority granted it. */
|
|
||||||
export interface CultMeshRudpDocumentDelete {
|
|
||||||
messageId: string;
|
|
||||||
schemaId: string;
|
|
||||||
recordKey: string;
|
|
||||||
remote: CultMeshRemoteWritePeer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDocumentDefinition = AnyCultCacheDocumentDefinition> {
|
export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDocumentDefinition = AnyCultCacheDocumentDefinition> {
|
||||||
binding: CultNetDocumentBinding<TDefinition>;
|
binding: CultNetDocumentBinding<TDefinition>;
|
||||||
recordKey: string;
|
recordKey: string;
|
||||||
|
|
@ -4091,24 +4030,10 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
|
||||||
documents: CultNetDocumentRegistry;
|
documents: CultNetDocumentRegistry;
|
||||||
getCache?: () => Promise<CultCache> | CultCache;
|
getCache?: () => Promise<CultCache> | CultCache;
|
||||||
onError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
/**
|
|
||||||
* Who may put and delete. Without it every peer put and delete is refused with a typed `write_not_authorized`
|
|
||||||
* (and `onDocumentPutRaw` is never called); pass `new CultMeshGrantAllWrites()` to accept them all. The authority is
|
|
||||||
* asked before the document is sized, resolved or decoded.
|
|
||||||
*/
|
|
||||||
writeAuthority?: CultMeshRemoteWriteAuthority;
|
|
||||||
/**
|
|
||||||
* How long the write authority has to answer before the write is refused (default
|
|
||||||
* `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS`). A `decide()` that never settles is a refusal like any
|
|
||||||
* other, so it cannot hold up the frames that follow it on the session.
|
|
||||||
*/
|
|
||||||
writeAuthorityTimeoutMs?: number;
|
|
||||||
onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) =>
|
onDocumentPutRaw?: (document: CultMeshRudpDocumentPut) =>
|
||||||
| CultMeshRudpDocumentReceipt
|
| CultMeshRudpDocumentReceipt
|
||||||
| void
|
| void
|
||||||
| Promise<CultMeshRudpDocumentReceipt | void>;
|
| Promise<CultMeshRudpDocumentReceipt | void>;
|
||||||
/** Applies a delete the authority granted. Without it a granted delete is answered with a text refusal. */
|
|
||||||
onDocumentDeleteRaw?: (request: CultMeshRudpDocumentDelete) => void | Promise<void>;
|
|
||||||
onOperationRequest?: (
|
onOperationRequest?: (
|
||||||
request: CultNetOperationRequestMessage,
|
request: CultNetOperationRequestMessage,
|
||||||
session: CultMeshRudpServerSession,
|
session: CultMeshRudpServerSession,
|
||||||
|
|
@ -4118,19 +4043,6 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt
|
||||||
sessionTimeoutMs?: number;
|
sessionTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A write authority that has not answered in this long has refused the write. */
|
|
||||||
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS = 5_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many consecutive refused frames of one kind a `createRudpDocumentServer` session may send before it is
|
|
||||||
* ended: undecodable frames (reset by any frame that decodes) and deletes the write authority refuses (reset by any
|
|
||||||
* decoded frame that is not a refused delete). Fewer are answered with a typed refusal and the session is kept; the
|
|
||||||
* frame that reaches this number is answered too, then the session ends. The same number is the cap in every runtime.
|
|
||||||
*/
|
|
||||||
export const CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES = 8;
|
|
||||||
/** How long a session whose capped frame was answered waits for the peer to acknowledge the answers before it ends anyway. */
|
|
||||||
const CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS = 2000;
|
|
||||||
|
|
||||||
/** Replies larger than one fragment are split into fragments of at most this many bytes. */
|
/** Replies larger than one fragment are split into fragments of at most this many bytes. */
|
||||||
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200;
|
export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200;
|
||||||
|
|
||||||
|
|
@ -4183,29 +4095,7 @@ export interface CultMeshRudpDocumentServer {
|
||||||
close(): void;
|
close(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A write the peer refused: the typed `cultnet.error.v0` it answered with (`write_not_authorized`,
|
|
||||||
* `message_invalid`, ...), whichever of the transport ack and the result frame came first.
|
|
||||||
*/
|
|
||||||
export class CultMeshRudpWriteRefusedError extends Error {
|
|
||||||
readonly code: string | undefined;
|
|
||||||
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>;
|
|
||||||
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>) {
|
|
||||||
super(response.error);
|
|
||||||
this.name = "CultMeshRudpWriteRefusedError";
|
|
||||||
this.code = response.code ?? undefined;
|
|
||||||
this.response = response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions {
|
export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions {
|
||||||
/**
|
|
||||||
* How long after the put's transport ack a refusal may still arrive. A reliable-transport ack is not
|
|
||||||
* admission; the outcome is the peer's result frame. A server that answers a put only on refusal sends no
|
|
||||||
* success frame, so the put is reported published once this window passes with no refusal; a success reply
|
|
||||||
* ends the wait early. Default 500 ms.
|
|
||||||
*/
|
|
||||||
resultWindowMs?: number;
|
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
sourceRuntimeId?: string;
|
sourceRuntimeId?: string;
|
||||||
sourceAgentId?: string;
|
sourceAgentId?: string;
|
||||||
|
|
@ -5569,11 +5459,6 @@ export class CultMesh {
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
/** A packet it owed could never be sent, so the session ended. */
|
/** A packet it owed could never be sent, so the session ended. */
|
||||||
unsendable: boolean;
|
unsendable: boolean;
|
||||||
/** Consecutive undecodable frames, and consecutive deletes the authority refused. */
|
|
||||||
unreadableInARow: number;
|
|
||||||
refusedDeletesInARow: number;
|
|
||||||
/** Set once the peer's capped frame is answered: the session ends when the answers are acknowledged, or at this time. */
|
|
||||||
endingDeadlineMs?: number;
|
|
||||||
work: Promise<void>;
|
work: Promise<void>;
|
||||||
};
|
};
|
||||||
const sessions = new Map<string, SessionRecord>();
|
const sessions = new Map<string, SessionRecord>();
|
||||||
|
|
@ -5581,11 +5466,6 @@ export class CultMesh {
|
||||||
let sendFailures = 0;
|
let sendFailures = 0;
|
||||||
const resendPollMs = Math.max(10, options.resendPollMs ?? 25);
|
const resendPollMs = Math.max(10, options.resendPollMs ?? 25);
|
||||||
const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000);
|
const sessionTimeoutMs = Math.max(1_000, options.sessionTimeoutMs ?? 30_000);
|
||||||
const decideTimeoutMs = options.writeAuthorityTimeoutMs ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_DECIDE_TIMEOUT_MS;
|
|
||||||
// A timer past 2^31-1 ms (or NaN) fires after 1 ms in Node, which would refuse every write that is not instant.
|
|
||||||
if (!(decideTimeoutMs > 0 && decideTimeoutMs <= 0x7fffffff)) {
|
|
||||||
throw new RangeError("CultMesh RUDP document server writeAuthorityTimeoutMs must be greater than zero and at most 2147483647.");
|
|
||||||
}
|
|
||||||
const wireContract = options.wireContract ?? "cultnet.schema.v0";
|
const wireContract = options.wireContract ?? "cultnet.schema.v0";
|
||||||
const maxFragmentBytes = options.maxFragmentBytes ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES;
|
const maxFragmentBytes = options.maxFragmentBytes ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES;
|
||||||
if (!(maxFragmentBytes > 0)) {
|
if (!(maxFragmentBytes > 0)) {
|
||||||
|
|
@ -5628,8 +5508,6 @@ export class CultMesh {
|
||||||
connectPayload,
|
connectPayload,
|
||||||
closed: false,
|
closed: false,
|
||||||
unsendable: false,
|
unsendable: false,
|
||||||
unreadableInARow: 0,
|
|
||||||
refusedDeletesInARow: 0,
|
|
||||||
session: new CultNetRudpSession({
|
session: new CultNetRudpSession({
|
||||||
connectionId,
|
connectionId,
|
||||||
initialSequence: options.initialSequence,
|
initialSequence: options.initialSequence,
|
||||||
|
|
@ -5694,36 +5572,15 @@ export class CultMesh {
|
||||||
record: SessionRecord,
|
record: SessionRecord,
|
||||||
payload: Uint8Array,
|
payload: Uint8Array,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Nothing after the capped frame is served while its answer is delivered.
|
if (record.closed) return;
|
||||||
if (record.closed || record.endingDeadlineMs !== undefined) return;
|
const message = parseCultNetMessage(decodeWireValue(payload), wireContract);
|
||||||
let decoded: unknown;
|
|
||||||
let message: CultNetMessage;
|
|
||||||
try {
|
|
||||||
decoded = decodeWireValue(payload);
|
|
||||||
message = parseCultNetMessage(decoded, wireContract);
|
|
||||||
} catch (error) {
|
|
||||||
// A frame the server cannot read is judged as the write it looks like (a put when it does not even decode), so
|
|
||||||
// an ungranted peer hears the same refusal for it as for any other write, not a parse error.
|
|
||||||
const write = readableWrite(decoded);
|
|
||||||
record.unreadableInARow += 1;
|
|
||||||
if (decoded !== undefined && !write) {
|
|
||||||
// It decodes but is no message the parser takes (an unknown schemaVersion, a non-map) and is no write the
|
|
||||||
// authority could judge: answered and counted like any other unreadable frame.
|
|
||||||
reportError(error);
|
|
||||||
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the message."));
|
|
||||||
} else if (!await writeRefused(record, write?.operation ?? "put", write?.schemaId ?? "", write?.recordKey ?? "", null, null)) {
|
|
||||||
reportError(error);
|
|
||||||
sendSchemaMessage(record, cultNetMessageInvalidError("message", "CultMesh RUDP document server could not read the write."));
|
|
||||||
}
|
|
||||||
if (record.unreadableInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
record.unreadableInARow = 0;
|
|
||||||
if (message.schemaVersion !== "cultnet.document_delete.v0") record.refusedDeletesInARow = 0;
|
|
||||||
switch (message.schemaVersion) {
|
switch (message.schemaVersion) {
|
||||||
case "cultnet.snapshot_request.v0": {
|
case "cultnet.snapshot_request.v0": {
|
||||||
if (!options.getCache) {
|
if (!options.getCache) {
|
||||||
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no cache for snapshot requests."));
|
sendSchemaMessage(record, {
|
||||||
|
schemaVersion: "cultnet.error.v0",
|
||||||
|
error: "CultMesh RUDP document server has no cache for snapshot requests.",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let reply: CultNetMessage;
|
let reply: CultNetMessage;
|
||||||
|
|
@ -5752,32 +5609,11 @@ export class CultMesh {
|
||||||
sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message));
|
sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message));
|
||||||
return;
|
return;
|
||||||
case "cultnet.document_put_raw.v0":
|
case "cultnet.document_put_raw.v0":
|
||||||
{
|
|
||||||
// The parser passes shard fields of any type through a put. The authority is asked first, with the shard
|
|
||||||
// fields it can trust (null when wrong-typed), so an ungranted peer hears only the refusal; a granted peer
|
|
||||||
// whose shard fields are wrong-typed is then refused as malformed.
|
|
||||||
const { shardId, shardEpoch } = message as { shardId?: unknown; shardEpoch?: unknown };
|
|
||||||
const malformedShard =
|
|
||||||
(shardId !== undefined && typeof shardId !== "string") ||
|
|
||||||
(shardEpoch !== undefined && !Number.isSafeInteger(shardEpoch));
|
|
||||||
if (await writeRefused(
|
|
||||||
record, "put", message.document.schemaId, message.document.recordKey,
|
|
||||||
malformedShard ? null : (shardId as string | undefined) ?? null,
|
|
||||||
malformedShard ? null : (shardEpoch as number | undefined) ?? null,
|
|
||||||
)) return;
|
|
||||||
if (malformedShard) {
|
|
||||||
sendSchemaMessage(record, cultNetMessageInvalidError(
|
|
||||||
typeof shardId !== "undefined" && typeof shardId !== "string" ? "shardId" : "shardEpoch",
|
|
||||||
"Raw put messages must carry a string shardId and an integer shardEpoch.",
|
|
||||||
));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (options.onDocumentPutRaw) {
|
if (options.onDocumentPutRaw) {
|
||||||
const unservable = unservablePut(message);
|
const unservable = unservablePut(message);
|
||||||
if (unservable) {
|
if (unservable) {
|
||||||
reportError(unservable);
|
reportError(unservable);
|
||||||
sendSchemaMessage(record, cultNetTextError(unservable.message));
|
sendSchemaMessage(record, { schemaVersion: "cultnet.error.v0", error: unservable.message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents));
|
const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents));
|
||||||
|
|
@ -5787,119 +5623,32 @@ export class CultMesh {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// The put is stored: the peer must hear that its receipt is not coming.
|
// The put is stored: the peer must hear that its receipt is not coming.
|
||||||
reportError(error);
|
reportError(error);
|
||||||
sendSchemaMessage(record, cultNetTextError(`CultMesh RUDP document server stored a put but could not send its receipt.`));
|
sendSchemaMessage(record, {
|
||||||
|
schemaVersion: "cultnet.error.v0",
|
||||||
|
error: `CultMesh RUDP document server stored a put but could not send its receipt.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case "cultnet.document_delete.v0": {
|
|
||||||
if (await writeRefused(record, "delete", message.schemaId, message.recordKey, null, null)) {
|
|
||||||
record.refusedDeletesInARow += 1;
|
|
||||||
if (record.refusedDeletesInARow >= CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES) endCappedSession(record);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
record.refusedDeletesInARow = 0;
|
|
||||||
if (!options.onDocumentDeleteRaw) {
|
|
||||||
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no delete handler."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await options.onDocumentDeleteRaw({
|
|
||||||
messageId: message.messageId,
|
|
||||||
schemaId: message.schemaId,
|
|
||||||
recordKey: message.recordKey,
|
|
||||||
remote: { ...record.remote },
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
case "cultnet.operation_request.v0":
|
case "cultnet.operation_request.v0":
|
||||||
if (!options.onOperationRequest) {
|
if (!options.onOperationRequest) {
|
||||||
sendSchemaMessage(record, cultNetTextError("CultMesh RUDP document server has no operation handler."));
|
sendSchemaMessage(record, {
|
||||||
|
schemaVersion: "cultnet.error.v0",
|
||||||
|
error: "CultMesh RUDP document server has no operation handler.",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record)));
|
sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record)));
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
sendSchemaMessage(record, cultNetTextError(`Unsupported CultMesh RUDP document request ${message.schemaVersion}.`));
|
sendSchemaMessage(record, {
|
||||||
|
schemaVersion: "cultnet.error.v0",
|
||||||
|
error: `Unsupported CultMesh RUDP document request ${message.schemaVersion}.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clipUtf8(text: string, maxBytes: number): string {
|
|
||||||
const bytes = Buffer.from(text, "utf8");
|
|
||||||
return bytes.byteLength <= maxBytes ? text : new TextDecoder().decode(bytes.subarray(0, maxBytes)).replace(/\uFFFD+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The put or delete a decoded frame claims to be, with the ids it carries when they are strings. */
|
|
||||||
function readableWrite(value: unknown): { operation: CultMeshRemoteWriteOperation; schemaId: string; recordKey: string } | undefined {
|
|
||||||
if (typeof value !== "object" || value === null) return undefined;
|
|
||||||
const frame = value as { schemaVersion?: unknown; schemaId?: unknown; recordKey?: unknown; document?: unknown };
|
|
||||||
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
||||||
if (frame.schemaVersion === "cultnet.document_delete.v0") {
|
|
||||||
return { operation: "delete", schemaId: str(frame.schemaId), recordKey: str(frame.recordKey) };
|
|
||||||
}
|
|
||||||
if (frame.schemaVersion === "cultnet.document_put_raw.v0") {
|
|
||||||
const document = (typeof frame.document === "object" && frame.document !== null ? frame.document : {}) as { schemaId?: unknown; recordKey?: unknown };
|
|
||||||
return { operation: "put", schemaId: str(document.schemaId), recordKey: str(document.recordKey) };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Asks the write authority about a put or delete, before anything of the write is sized, resolved or decoded, and
|
|
||||||
* answers a refusal with the typed `write_not_authorized` naming the record. True when the write is refused.
|
|
||||||
* No authority refuses; an authority that throws, rejects or does not say `granted: true` refuses too (fail
|
|
||||||
* closed), and a failure is reported without quoting it.
|
|
||||||
*/
|
|
||||||
async function writeRefused(
|
|
||||||
record: SessionRecord,
|
|
||||||
operation: CultMeshRemoteWriteOperation,
|
|
||||||
schemaId: string,
|
|
||||||
recordKey: string,
|
|
||||||
shardId: string | null,
|
|
||||||
shardEpoch: number | null,
|
|
||||||
): Promise<boolean> {
|
|
||||||
let reason: string | undefined;
|
|
||||||
const authority = options.writeAuthority;
|
|
||||||
if (!authority) {
|
|
||||||
reason = "no remote write authority is configured";
|
|
||||||
} else {
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
try {
|
|
||||||
const timedOut = Symbol("decide timeout");
|
|
||||||
const decision = await Promise.race([
|
|
||||||
Promise.resolve().then(() => authority.decide({
|
|
||||||
peer: { ...record.remote },
|
|
||||||
operation,
|
|
||||||
schemaId: options.documents.canonicalSchemaId(schemaId) ?? schemaId,
|
|
||||||
recordKey,
|
|
||||||
shardId,
|
|
||||||
shardEpoch,
|
|
||||||
})),
|
|
||||||
new Promise<typeof timedOut>((resolve) => { timer = setTimeout(() => resolve(timedOut), decideTimeoutMs); }),
|
|
||||||
]);
|
|
||||||
if (decision === timedOut) {
|
|
||||||
reportError(new Error(`CultMesh remote write authority did not answer within ${decideTimeoutMs} ms; the write was refused.`));
|
|
||||||
reason = "the write authority timed out";
|
|
||||||
} else {
|
|
||||||
if (decision?.granted === true) return false;
|
|
||||||
reason = typeof decision?.reason === "string" ? decision.reason : undefined;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
reportError(new Error(`CultMesh remote write authority failed (${error instanceof Error ? error.name : typeof error}); the write was refused.`));
|
|
||||||
reason = "the write authority failed";
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// The key is the peer's and the reason the authority's: both are clipped so the refusal fits `maxPayloadBytes`
|
|
||||||
// whatever they are (the peer is told the clipped key; a `maxPayloadBytes` under about 512 can still not carry it).
|
|
||||||
sendSchemaMessage(record, cultNetWriteNotAuthorizedError(clipUtf8(recordKey, 64), reason === undefined ? undefined : clipUtf8(reason, 96)));
|
|
||||||
} catch (error) {
|
|
||||||
reportError(error);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicSession(record: SessionRecord): CultMeshRudpServerSession {
|
function publicSession(record: SessionRecord): CultMeshRudpServerSession {
|
||||||
return {
|
return {
|
||||||
sessionId: record.sessionId,
|
sessionId: record.sessionId,
|
||||||
|
|
@ -5949,17 +5698,6 @@ export class CultMesh {
|
||||||
sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {});
|
sendRudpDatagram(socket, goodbye, record.remote.port, record.remote.address, () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A session that reached the cap of consecutive refused frames: its last refusal went out like any reply
|
|
||||||
* (reliable, so a lost datagram is resent), and the session ends once the peer has acknowledged every reply or
|
|
||||||
* the grace runs out. The resend timer ends it.
|
|
||||||
*/
|
|
||||||
function endCappedSession(record: SessionRecord): void {
|
|
||||||
const key = `${record.remote.address}:${record.remote.port}`;
|
|
||||||
if (sessions.get(key) !== record) return;
|
|
||||||
record.endingDeadlineMs ??= Date.now() + CULTMESH_RUDP_DOCUMENT_SERVER_CAPPED_ANSWER_GRACE_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The session owes its peer a packet that can never be sent as built. */
|
/** The session owes its peer a packet that can never be sent as built. */
|
||||||
function endUnsendableSession(record: SessionRecord, error: Error): void {
|
function endUnsendableSession(record: SessionRecord, error: Error): void {
|
||||||
if (record.unsendable) return;
|
if (record.unsendable) return;
|
||||||
|
|
@ -6003,7 +5741,7 @@ export class CultMesh {
|
||||||
*/
|
*/
|
||||||
function refuse(record: SessionRecord, text: string): void {
|
function refuse(record: SessionRecord, text: string): void {
|
||||||
if (record.closed) return;
|
if (record.closed) return;
|
||||||
const payload = encodeSchemaMessage(cultNetTextError(text));
|
const payload = encodeSchemaMessage({ schemaVersion: "cultnet.error.v0", error: text });
|
||||||
for (const packet of record.session.sendMany("schema", payload, {
|
for (const packet of record.session.sendMany("schema", payload, {
|
||||||
reliable: false,
|
reliable: false,
|
||||||
ordered: false,
|
ordered: false,
|
||||||
|
|
@ -6093,11 +5831,6 @@ export class CultMesh {
|
||||||
closeSession(record);
|
closeSession(record);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (record.endingDeadlineMs !== undefined
|
|
||||||
&& (record.session.outstandingReliablePacketCount === 0 || nowMs >= record.endingDeadlineMs)) {
|
|
||||||
endRefusedSession(key, record);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const packet of record.session.dueResends(nowMs)) {
|
for (const packet of record.session.dueResends(nowMs)) {
|
||||||
sendPacket(record, packet);
|
sendPacket(record, packet);
|
||||||
}
|
}
|
||||||
|
|
@ -6179,23 +5912,11 @@ export class CultMesh {
|
||||||
requireNonEmpty(runtimeId, "runtimeId");
|
requireNonEmpty(runtimeId, "runtimeId");
|
||||||
requireNonEmpty(recordKey, "recordKey");
|
requireNonEmpty(recordKey, "recordKey");
|
||||||
const registry = new CultNetDocumentRegistry([binding]);
|
const registry = new CultNetDocumentRegistry([binding]);
|
||||||
const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`;
|
|
||||||
const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
|
const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
|
||||||
try {
|
try {
|
||||||
// A transport ack is not admission: the put completes on its result frame. A refusal rejects with the
|
|
||||||
// typed error whether it arrives before the ack, after it, or inside the result window.
|
|
||||||
let refusal: CultMeshRudpWriteRefusedError | undefined;
|
|
||||||
let succeeded = false;
|
|
||||||
let wake: (() => void) | undefined;
|
|
||||||
peer.on("message", (message: CultNetMessage) => {
|
|
||||||
if (message.schemaVersion === "cultnet.error.v0") refusal ??= new CultMeshRudpWriteRefusedError(message);
|
|
||||||
else if ("messageId" in message && message.messageId === messageId) succeeded = true;
|
|
||||||
else return;
|
|
||||||
wake?.();
|
|
||||||
});
|
|
||||||
peer.send(registry.createRawDocumentPutMessage(
|
peer.send(registry.createRawDocumentPutMessage(
|
||||||
binding,
|
binding,
|
||||||
messageId,
|
options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`,
|
||||||
recordKey,
|
recordKey,
|
||||||
value,
|
value,
|
||||||
{
|
{
|
||||||
|
|
@ -6206,13 +5927,6 @@ export class CultMesh {
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
await peer.flush(options.flushTimeoutMs ?? 30_000);
|
await peer.flush(options.flushTimeoutMs ?? 30_000);
|
||||||
if (!refusal && !succeeded) {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
const timer = setTimeout(resolve, options.resultWindowMs ?? 500);
|
|
||||||
wake = () => { clearTimeout(timer); resolve(); };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (refusal) throw refusal;
|
|
||||||
} finally {
|
} finally {
|
||||||
peer.close();
|
peer.close();
|
||||||
}
|
}
|
||||||
|
|
@ -6231,7 +5945,6 @@ export class CultMesh {
|
||||||
const receipt = new Promise<CultCacheDocumentValue<TReceiptDefinition>>((resolve, reject) => {
|
const receipt = new Promise<CultCacheDocumentValue<TReceiptDefinition>>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000);
|
const timeout = setTimeout(() => { cleanup(); reject(new Error(`Timed out waiting for RUDP application receipt for ${recordKey}.`)); }, options.receiptTimeoutMs ?? 5_000);
|
||||||
const onMessage = (message: CultNetMessage): void => {
|
const onMessage = (message: CultNetMessage): void => {
|
||||||
if (message.schemaVersion === "cultnet.error.v0") { cleanup(); reject(new CultMeshRudpWriteRefusedError(message)); return; }
|
|
||||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return;
|
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || message.messageId !== messageId || message.document.schemaId !== receiptBinding.definition.schemaId) return;
|
||||||
try {
|
try {
|
||||||
const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload);
|
const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { createSocket, type Socket } from "node:dgram";
|
import { createSocket, type Socket } from "node:dgram";
|
||||||
import { documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
import { documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
||||||
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { encodeWireValue } from "cultnet-ts/wire";
|
||||||
import {
|
import {
|
||||||
CultNetPeer,
|
CultNetPeer,
|
||||||
CultNetRudpSocketTransportConnection,
|
CultNetRudpSocketTransportConnection,
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
type CultNetOperationRequestMessage,
|
type CultNetOperationRequestMessage,
|
||||||
type CultNetOperationResponseMessage,
|
type CultNetOperationResponseMessage,
|
||||||
type CultNetRawDocumentRecord,
|
type CultNetRawDocumentRecord,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
CultMeshProviderCommand,
|
CultMeshProviderCommand,
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import { encodeDocumentPayload } from "@gamecult/cultcache-ts";
|
import { encodeDocumentPayload } from "@gamecult/cultcache-ts";
|
||||||
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { encodeWireValue } from "cultnet-ts/wire";
|
||||||
import type {
|
import type {
|
||||||
CultNetDocumentPutRawMessage,
|
CultNetDocumentPutRawMessage,
|
||||||
CultNetOperationRequestMessage,
|
CultNetOperationRequestMessage,
|
||||||
CultNetOperationResponseMessage,
|
CultNetOperationResponseMessage,
|
||||||
CultNetRawDocumentRecord,
|
CultNetRawDocumentRecord,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
|
|
||||||
import type { CultMeshRudpServerSession } from "./index";
|
import type { CultMeshRudpServerSession } from "./index";
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { decodeDocumentPayload, defineDocumentType } from "@gamecult/cultcache-ts";
|
import { decodeDocumentPayload, defineDocumentType } from "@gamecult/cultcache-ts";
|
||||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||||
import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts";
|
import type { CultNetRawDocumentRecord } from "cultnet-ts";
|
||||||
|
|
||||||
export const CULTMESH_PROVIDER_SESSION_SERVICE_ID = "gamecult.mesh.provider_session";
|
export const CULTMESH_PROVIDER_SESSION_SERVICE_ID = "gamecult.mesh.provider_session";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import {
|
||||||
trimCSharp,
|
trimCSharp,
|
||||||
verifyAuthorityRoute,
|
verifyAuthorityRoute,
|
||||||
type CultMeshAuthorityTrustPolicy,
|
type CultMeshAuthorityTrustPolicy,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
import type { CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts";
|
import type { CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
decodeRealtimeFrame,
|
decodeRealtimeFrame,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
// agree with itself.
|
// agree with itself.
|
||||||
|
|
||||||
import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts";
|
import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts";
|
||||||
import { isNullOrWhiteSpaceCSharp } from "@gamecult/cultnet-ts";
|
import { isNullOrWhiteSpaceCSharp } from "cultnet-ts";
|
||||||
|
|
||||||
/** `CultMeshRealtimeWireProtocol.ApplicationProtocolName`: the QUIC ALPN. */
|
/** `CultMeshRealtimeWireProtocol.ApplicationProtocolName`: the QUIC ALPN. */
|
||||||
export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1";
|
export const CULTMESH_REALTIME_ALPN = "cultmesh-state-v1";
|
||||||
|
|
@ -70,7 +70,7 @@ const INT64_MAX = 2n ** 63n - 1n;
|
||||||
* `CultMeshRealtimeFrame.Validate`. The C# reading of "non-empty" is
|
* `CultMeshRealtimeFrame.Validate`. The C# reading of "non-empty" is
|
||||||
* `string.IsNullOrWhiteSpace`, whose whitespace set is not
|
* `string.IsNullOrWhiteSpace`, whose whitespace set is not
|
||||||
* `String.prototype.trim`'s, so this borrows the one TypeScript implementation
|
* `String.prototype.trim`'s, so this borrows the one TypeScript implementation
|
||||||
* of that set from `@gamecult/cultnet-ts` rather than keeping a second reading here.
|
* 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
|
* 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`
|
* `bigint` does not, and a `DataView` would otherwise throw a bare `RangeError`
|
||||||
|
|
|
||||||
|
|
@ -1,117 +0,0 @@
|
||||||
// Heimdall's invariant: no client may treat a reliable-transport ack as write admission. A put completes only
|
|
||||||
// on its result frame (write_not_authorized, or the success reply). Each test serves a client a peer that
|
|
||||||
// acknowledges the put and refuses it, the ack arriving before the refusal and after it.
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createSocket, type Socket } from "node:dgram";
|
|
||||||
import test from "node:test";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
|
||||||
import {
|
|
||||||
CultNetDocumentRegistry,
|
|
||||||
CultNetRudpSession,
|
|
||||||
cultNetWriteNotAuthorizedError,
|
|
||||||
decodeRudpPacket,
|
|
||||||
defineCultNetDocumentBinding,
|
|
||||||
encodeCultNetMessageForWire,
|
|
||||||
encodeRudpPacket,
|
|
||||||
} from "@gamecult/cultnet-ts";
|
|
||||||
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
|
||||||
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError } from "../src/index";
|
|
||||||
|
|
||||||
const isTheRefusal = (error: unknown): boolean =>
|
|
||||||
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized";
|
|
||||||
|
|
||||||
const note = defineDocumentType({
|
|
||||||
type: "cultmesh.completion_note",
|
|
||||||
schemaId: "cultmesh.completion_note.v0",
|
|
||||||
schema: z.object({ noteId: z.string(), text: z.string() }),
|
|
||||||
name: "noteId",
|
|
||||||
});
|
|
||||||
const binding = defineCultNetDocumentBinding({ definition: note });
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
type Order = "ack-then-refusal" | "refusal-then-ack";
|
|
||||||
|
|
||||||
/** A peer that acknowledges every reliable put and refuses it with write_not_authorized, in the given order. */
|
|
||||||
async function refusingPeer(connectionId: number, order: Order) {
|
|
||||||
const socket: Socket = createSocket("udp4");
|
|
||||||
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
|
|
||||||
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
|
|
||||||
const timers = new Set<NodeJS.Timeout>();
|
|
||||||
let closed = false;
|
|
||||||
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
|
|
||||||
if (!closed) socket.send(encodeRudpPacket(packet), port, address);
|
|
||||||
};
|
|
||||||
const later = (action: () => void) => { const timer = setTimeout(() => { timers.delete(timer); action(); }, 120); timers.add(timer); };
|
|
||||||
socket.on("message", (wire, remote) => {
|
|
||||||
const packet = decodeRudpPacket(wire);
|
|
||||||
const now = Date.now();
|
|
||||||
if (packet.packetType === "connect") {
|
|
||||||
send(session.acceptConnect(packet, now), remote.port, remote.address);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = session.receive(packet, now);
|
|
||||||
if (result.reply) send(result.reply, remote.port, remote.address);
|
|
||||||
for (const frame of result.delivered) {
|
|
||||||
if (frame.channelId !== "schema") continue;
|
|
||||||
const ack = session.createAckForReceived(packet.sequence);
|
|
||||||
const refusal = session.sendMany(
|
|
||||||
"schema",
|
|
||||||
encodeWireValue(encodeCultNetMessageForWire(cultNetWriteNotAuthorizedError("tests:completion:a"), "cultnet.schema.v0")),
|
|
||||||
{ reliable: true, ordered: true, nowMs: now },
|
|
||||||
);
|
|
||||||
if (order === "ack-then-refusal") {
|
|
||||||
send(ack, remote.port, remote.address);
|
|
||||||
later(() => refusal.forEach((p) => send(p, remote.port, remote.address)));
|
|
||||||
} else {
|
|
||||||
// The refusal carries no acknowledgement of its own; the ack follows it.
|
|
||||||
refusal.forEach((p) => send({ ...p, ack: 0, ackMask: 0 }, remote.port, remote.address));
|
|
||||||
later(() => send(ack, remote.port, remote.address));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (packet.reliable) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
|
|
||||||
});
|
|
||||||
return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const orders: Order[] = ["ack-then-refusal", "refusal-then-ack"];
|
|
||||||
let nextConnection = 0x10205000;
|
|
||||||
|
|
||||||
for (const order of orders) {
|
|
||||||
test(`publishRudpDocumentOnce does not complete on the ack of a refused put (${order})`, async () => {
|
|
||||||
const peer = await refusingPeer(nextConnection, order);
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
CultMesh.publishRudpDocumentOnce("completion-once", nextConnection++, peer.endpoint, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 }),
|
|
||||||
isTheRefusal,
|
|
||||||
);
|
|
||||||
} finally { peer.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test(`publishRudpDocumentAndWaitForReceipt reports a refused put as refused, never as success (${order})`, async () => {
|
|
||||||
const peer = await refusingPeer(nextConnection, order);
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
CultMesh.publishRudpDocumentAndWaitForReceipt("completion-receipt", nextConnection++, peer.endpoint, binding, "tests:completion:a", { noteId: "a", text: "x" }, binding, { resendDelayMs: 50, resendPollMs: 5, receiptTimeoutMs: 800 }),
|
|
||||||
isTheRefusal,
|
|
||||||
);
|
|
||||||
} finally { peer.close(); }
|
|
||||||
await sleep(10);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("publishRudpDocumentOnce still publishes a put the server admitted", async () => {
|
|
||||||
const connectionId = nextConnection++;
|
|
||||||
const puts: string[] = [];
|
|
||||||
const server = CultMesh.createRudpDocumentServer("completion-admit", connectionId, {
|
|
||||||
documents: new CultNetDocumentRegistry([binding]),
|
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 50, resendPollMs: 5,
|
|
||||||
onDocumentPutRaw: (put) => { puts.push(put.recordKey); },
|
|
||||||
});
|
|
||||||
await server.start();
|
|
||||||
try {
|
|
||||||
await CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 });
|
|
||||||
assert.deepEqual(puts, ["tests:completion:a"]);
|
|
||||||
} finally { server.close(); }
|
|
||||||
});
|
|
||||||
|
|
@ -19,8 +19,8 @@ import {
|
||||||
CultNetDocumentRegistry,
|
CultNetDocumentRegistry,
|
||||||
defineCultNetDocumentBinding,
|
defineCultNetDocumentBinding,
|
||||||
type CultNetPeer,
|
type CultNetPeer,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
import { CultMesh, CultMeshGrantAllWrites, cultMeshDocumentFromSingleFile, type CultMeshRudpDocumentPut } from "../src/index";
|
import { CultMesh, cultMeshDocumentFromSingleFile, type CultMeshRudpDocumentPut } from "../src/index";
|
||||||
|
|
||||||
// A document written in the reference's positional shape, and a plain map document beside it. Every mesh reader of a
|
// A document written in the reference's positional shape, and a plain map document beside it. Every mesh reader of a
|
||||||
// payload reads it the way the cache does, so both read through the mesh.
|
// payload reads it the way the cache does, so both read through the mesh.
|
||||||
|
|
@ -77,7 +77,6 @@ test("the mesh syncs a codec document from a peer snapshot and returns a codec r
|
||||||
const receiptBinding = defineCultNetDocumentBinding({ definition: codecReceipt });
|
const receiptBinding = defineCultNetDocumentBinding({ definition: codecReceipt });
|
||||||
const puts: unknown[] = [];
|
const puts: unknown[] = [];
|
||||||
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-codec-server", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-codec-server", connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry([commandBinding, plainBinding, receiptBinding]),
|
documents: new CultNetDocumentRegistry([commandBinding, plainBinding, receiptBinding]),
|
||||||
getCache: () => source.cache,
|
getCache: () => source.cache,
|
||||||
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
||||||
|
|
@ -132,7 +131,6 @@ test("a RUDP put naming a schema the server has no definition for reaches the ha
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
const puts: CultMeshRudpDocumentPut[] = [];
|
const puts: CultMeshRudpDocumentPut[] = [];
|
||||||
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-unregistered-server", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-unregistered-server", connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry([known]),
|
documents: new CultNetDocumentRegistry([known]),
|
||||||
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
||||||
onError: (error) => errors.push(error),
|
onError: (error) => errors.push(error),
|
||||||
|
|
@ -173,7 +171,6 @@ test("a payload past the nesting limit is refused at the network doors: a RUDP p
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
const puts: CultMeshRudpDocumentPut[] = [];
|
const puts: CultMeshRudpDocumentPut[] = [];
|
||||||
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-depth-server", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-depth-server", connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: registry,
|
documents: registry,
|
||||||
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
||||||
onError: (error) => errors.push(error),
|
onError: (error) => errors.push(error),
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { join } from "node:path";
|
||||||
import test, { mock } from "node:test";
|
import test, { mock } from "node:test";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
|
|
||||||
import { EMPTY_SHA256, chunkRecordKey, packContent, sha256Hex } from "@gamecult/cultnet-ts/content";
|
import { EMPTY_SHA256, chunkRecordKey, packContent, sha256Hex } from "cultnet-ts/content";
|
||||||
import { closeSync, constants as fsConstants, openSync, symlinkSync } from "node:fs";
|
import { closeSync, constants as fsConstants, openSync, symlinkSync } from "node:fs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
import { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
CultNetPeer,
|
CultNetPeer,
|
||||||
CultNetRudpSession,
|
CultNetRudpSession,
|
||||||
cultNetBuiltinSchemaRegistry,
|
cultNetBuiltinSchemaRegistry,
|
||||||
cultNetTextError,
|
|
||||||
decodeRudpPacket,
|
decodeRudpPacket,
|
||||||
defineCultNetDocumentBinding,
|
defineCultNetDocumentBinding,
|
||||||
encodeRudpPacket,
|
encodeRudpPacket,
|
||||||
|
|
@ -20,8 +19,8 @@ import {
|
||||||
type CultNetRudpPacket,
|
type CultNetRudpPacket,
|
||||||
type CultNetMessage,
|
type CultNetMessage,
|
||||||
type CultNetSnapshotResponseRawMessage,
|
type CultNetSnapshotResponseRawMessage,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError } from "../src/index";
|
import { CultMesh, CultMeshRudpUnservableDocumentError } from "../src/index";
|
||||||
|
|
||||||
// A record's payload as the bytes a peer would put on the wire.
|
// A record's payload as the bytes a peer would put on the wire.
|
||||||
const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value));
|
const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value));
|
||||||
|
|
@ -2857,7 +2856,6 @@ test("CultMesh TS RUDP document server accepts raw document puts from multiple p
|
||||||
"cultmesh-ts-document-server",
|
"cultmesh-ts-document-server",
|
||||||
connectionId,
|
connectionId,
|
||||||
{
|
{
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry(),
|
documents: new CultNetDocumentRegistry(),
|
||||||
bindHost: "127.0.0.1",
|
bindHost: "127.0.0.1",
|
||||||
bindPort: 0,
|
bindPort: 0,
|
||||||
|
|
@ -2946,7 +2944,6 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async ()
|
||||||
"cultmesh-ts-odin-catalog",
|
"cultmesh-ts-odin-catalog",
|
||||||
connectionId,
|
connectionId,
|
||||||
{
|
{
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry(),
|
documents: new CultNetDocumentRegistry(),
|
||||||
bindHost: "127.0.0.1",
|
bindHost: "127.0.0.1",
|
||||||
bindPort: 0,
|
bindPort: 0,
|
||||||
|
|
@ -3022,7 +3019,6 @@ test("CultMesh TS returns a correlated provider receipt after a RUDP document mu
|
||||||
const commandBinding = defineCultNetDocumentBinding({ definition: noteDocument });
|
const commandBinding = defineCultNetDocumentBinding({ definition: noteDocument });
|
||||||
const receiptBinding = defineCultNetDocumentBinding({ definition: noteReceiptDocument });
|
const receiptBinding = defineCultNetDocumentBinding({ definition: noteReceiptDocument });
|
||||||
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-receipt-server", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("cultmesh-ts-receipt-server", connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry([commandBinding, receiptBinding]), bindHost: "127.0.0.1", bindPort: 0,
|
documents: new CultNetDocumentRegistry([commandBinding, receiptBinding]), bindHost: "127.0.0.1", bindPort: 0,
|
||||||
resendDelayMs: 25, resendPollMs: 5,
|
resendDelayMs: 25, resendPollMs: 5,
|
||||||
onDocumentPutRaw: async (document) => ({
|
onDocumentPutRaw: async (document) => ({
|
||||||
|
|
@ -3058,7 +3054,6 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async (
|
||||||
"cultmesh-ts-rudp-snapshot-server",
|
"cultmesh-ts-rudp-snapshot-server",
|
||||||
connectionId,
|
connectionId,
|
||||||
{
|
{
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry([
|
documents: new CultNetDocumentRegistry([
|
||||||
defineCultNetDocumentBinding({ definition: noteDocument }),
|
defineCultNetDocumentBinding({ definition: noteDocument }),
|
||||||
]),
|
]),
|
||||||
|
|
@ -3156,7 +3151,6 @@ test("CultMesh TS syncs remote RUDP snapshots into a local node", async () => {
|
||||||
"cultmesh-ts-rudp-sync-server",
|
"cultmesh-ts-rudp-sync-server",
|
||||||
connectionId,
|
connectionId,
|
||||||
{
|
{
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry([
|
documents: new CultNetDocumentRegistry([
|
||||||
defineCultNetDocumentBinding({ definition: noteDocument }),
|
defineCultNetDocumentBinding({ definition: noteDocument }),
|
||||||
]),
|
]),
|
||||||
|
|
@ -3238,7 +3232,6 @@ test("CultMesh TS peer snapshots never take another type's record at the key", a
|
||||||
"cultmesh-ts-rudp-foreign-snapshot-server",
|
"cultmesh-ts-rudp-foreign-snapshot-server",
|
||||||
connectionId,
|
connectionId,
|
||||||
{
|
{
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry([
|
documents: new CultNetDocumentRegistry([
|
||||||
defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }),
|
defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }),
|
||||||
defineCultNetDocumentBinding({ definition: foreignNoteDocument }),
|
defineCultNetDocumentBinding({ definition: foreignNoteDocument }),
|
||||||
|
|
@ -3480,7 +3473,6 @@ async function withServedBoundRig(
|
||||||
const messages: CultNetMessage[] = [];
|
const messages: CultNetMessage[] = [];
|
||||||
const unparsed: Error[] = [];
|
const unparsed: Error[] = [];
|
||||||
const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, {
|
const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: registry,
|
documents: registry,
|
||||||
getCache: () => node.cache,
|
getCache: () => node.cache,
|
||||||
bindHost: "127.0.0.1",
|
bindHost: "127.0.0.1",
|
||||||
|
|
@ -3642,7 +3634,6 @@ async function withRawPeerRig(
|
||||||
for (const put of stored) await registry.applyRawDocumentPutMessage(node.cache, put);
|
for (const put of stored) await registry.applyRawDocumentPutMessage(node.cache, put);
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, {
|
const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: registry,
|
documents: registry,
|
||||||
getCache: () => (getCache ? getCache(node.cache) : node.cache) as typeof node.cache,
|
getCache: () => (getCache ? getCache(node.cache) : node.cache) as typeof node.cache,
|
||||||
bindHost: "127.0.0.1",
|
bindHost: "127.0.0.1",
|
||||||
|
|
@ -3717,7 +3708,7 @@ test("CultMesh TS RUDP document server answers a peer whose reliable queue is fu
|
||||||
while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5);
|
while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5);
|
||||||
const refusal = refusalIn(rig.inbox);
|
const refusal = refusalIn(rig.inbox);
|
||||||
assert.ok(refusal, "the refusal reaches the peer");
|
assert.ok(refusal, "the refusal reaches the peer");
|
||||||
assert.deepEqual(refusal.message, cultNetTextError("the snapshot response could not be queued"));
|
assert.deepEqual(refusal.message, { schemaVersion: "cultnet.error.v0", error: "the snapshot response could not be queued" });
|
||||||
for (const part of refusal.parts) {
|
for (const part of refusal.parts) {
|
||||||
assert.ok(refused.every((sequence) => !acknowledges(part, sequence)), `ack ${part.ack} mask ${part.ackMask}`);
|
assert.ok(refused.every((sequence) => !acknowledges(part, sequence)), `ack ${part.ack} mask ${part.ackMask}`);
|
||||||
}
|
}
|
||||||
|
|
@ -3734,7 +3725,7 @@ test("CultMesh TS RUDP document server refuses a snapshot with fixed text, never
|
||||||
while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5);
|
while (!refusalIn(rig.inbox) && Date.now() - startedAt < 3_000) await delay(5);
|
||||||
const refusal = refusalIn(rig.inbox);
|
const refusal = refusalIn(rig.inbox);
|
||||||
assert.ok(refusal, "the refusal reaches the peer");
|
assert.ok(refusal, "the refusal reaches the peer");
|
||||||
assert.deepEqual(refusal.message, cultNetTextError("the snapshot source failed"));
|
assert.deepEqual(refusal.message, { schemaVersion: "cultnet.error.v0", error: "the snapshot source failed" });
|
||||||
assert.match(rig.errors[0]?.message ?? "", /CANARY-7f3a/);
|
assert.match(rig.errors[0]?.message ?? "", /CANARY-7f3a/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@
|
||||||
// harness can dial a peer late and exercise Cut C's retained-state seed
|
// 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.
|
// instead of the provider always tearing down the moment it is done sending.
|
||||||
//
|
//
|
||||||
// Neither mode imports `@gamecult/cultmesh-ts` itself (the harness drives processes,
|
// Neither mode imports `cultmesh-ts` itself (the harness drives processes,
|
||||||
// and the dependency direction is `@gamecult/cultmesh-ts` -> `@gamecult/cultnet-ts`, never the
|
// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the
|
||||||
// reverse); this script reaches the provider/connector through its own
|
// reverse); this script reaches the provider/connector through its own
|
||||||
// package's `src/`.
|
// package's `src/`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import {
|
||||||
documentPayloadFromBytes,
|
documentPayloadFromBytes,
|
||||||
type DocumentPayload,
|
type DocumentPayload,
|
||||||
} from "@gamecult/cultcache-ts";
|
} from "@gamecult/cultcache-ts";
|
||||||
import { decodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue } from "cultnet-ts/wire";
|
||||||
import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts";
|
import type { CultNetRawDocumentRecord } from "cultnet-ts";
|
||||||
|
|
||||||
import type { CultMeshRudpDocumentPut } from "../src/index";
|
import type { CultMeshRudpDocumentPut } from "../src/index";
|
||||||
import { decodeRealtimeFrame, encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire";
|
import { decodeRealtimeFrame, encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire";
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { createSocket } from "node:dgram";
|
import { createSocket } from "node:dgram";
|
||||||
import { documentPayloadBytes } from "@gamecult/cultcache-ts";
|
import { documentPayloadBytes } from "@gamecult/cultcache-ts";
|
||||||
import { decodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { decodeWireValue } from "cultnet-ts/wire";
|
||||||
import {
|
import {
|
||||||
CultNetDocumentRegistry,
|
CultNetDocumentRegistry,
|
||||||
CultNetRudpSession,
|
CultNetRudpSession,
|
||||||
|
|
@ -10,11 +10,10 @@ import {
|
||||||
encodeRudpPacket,
|
encodeRudpPacket,
|
||||||
type CultNetOperationRequestMessage,
|
type CultNetOperationRequestMessage,
|
||||||
type CultNetRudpPacket,
|
type CultNetRudpPacket,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CultMesh,
|
CultMesh,
|
||||||
CultMeshGrantAllWrites,
|
|
||||||
CultMeshProviderRudpTransport,
|
CultMeshProviderRudpTransport,
|
||||||
CultMeshProviderSessionBroker,
|
CultMeshProviderSessionBroker,
|
||||||
CULTMESH_PROVIDER_SESSION_SERVICE_ID,
|
CULTMESH_PROVIDER_SESSION_SERVICE_ID,
|
||||||
|
|
@ -65,7 +64,6 @@ test("RUDP provider transport requires broker acceptance for the full lifecycle"
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const server = CultMesh.createRudpDocumentServer("odin-test", 0x43554c54, {
|
const server = CultMesh.createRudpDocumentServer("odin-test", 0x43554c54, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
bindPort: 0,
|
bindPort: 0,
|
||||||
documents: new CultNetDocumentRegistry(),
|
documents: new CultNetDocumentRegistry(),
|
||||||
onOperationRequest: (request, session) => broker.handle(request, session),
|
onOperationRequest: (request, session) => broker.handle(request, session),
|
||||||
|
|
@ -258,7 +256,6 @@ test("a replaced RUDP generation drops operations queued behind an active handle
|
||||||
let handlerCalls = 0;
|
let handlerCalls = 0;
|
||||||
let closedSessions = 0;
|
let closedSessions = 0;
|
||||||
const server = CultMesh.createRudpDocumentServer("odin-queue-test", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("odin-queue-test", connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
bindPort: 0,
|
bindPort: 0,
|
||||||
documents: new CultNetDocumentRegistry(),
|
documents: new CultNetDocumentRegistry(),
|
||||||
onOperationRequest: async request => {
|
onOperationRequest: async request => {
|
||||||
|
|
@ -329,7 +326,6 @@ test("a client restarted on the same address with the same Connect payload repla
|
||||||
const connectionId = 0x43554c54;
|
const connectionId = 0x43554c54;
|
||||||
let closedSessions = 0;
|
let closedSessions = 0;
|
||||||
const server = CultMesh.createRudpDocumentServer("odin-restart-test", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("odin-restart-test", connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
bindPort: 0,
|
bindPort: 0,
|
||||||
documents: new CultNetDocumentRegistry(),
|
documents: new CultNetDocumentRegistry(),
|
||||||
onSessionClosed: () => { closedSessions += 1; },
|
onSessionClosed: () => { closedSessions += 1; },
|
||||||
|
|
@ -368,7 +364,6 @@ test("a client restarted on the same address with the same Connect payload repla
|
||||||
test("the RUDP document server does not acknowledge by name a reliable packet its session did not receive", async () => {
|
test("the RUDP document server does not acknowledge by name a reliable packet its session did not receive", async () => {
|
||||||
const connectionId = 0x43554c55;
|
const connectionId = 0x43554c55;
|
||||||
const server = CultMesh.createRudpDocumentServer("odin-ack-test", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("odin-ack-test", connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
bindPort: 0,
|
bindPort: 0,
|
||||||
documents: new CultNetDocumentRegistry(),
|
documents: new CultNetDocumentRegistry(),
|
||||||
onError: () => undefined,
|
onError: () => undefined,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import test from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts";
|
import { documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts";
|
||||||
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { encodeWireValue } from "cultnet-ts/wire";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cultMeshProviderSessionSchemas,
|
cultMeshProviderSessionSchemas,
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ import {
|
||||||
type CultMeshAuthorityRouteView,
|
type CultMeshAuthorityRouteView,
|
||||||
type CultMeshAuthorityTrustPolicy,
|
type CultMeshAuthorityTrustPolicy,
|
||||||
type CultMeshP256PublicKey,
|
type CultMeshP256PublicKey,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts";
|
import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CultMeshQuicNativeRuntime,
|
CultMeshQuicNativeRuntime,
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import type { CultMeshAuthorityTrustPolicy } from "@gamecult/cultnet-ts";
|
import type { CultMeshAuthorityTrustPolicy } from "cultnet-ts";
|
||||||
import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts";
|
import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CultMeshQuicRealtimeSessionManager,
|
CultMeshQuicRealtimeSessionManager,
|
||||||
|
|
|
||||||
|
|
@ -1,685 +0,0 @@
|
||||||
// A peer's put or delete is applied only when the server's write authority grants it. Ports the behaviours of
|
|
||||||
// cultmesh-py test_remote_write_authority.py (no forwarding: this server does not forward non-primary writes).
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createSocket, type Socket } from "node:dgram";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
import test from "node:test";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { documentPayloadFromBytes, defineDocumentType } from "@gamecult/cultcache-ts";
|
|
||||||
import { decodeRudpPacket } from "@gamecult/cultnet-ts";
|
|
||||||
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
|
||||||
import {
|
|
||||||
CultNetDocumentRegistry,
|
|
||||||
CultNetPeer,
|
|
||||||
cultNetWriteNotAuthorizedError,
|
|
||||||
defineCultNetDocumentBinding,
|
|
||||||
type CultNetMessage,
|
|
||||||
} from "@gamecult/cultnet-ts";
|
|
||||||
import {
|
|
||||||
CultMesh,
|
|
||||||
CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES,
|
|
||||||
CultMeshGrantAllWrites,
|
|
||||||
type CultMeshRemoteWriteAuthority,
|
|
||||||
type CultMeshRemoteWriteDecision,
|
|
||||||
type CultMeshRemoteWriteRequest,
|
|
||||||
type CultMeshRudpDocumentDelete,
|
|
||||||
type CultMeshRudpDocumentPut,
|
|
||||||
} from "../src/index";
|
|
||||||
|
|
||||||
const SCHEMA_ID = "cultmesh.authority_note.v0";
|
|
||||||
const ALIAS = "runtime.generated.authority-note.ui.42";
|
|
||||||
const KEY_A = "tests:authority:a";
|
|
||||||
const KEY_B = "tests:authority:b";
|
|
||||||
|
|
||||||
const note = defineDocumentType({
|
|
||||||
type: "cultmesh.authority_note",
|
|
||||||
schemaId: SCHEMA_ID,
|
|
||||||
compatibleSchemaIds: [ALIAS],
|
|
||||||
schema: z.object({ noteId: z.string(), text: z.string() }),
|
|
||||||
name: "noteId",
|
|
||||||
});
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
function put(recordKey: string, extra: Record<string, unknown> = {}, schemaId = SCHEMA_ID, payload?: Uint8Array) {
|
|
||||||
return {
|
|
||||||
schemaVersion: "cultnet.document_put_raw.v0" as const,
|
|
||||||
messageId: `put-${recordKey}`,
|
|
||||||
document: {
|
|
||||||
schemaId,
|
|
||||||
recordKey,
|
|
||||||
storedAt: "2026-11-07T00:00:00Z",
|
|
||||||
payloadEncoding: "messagepack" as const,
|
|
||||||
payload: documentPayloadFromBytes(payload ?? encodeWireValue({ noteId: recordKey, text: "peer" })),
|
|
||||||
},
|
|
||||||
...extra,
|
|
||||||
} as CultNetMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
function del(recordKey: string, schemaId = SCHEMA_ID): CultNetMessage {
|
|
||||||
return { schemaVersion: "cultnet.document_delete.v0", messageId: `delete-${recordKey}`, schemaId, recordKey };
|
|
||||||
}
|
|
||||||
|
|
||||||
class Recording implements CultMeshRemoteWriteAuthority {
|
|
||||||
readonly requests: CultMeshRemoteWriteRequest[] = [];
|
|
||||||
constructor(private readonly answer: (request: CultMeshRemoteWriteRequest) => unknown) {}
|
|
||||||
decide(request: CultMeshRemoteWriteRequest): CultMeshRemoteWriteDecision | Promise<CultMeshRemoteWriteDecision> {
|
|
||||||
this.requests.push(request);
|
|
||||||
return this.answer(request) as CultMeshRemoteWriteDecision | Promise<CultMeshRemoteWriteDecision>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const grant = () => ({ granted: true });
|
|
||||||
|
|
||||||
interface Rig {
|
|
||||||
puts: CultMeshRudpDocumentPut[];
|
|
||||||
deletes: CultMeshRudpDocumentDelete[];
|
|
||||||
errors: Error[];
|
|
||||||
replies: CultNetMessage[];
|
|
||||||
peer: CultNetPeer;
|
|
||||||
settle(): Promise<unknown>;
|
|
||||||
close(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextConnection = 0x10204000;
|
|
||||||
|
|
||||||
async function rig(writeAuthority?: CultMeshRemoteWriteAuthority, extra: { maxPayloadBytes?: number; writeAuthorityTimeoutMs?: number } = {}): Promise<Rig> {
|
|
||||||
const connectionId = nextConnection++;
|
|
||||||
const puts: CultMeshRudpDocumentPut[] = [];
|
|
||||||
const deletes: CultMeshRudpDocumentDelete[] = [];
|
|
||||||
const errors: Error[] = [];
|
|
||||||
const server = CultMesh.createRudpDocumentServer("authority-server", connectionId, {
|
|
||||||
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
|
|
||||||
writeAuthority,
|
|
||||||
bindHost: "127.0.0.1",
|
|
||||||
bindPort: 0,
|
|
||||||
resendDelayMs: 25,
|
|
||||||
resendPollMs: 5,
|
|
||||||
onDocumentPutRaw: (document) => { puts.push(document); },
|
|
||||||
onDocumentDeleteRaw: (request) => { deletes.push(request); },
|
|
||||||
onError: (error) => errors.push(error),
|
|
||||||
...extra,
|
|
||||||
});
|
|
||||||
await server.start();
|
|
||||||
const peer: CultNetPeer = await CultMesh.createRudpPeer("authority-peer", connectionId, `rudp://127.0.0.1:${server.bind.port}`, {
|
|
||||||
resendDelayMs: 25,
|
|
||||||
resendPollMs: 5,
|
|
||||||
connectTimeoutMs: 2_000,
|
|
||||||
});
|
|
||||||
const replies: CultNetMessage[] = [];
|
|
||||||
peer.on("message", (message) => replies.push(message));
|
|
||||||
return {
|
|
||||||
puts, deletes, errors, replies, peer,
|
|
||||||
// A refusal is a reply; a grant sends none, so settle on a quiet period after the last send.
|
|
||||||
settle: () => sleep(250),
|
|
||||||
close: () => { peer.close(); server.close(); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function using(r: Rig, body: (r: Rig) => Promise<unknown>): Promise<void> {
|
|
||||||
try { await body(r); } finally { r.close(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertRefused(reply: CultNetMessage | undefined, key: string, error?: string): void {
|
|
||||||
assert.ok(reply && reply.schemaVersion === "cultnet.error.v0");
|
|
||||||
assert.equal(reply.code, "write_not_authorized");
|
|
||||||
assert.ok(reply.error.startsWith("write_not_authorized"));
|
|
||||||
assert.deepEqual(reply.details, { field: "recordKey", value: key, asOf: null, current: null });
|
|
||||||
if (error !== undefined) assert.equal(reply.error, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("with no authority a peer's put and delete are refused and the handlers never run", async () => {
|
|
||||||
await using(await rig(undefined), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A));
|
|
||||||
r.peer.send(del(KEY_A));
|
|
||||||
r.peer.send(put(KEY_B));
|
|
||||||
await r.settle();
|
|
||||||
assert.equal(r.replies.length, 3);
|
|
||||||
assertRefused(r.replies[0], KEY_A);
|
|
||||||
assertRefused(r.replies[1], KEY_A);
|
|
||||||
assertRefused(r.replies[2], KEY_B);
|
|
||||||
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]);
|
|
||||||
assert.deepEqual(r.errors, []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a granted put and delete reach their handlers, send nothing back, and the authority saw this peer", async () => {
|
|
||||||
const authority = new Recording(grant);
|
|
||||||
await using(await rig(authority), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A));
|
|
||||||
r.peer.send(del(KEY_A));
|
|
||||||
await r.settle();
|
|
||||||
assert.deepEqual(r.replies, []);
|
|
||||||
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]);
|
|
||||||
assert.deepEqual(r.deletes.map((d) => d.recordKey), [KEY_A]);
|
|
||||||
assert.equal(authority.requests.length, 2);
|
|
||||||
for (const request of authority.requests) {
|
|
||||||
assert.equal(request.peer.address, "127.0.0.1");
|
|
||||||
assert.ok(request.peer.port > 0);
|
|
||||||
assert.equal(request.schemaId, SCHEMA_ID, "a granted write was judged under the canonical schema id");
|
|
||||||
}
|
|
||||||
assert.deepEqual(authority.requests[1]!.peer, r.deletes[0]!.remote, "the delete handler sees the peer the authority was asked about");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the request carries the resolved write, and scoped grants refuse their neighbours", async () => {
|
|
||||||
const authority = new Recording((request) =>
|
|
||||||
request.operation === "put" && request.recordKey === KEY_A ? { granted: true } : { granted: false, reason: "scoped" });
|
|
||||||
await using(await rig(authority), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A, { shardId: "authority-shard", shardEpoch: 1 }, ALIAS));
|
|
||||||
await r.settle();
|
|
||||||
const request = authority.requests[0]!;
|
|
||||||
assert.equal(request.schemaId, SCHEMA_ID, "the authority sees the canonical id, not the alias as sent");
|
|
||||||
assert.equal(request.operation, "put");
|
|
||||||
assert.equal(request.recordKey, KEY_A);
|
|
||||||
assert.equal(request.shardId, "authority-shard");
|
|
||||||
assert.equal(request.shardEpoch, 1);
|
|
||||||
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]);
|
|
||||||
|
|
||||||
r.peer.send(put(KEY_B));
|
|
||||||
r.peer.send(del(KEY_A));
|
|
||||||
await r.settle();
|
|
||||||
assertRefused(r.replies[0], KEY_B, "write_not_authorized: scoped");
|
|
||||||
assertRefused(r.replies[1], KEY_A, "write_not_authorized: scoped");
|
|
||||||
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "a grant scoped to key a refuses key b");
|
|
||||||
assert.deepEqual(r.deletes, [], "a grant scoped to put refuses delete");
|
|
||||||
assert.equal(authority.requests.at(-1)!.operation, "delete");
|
|
||||||
assert.equal(authority.requests.at(-1)!.schemaId, SCHEMA_ID);
|
|
||||||
assert.equal(authority.requests.at(-1)!.shardId, null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a delete sent under a schema alias asks the authority with the canonical id", async () => {
|
|
||||||
const authority = new Recording(grant);
|
|
||||||
await using(await rig(authority), async (r) => {
|
|
||||||
r.peer.send(del(KEY_A, ALIAS));
|
|
||||||
await r.settle();
|
|
||||||
assert.equal(authority.requests[0]!.schemaId, SCHEMA_ID);
|
|
||||||
assert.equal(r.deletes.length, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refusal precedes every other judgment of the put", async () => {
|
|
||||||
await using(await rig(undefined, { maxPayloadBytes: 256 }), async (r) => {
|
|
||||||
r.peer.send(put("unrelated:authority", {}, "sha256:nobody-owns-this"));
|
|
||||||
r.peer.send(put(KEY_A, {}, SCHEMA_ID, new Uint8Array(20_000)));
|
|
||||||
await r.settle();
|
|
||||||
assertRefused(r.replies[0], "unrelated:authority");
|
|
||||||
assertRefused(r.replies[1], KEY_A);
|
|
||||||
assert.deepEqual(r.errors, [], "the gate ran before the put was sized or resolved");
|
|
||||||
assert.equal(r.puts.length, 0);
|
|
||||||
});
|
|
||||||
const granting = new Recording(grant);
|
|
||||||
await using(await rig(granting, { maxPayloadBytes: 256 }), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A, {}, SCHEMA_ID, new Uint8Array(20_000)));
|
|
||||||
await r.settle();
|
|
||||||
assert.equal(r.replies[0]?.schemaVersion, "cultnet.error.v0");
|
|
||||||
assert.notEqual((r.replies[0] as { code?: string }).code, "write_not_authorized", "a granted put still meets the size judgment");
|
|
||||||
assert.equal(r.errors.length, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an authority that throws or rejects refuses, fails closed, and is reported without being quoted", async () => {
|
|
||||||
for (const decide of [
|
|
||||||
() => { throw new Error("authority exploded"); },
|
|
||||||
() => Promise.reject(new Error("authority exploded")),
|
|
||||||
]) {
|
|
||||||
await using(await rig(new Recording(decide)), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A));
|
|
||||||
r.peer.send(del(KEY_A));
|
|
||||||
await r.settle();
|
|
||||||
assertRefused(r.replies[0], KEY_A);
|
|
||||||
assertRefused(r.replies[1], KEY_A);
|
|
||||||
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]);
|
|
||||||
assert.equal(r.errors.length, 2);
|
|
||||||
assert.ok(r.errors.every((e) => e.message.includes("Error") && !e.message.includes("exploded")));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an authority that never settles refuses after the timeout and does not block the frames behind it", async () => {
|
|
||||||
// The first put hangs forever; later frames from the same session must still be judged.
|
|
||||||
const authority = new Recording((request) => request.recordKey === KEY_A ? new Promise(() => {}) : { granted: true });
|
|
||||||
await using(await rig(authority, { writeAuthorityTimeoutMs: 100 }), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A));
|
|
||||||
r.peer.send(put(KEY_B));
|
|
||||||
await sleep(600);
|
|
||||||
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_B], "the granted put behind the hung one was applied");
|
|
||||||
assert.equal(r.replies.length, 1);
|
|
||||||
assertRefused(r.replies[0], KEY_A);
|
|
||||||
assert.equal((r.replies[0] as { error: string }).error.includes("timed out"), true);
|
|
||||||
assert.equal(r.errors.length, 1);
|
|
||||||
assert.ok(r.errors[0]!.message.includes("did not answer within 100 ms"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an authority that settles GRANTED after the timeout is still refused and the write never reaches the handler", async () => {
|
|
||||||
const authority = new Recording((request) => request.recordKey === KEY_A
|
|
||||||
? new Promise((resolve) => setTimeout(() => resolve({ granted: true }), 300))
|
|
||||||
: { granted: true });
|
|
||||||
await using(await rig(authority, { writeAuthorityTimeoutMs: 100 }), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A));
|
|
||||||
r.peer.send(del(KEY_A));
|
|
||||||
await sleep(700);
|
|
||||||
assert.equal(r.replies.length, 2, "one refusal per frame, no late second reply");
|
|
||||||
assertRefused(r.replies[0], KEY_A);
|
|
||||||
assertRefused(r.replies[1], KEY_A);
|
|
||||||
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0], "the late grant never reached the handler");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a write authority timeout that is not a usable timer delay is refused at construction", () => {
|
|
||||||
for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, 2 ** 31]) {
|
|
||||||
assert.throws(() => CultMesh.createRudpDocumentServer("authority-server", 1, {
|
|
||||||
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
|
|
||||||
bindHost: "127.0.0.1",
|
|
||||||
bindPort: 0,
|
|
||||||
writeAuthorityTimeoutMs: bad,
|
|
||||||
}), RangeError, String(bad));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refusal with no reason still says why, and a missing decision refuses", async () => {
|
|
||||||
await using(await rig(new Recording(() => ({ granted: false }))), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A));
|
|
||||||
await r.settle();
|
|
||||||
assertRefused(r.replies[0], KEY_A, "write_not_authorized");
|
|
||||||
});
|
|
||||||
for (const answer of [null, undefined, {}, { granted: "yes" }, { granted: 1 }]) {
|
|
||||||
await using(await rig(new Recording(() => answer)), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A));
|
|
||||||
r.peer.send(del(KEY_A));
|
|
||||||
await r.settle();
|
|
||||||
assertRefused(r.replies[0], KEY_A);
|
|
||||||
assertRefused(r.replies[1], KEY_A);
|
|
||||||
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the refusal on the wire is the pinned error-vectors bytes", async () => {
|
|
||||||
const vectors = JSON.parse(readFileSync(join(__dirname, "../../../../contracts/cultnet/interop/error-vectors.json"), "utf8")).vectors;
|
|
||||||
const vector = vectors.find((v: { name: string }) => v.name === "write_not_authorized");
|
|
||||||
const expected = Buffer.from(vector.messagePackHex, "hex");
|
|
||||||
assert.deepEqual(Buffer.from(encodeWireValue(cultNetWriteNotAuthorizedError("some:key", "not granted"))), expected);
|
|
||||||
await using(await rig(new Recording(() => ({ granted: false, reason: "not granted" }))), async (r) => {
|
|
||||||
r.peer.send(put("some:key"));
|
|
||||||
await r.settle();
|
|
||||||
assert.deepEqual(Buffer.from(encodeWireValue(r.replies[0])), expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("wrong-typed shard fields on a put are judged with null shard fields, then refused as malformed once granted", async () => {
|
|
||||||
const authority = new Recording(grant);
|
|
||||||
await using(await rig(authority), async (r) => {
|
|
||||||
const bad: Record<string, unknown>[] = [
|
|
||||||
{ shardId: { not: "a string" } }, { shardId: 5 }, { shardEpoch: "7" }, { shardEpoch: true }, { shardEpoch: 1.5 },
|
|
||||||
];
|
|
||||||
for (const fields of bad) r.peer.send(put(KEY_A, fields));
|
|
||||||
await r.settle();
|
|
||||||
assert.equal(r.replies.length, bad.length);
|
|
||||||
assert.deepEqual(
|
|
||||||
r.replies.map((reply) => reply.schemaVersion === "cultnet.error.v0" ? [reply.code, reply.details?.field] : reply),
|
|
||||||
["shardId", "shardId", "shardEpoch", "shardEpoch", "shardEpoch"].map((field) => ["message_invalid", field]),
|
|
||||||
);
|
|
||||||
assert.equal(authority.requests.length, bad.length, "the authority is asked first");
|
|
||||||
assert.ok(authority.requests.every((q) => q.shardId === null && q.shardEpoch === null), "with null shard fields");
|
|
||||||
assert.equal(r.puts.length, 0);
|
|
||||||
});
|
|
||||||
await using(await rig(undefined), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A, { shardId: 5 }));
|
|
||||||
await r.settle();
|
|
||||||
assertRefused(r.replies[0], KEY_A);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refusal fits maxPayloadBytes however long the record key and reason are", async () => {
|
|
||||||
const longKey = "k".repeat(3_000) + "é";
|
|
||||||
await using(await rig(new Recording(() => ({ granted: false, reason: "r".repeat(100_000) })), { maxPayloadBytes: 1024 }), async (r) => {
|
|
||||||
r.peer.send(put(longKey));
|
|
||||||
r.peer.send(del(longKey));
|
|
||||||
await r.settle();
|
|
||||||
assert.equal(r.replies.length, 2);
|
|
||||||
for (const reply of r.replies) {
|
|
||||||
assert.equal((reply as { code?: string }).code, "write_not_authorized");
|
|
||||||
assert.ok(encodeWireValue(reply).byteLength <= 1024);
|
|
||||||
assert.ok(longKey.startsWith((reply as { details: { value: string } }).details.value));
|
|
||||||
}
|
|
||||||
assert.deepEqual(r.errors, []);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("well-typed shard fields reach the authority as sent", async () => {
|
|
||||||
const authority = new Recording(grant);
|
|
||||||
await using(await rig(authority), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A, { shardId: "authority-shard", shardEpoch: 1 }));
|
|
||||||
await r.settle();
|
|
||||||
assert.deepEqual([authority.requests[0]!.shardId, authority.requests[0]!.shardEpoch], ["authority-shard", 1]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("grant-all is an explicit choice that grants every write", async () => {
|
|
||||||
await using(await rig(new CultMeshGrantAllWrites()), async (r) => {
|
|
||||||
r.peer.send(put(KEY_A));
|
|
||||||
r.peer.send(del(KEY_A));
|
|
||||||
await r.settle();
|
|
||||||
assert.deepEqual([r.puts.length, r.deletes.length, r.replies.length], [1, 1, 0]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a granted delete with no delete handler is answered with a text refusal", async () => {
|
|
||||||
const connectionId = nextConnection++;
|
|
||||||
const server = CultMesh.createRudpDocumentServer("no-delete", connectionId, {
|
|
||||||
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
|
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
|
||||||
});
|
|
||||||
await server.start();
|
|
||||||
const peer = await CultMesh.createRudpPeer("p", connectionId, `rudp://127.0.0.1:${server.bind.port}`, { resendDelayMs: 25, resendPollMs: 5, connectTimeoutMs: 2_000 });
|
|
||||||
const replies: CultNetMessage[] = [];
|
|
||||||
peer.on("message", (m) => replies.push(m));
|
|
||||||
try {
|
|
||||||
peer.send(del(KEY_A));
|
|
||||||
await sleep(250);
|
|
||||||
assert.equal(replies.length, 1);
|
|
||||||
assert.notEqual((replies[0] as { code?: string }).code, "write_not_authorized");
|
|
||||||
} finally { peer.close(); server.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hostile frames, sent as raw bytes the typed peer would never build.
|
|
||||||
const bin = (n: number, ...tail: number[]) => [0xc6, (n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255, ...tail]; // bin32 header
|
|
||||||
const str = (t: string) => [0xa0 | t.length, ...Buffer.from(t)];
|
|
||||||
function rawPut(payloadBytes: number[], key = KEY_A): Uint8Array {
|
|
||||||
// {schemaVersion, messageId, document: {schemaId, recordKey, storedAt, payloadEncoding, payload: <payloadBytes>}}
|
|
||||||
return Uint8Array.from([
|
|
||||||
0x83, ...str("schemaVersion"), ...str("cultnet.document_put_raw.v0"), ...str("messageId"), ...str("raw"),
|
|
||||||
...str("document"), 0x85, ...str("schemaId"), ...str(SCHEMA_ID), ...str("recordKey"), ...str(key),
|
|
||||||
...str("storedAt"), ...str("2026-11-07T00:00:00Z"), ...str("payloadEncoding"), ...str("messagepack"),
|
|
||||||
...str("payload"), ...payloadBytes,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rawRig(writeAuthority?: CultMeshRemoteWriteAuthority) {
|
|
||||||
const connectionId = nextConnection++;
|
|
||||||
const puts: CultMeshRudpDocumentPut[] = [];
|
|
||||||
const deletes: CultMeshRudpDocumentDelete[] = [];
|
|
||||||
const errors: Error[] = [];
|
|
||||||
const server = CultMesh.createRudpDocumentServer("authority-raw", connectionId, {
|
|
||||||
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
|
|
||||||
writeAuthority, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
|
||||||
onDocumentPutRaw: (d) => { puts.push(d); }, onDocumentDeleteRaw: (d) => { deletes.push(d); }, onError: (e) => errors.push(e),
|
|
||||||
});
|
|
||||||
await server.start();
|
|
||||||
const client = await CultMesh.createRudpClient("raw", connectionId, `rudp://127.0.0.1:${server.bind.port}`, { resendDelayMs: 25, resendPollMs: 5 });
|
|
||||||
client.connect();
|
|
||||||
const started = Date.now();
|
|
||||||
while (!client.connected) {
|
|
||||||
if (Date.now() - started > 2_000) throw new Error("not connected");
|
|
||||||
await sleep(5);
|
|
||||||
}
|
|
||||||
const replies: CultNetMessage[] = [];
|
|
||||||
new CultNetPeer(client, { wireContract: "cultnet.schema.v0" }).on("message", (m) => replies.push(m));
|
|
||||||
return { puts, deletes, errors, replies, send: (b: Uint8Array) => client.send("schema", b), close: () => { client.close(); server.close(); } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const float64 = (v: number) => { const b = Buffer.alloc(9); b[0] = 0xcb; b.writeDoubleBE(v, 1); return [...b]; };
|
|
||||||
|
|
||||||
// Each hostile frame, the operation it is judged as, and the ids the authority is told (empty when the frame does not carry them).
|
|
||||||
const hostile: { name: string; frame: Uint8Array; operation: "put" | "delete"; schemaId: string; recordKey: string }[] = [
|
|
||||||
{ name: "an int payload", frame: rawPut([0x2a]), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A },
|
|
||||||
{ name: "a uint64-max integer payload", frame: rawPut([0xcf, 255, 255, 255, 255, 255, 255, 255, 255]), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A },
|
|
||||||
{ name: "a float64 2**70 payload", frame: rawPut(float64(2 ** 70)), operation: "put", schemaId: SCHEMA_ID, recordKey: KEY_A },
|
|
||||||
{ name: "a bin whose claimed length exceeds the frame", frame: rawPut(bin(1_000_000_000, 1, 2, 3)), operation: "put", schemaId: "", recordKey: "" },
|
|
||||||
{ name: "a frame that does not decode at all", frame: Uint8Array.from([0xc1]), operation: "put", schemaId: "", recordKey: "" },
|
|
||||||
{ name: "a put whose document is not a map", frame: Uint8Array.from([0x82, ...str("schemaVersion"), ...str("cultnet.document_put_raw.v0"), ...str("document"), 0x05]), operation: "put", schemaId: "", recordKey: "" },
|
|
||||||
{ name: "a delete whose recordKey is not a string", frame: Uint8Array.from([0x84, ...str("schemaVersion"), ...str("cultnet.document_delete.v0"), ...str("messageId"), ...str("d"), ...str("schemaId"), ...str(SCHEMA_ID), ...str("recordKey"), 0x07]), operation: "delete", schemaId: SCHEMA_ID, recordKey: "" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Records the size of every buffer a frame makes the process allocate, so a transient allocation is seen after it is freed. */
|
|
||||||
function watchAllocations(): { largest: () => number; stop: () => void } {
|
|
||||||
let largest = 0;
|
|
||||||
const note = (n: unknown) => { if (typeof n === "number" && n > largest) largest = n; };
|
|
||||||
const originals = { alloc: Buffer.alloc, unsafe: Buffer.allocUnsafe, slow: Buffer.allocUnsafeSlow, u8: globalThis.Uint8Array, ab: globalThis.ArrayBuffer };
|
|
||||||
Buffer.alloc = ((n: number, ...rest: never[]) => { note(n); return originals.alloc.call(Buffer, n, ...rest); }) as typeof Buffer.alloc;
|
|
||||||
Buffer.allocUnsafe = ((n: number) => { note(n); return originals.unsafe.call(Buffer, n); }) as typeof Buffer.allocUnsafe;
|
|
||||||
Buffer.allocUnsafeSlow = ((n: number) => { note(n); return originals.slow.call(Buffer, n); }) as typeof Buffer.allocUnsafeSlow;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const watched = (ctor: any): any => new Proxy(ctor, { construct: (target, args, newTarget) => { note(args[0]); return Reflect.construct(target, args, newTarget); } });
|
|
||||||
(globalThis as { Uint8Array: unknown }).Uint8Array = watched(originals.u8);
|
|
||||||
(globalThis as { ArrayBuffer: unknown }).ArrayBuffer = watched(originals.ab);
|
|
||||||
return {
|
|
||||||
largest: () => largest,
|
|
||||||
stop: () => {
|
|
||||||
Buffer.alloc = originals.alloc; Buffer.allocUnsafe = originals.unsafe; Buffer.allocUnsafeSlow = originals.slow;
|
|
||||||
(globalThis as { Uint8Array: unknown }).Uint8Array = originals.u8;
|
|
||||||
(globalThis as { ArrayBuffer: unknown }).ArrayBuffer = originals.ab;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test("an ungranted peer's unreadable write is refused with write_not_authorized naming what it carried, and nothing is allocated for it", async () => {
|
|
||||||
for (const h of hostile) {
|
|
||||||
const authority = new Recording(() => ({ granted: false }));
|
|
||||||
for (const withAuthority of [false, true]) {
|
|
||||||
const r = await rawRig(withAuthority ? authority : undefined);
|
|
||||||
const watch = watchAllocations();
|
|
||||||
try {
|
|
||||||
r.send(h.frame);
|
|
||||||
await sleep(250);
|
|
||||||
} finally { watch.stop(); }
|
|
||||||
try {
|
|
||||||
assert.equal(r.replies.length, 1, h.name);
|
|
||||||
assertRefused(r.replies[0], h.recordKey);
|
|
||||||
assert.ok(watch.largest() < 10_000_000, `${h.name}: allocated ${watch.largest()} bytes`);
|
|
||||||
assert.deepEqual([r.puts.length, r.deletes.length, r.errors.length], [0, 0, 0], h.name);
|
|
||||||
} finally { r.close(); }
|
|
||||||
}
|
|
||||||
assert.equal(authority.requests.length, 1, h.name);
|
|
||||||
assert.deepEqual(
|
|
||||||
[authority.requests[0]!.operation, authority.requests[0]!.schemaId, authority.requests[0]!.recordKey],
|
|
||||||
[h.operation, h.schemaId, h.recordKey], h.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the allocation watch sees a transient allocation", () => {
|
|
||||||
const watch = watchAllocations();
|
|
||||||
try { void Buffer.alloc(20_000_000); } finally { watch.stop(); }
|
|
||||||
assert.ok(watch.largest() >= 20_000_000);
|
|
||||||
const typed = watchAllocations();
|
|
||||||
try { void new Uint8Array(20_000_000); } finally { typed.stop(); }
|
|
||||||
assert.ok(typed.largest() >= 20_000_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a granted peer's unreadable write reaches onError and gets a malformed refusal, and no handler runs", async () => {
|
|
||||||
for (const h of hostile) {
|
|
||||||
const authority = new Recording(grant);
|
|
||||||
const r = await rawRig(authority);
|
|
||||||
try {
|
|
||||||
r.send(h.frame);
|
|
||||||
await sleep(250);
|
|
||||||
assert.equal(r.replies.length, 1, h.name);
|
|
||||||
const reply = r.replies[0] as { code: unknown; error: string; details: { field: string } };
|
|
||||||
assert.equal(reply.code, "message_invalid", h.name);
|
|
||||||
assert.equal(reply.details.field, "message", h.name);
|
|
||||||
assert.equal(r.errors.length, 1, h.name);
|
|
||||||
assert.deepEqual([r.puts.length, r.deletes.length], [0, 0], h.name);
|
|
||||||
assert.deepEqual([authority.requests.length, authority.requests[0]!.operation, authority.requests[0]!.recordKey], [1, h.operation, h.recordKey], h.name);
|
|
||||||
} finally { r.close(); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const CAP = 8; // the cap every runtime shares; the exported constant must stay this number
|
|
||||||
|
|
||||||
test("the consecutive-refused-frames cap is the number every runtime shares", () => {
|
|
||||||
assert.equal(CULTMESH_RUDP_DOCUMENT_SERVER_MAX_CONSECUTIVE_REFUSED_FRAMES, CAP);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("unreadable frames are answered and the session kept until eight in a row; any decoded frame resets the run", async () => {
|
|
||||||
const r = await rawRig(new Recording(grant));
|
|
||||||
try {
|
|
||||||
const batch = async (frames: number) => {
|
|
||||||
for (let i = 0; i < frames; i++) r.send(Uint8Array.from([0xc1]));
|
|
||||||
await sleep(400);
|
|
||||||
};
|
|
||||||
await batch(CAP - 1);
|
|
||||||
assert.equal(r.replies.length, CAP - 1, "each is answered");
|
|
||||||
r.send(encodeWireValue(put(KEY_A)));
|
|
||||||
await sleep(250);
|
|
||||||
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "the session was kept through seven");
|
|
||||||
await batch(CAP - 1);
|
|
||||||
assert.equal(r.replies.length, 2 * (CAP - 1), "the valid frame reset the run");
|
|
||||||
r.send(Uint8Array.from([0xc1]));
|
|
||||||
await sleep(300);
|
|
||||||
assert.equal(r.replies.length, 2 * (CAP - 1) + 1, "the eighth consecutive one is answered too");
|
|
||||||
assert.ok(r.replies.every((reply) => (reply as { code?: string }).code === "message_invalid"));
|
|
||||||
// The goodbye reached the peer: its client no longer takes a send.
|
|
||||||
assert.throws(() => r.send(encodeWireValue(put(KEY_B))), /before the session is connected/, "then the session ended");
|
|
||||||
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A]);
|
|
||||||
} finally { r.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deletes the authority refuses are answered and the session kept until eight in a row; any other decoded frame resets the run", async () => {
|
|
||||||
const authority = new Recording((request) => request.recordKey === KEY_B ? { granted: true } : { granted: false });
|
|
||||||
await using(await rig(authority), async (r) => {
|
|
||||||
const refuse = async (deletes: number) => {
|
|
||||||
for (let i = 0; i < deletes; i++) r.peer.send(del(KEY_A));
|
|
||||||
await sleep(400);
|
|
||||||
};
|
|
||||||
await refuse(CAP - 1);
|
|
||||||
assert.equal(r.replies.length, CAP - 1);
|
|
||||||
r.peer.send(del(KEY_B)); // granted: resets the run
|
|
||||||
await sleep(250);
|
|
||||||
assert.deepEqual(r.deletes.map((d) => d.recordKey), [KEY_B]);
|
|
||||||
await refuse(CAP - 1);
|
|
||||||
assert.equal(r.replies.length, 2 * (CAP - 1));
|
|
||||||
r.peer.send(put(KEY_B)); // a decoded non-delete resets too
|
|
||||||
await sleep(250);
|
|
||||||
await refuse(CAP - 1);
|
|
||||||
assert.equal(r.replies.length, 3 * (CAP - 1), "a put reset the run");
|
|
||||||
r.peer.send(del(KEY_A));
|
|
||||||
await sleep(300);
|
|
||||||
assert.equal(r.replies.length, 3 * (CAP - 1) + 1, "the eighth consecutive refused delete is answered too");
|
|
||||||
for (const reply of r.replies) assertRefused(reply, KEY_A);
|
|
||||||
assert.throws(() => r.peer.send(del(KEY_B)), /before the session is connected/, "then the session ended");
|
|
||||||
assert.equal(r.deletes.length, 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a frame that decodes but is no message the parser takes is answered, counted, and ended at eight in a row like any unreadable frame", async () => {
|
|
||||||
const r = await rawRig(new Recording(grant));
|
|
||||||
const unknown = () => encodeWireValue({ schemaVersion: "cultnet.not-a-message.v0", messageId: "x" });
|
|
||||||
try {
|
|
||||||
const batch = async (frames: number) => {
|
|
||||||
for (let i = 0; i < frames; i++) r.send(unknown());
|
|
||||||
await sleep(400);
|
|
||||||
};
|
|
||||||
await batch(CAP - 1);
|
|
||||||
assert.equal(r.replies.length, CAP - 1, "each is answered");
|
|
||||||
assert.equal(r.errors.length, CAP - 1, "and reported");
|
|
||||||
r.send(encodeWireValue(put(KEY_A)));
|
|
||||||
await sleep(250);
|
|
||||||
assert.deepEqual(r.puts.map((p) => p.recordKey), [KEY_A], "the session was kept through seven");
|
|
||||||
await batch(CAP - 1);
|
|
||||||
assert.equal(r.replies.length, 2 * (CAP - 1), "the decoded put reset the run");
|
|
||||||
r.send(unknown());
|
|
||||||
await sleep(300);
|
|
||||||
assert.equal(r.replies.length, 2 * (CAP - 1) + 1, "the eighth is answered too");
|
|
||||||
for (const reply of r.replies) {
|
|
||||||
assert.equal((reply as { code?: string }).code, "message_invalid");
|
|
||||||
assert.equal((reply as { details: { field: string } }).details.field, "message");
|
|
||||||
}
|
|
||||||
assert.throws(() => r.send(unknown()), /before the session is connected/, "then the session ended");
|
|
||||||
} finally { r.close(); }
|
|
||||||
});
|
|
||||||
|
|
||||||
/** A UDP relay between a client and a server that drops the first (or, with `dropEvery`, every) server datagram carrying `payloadContains` once armed. */
|
|
||||||
async function lossyRelay(serverPort: number, payloadContains: string, dropEvery = false) {
|
|
||||||
const dropped: unknown[] = [];
|
|
||||||
let armed = false;
|
|
||||||
let clientAddress: { port: number; address: string } | undefined;
|
|
||||||
const toClient: Socket = createSocket("udp4");
|
|
||||||
const toServer: Socket = createSocket("udp4");
|
|
||||||
toServer.on("message", (wire) => {
|
|
||||||
if (!clientAddress) return;
|
|
||||||
if (armed && (dropEvery || dropped.length === 0)) {
|
|
||||||
const packet = decodeRudpPacket(wire);
|
|
||||||
if (packet.packetType === "data" && Buffer.from(packet.payload ?? []).includes(payloadContains)) {
|
|
||||||
dropped.push(packet);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
toClient.send(wire, clientAddress.port, clientAddress.address);
|
|
||||||
});
|
|
||||||
toClient.on("message", (wire, remote) => {
|
|
||||||
clientAddress = remote;
|
|
||||||
toServer.send(wire, serverPort, "127.0.0.1");
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolve) => toClient.bind(0, "127.0.0.1", resolve));
|
|
||||||
await new Promise<void>((resolve) => toServer.bind(0, "127.0.0.1", resolve));
|
|
||||||
return {
|
|
||||||
port: toClient.address().port,
|
|
||||||
dropped,
|
|
||||||
arm: () => { armed = true; },
|
|
||||||
close: () => { toClient.close(); toServer.close(); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cappedOverRelay(dropEvery: boolean, body: (x: { relay: Awaited<ReturnType<typeof lossyRelay>>; client: { send(channel: string, payload: Uint8Array): void }; replies: CultNetMessage[] }) => Promise<void>) {
|
|
||||||
const connectionId = nextConnection++;
|
|
||||||
const server = CultMesh.createRudpDocumentServer("authority-lossy", connectionId, {
|
|
||||||
documents: new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]),
|
|
||||||
writeAuthority: new Recording(grant), bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5,
|
|
||||||
onDocumentPutRaw: () => {}, onError: () => {},
|
|
||||||
});
|
|
||||||
await server.start();
|
|
||||||
const relay = await lossyRelay(server.bind.port, "message_invalid", dropEvery);
|
|
||||||
const client = await CultMesh.createRudpClient("lossy", connectionId, `rudp://127.0.0.1:${relay.port}`, { resendDelayMs: 25, resendPollMs: 5 });
|
|
||||||
try {
|
|
||||||
client.connect();
|
|
||||||
const started = Date.now();
|
|
||||||
while (!client.connected) {
|
|
||||||
if (Date.now() - started > 2_000) throw new Error("not connected");
|
|
||||||
await sleep(5);
|
|
||||||
}
|
|
||||||
const replies: CultNetMessage[] = [];
|
|
||||||
new CultNetPeer(client, { wireContract: "cultnet.schema.v0" }).on("message", (m) => replies.push(m));
|
|
||||||
for (let i = 0; i < CAP - 1; i++) client.send("schema", Uint8Array.from([0xc1]));
|
|
||||||
await sleep(300);
|
|
||||||
assert.equal(replies.length, CAP - 1);
|
|
||||||
relay.arm();
|
|
||||||
client.send("schema", Uint8Array.from([0xc1]));
|
|
||||||
await body({ relay, client, replies });
|
|
||||||
} finally { client.close(); server.close(); relay.close(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
test("the capped frame's answer is resent when its datagram is lost, and the goodbye follows only after it arrived", async () => {
|
|
||||||
await cappedOverRelay(false, async ({ relay, client, replies }) => {
|
|
||||||
await sleep(600);
|
|
||||||
assert.equal(relay.dropped.length, 1, "fixture: the first transmission of the last answer was lost");
|
|
||||||
assert.equal(replies.length, CAP, "the lost answer was resent and arrived");
|
|
||||||
assert.equal((replies[CAP - 1] as { code?: string }).code, "message_invalid");
|
|
||||||
assert.throws(() => client.send("schema", Uint8Array.from([0xc1])), /before the session is connected/, "then the session ended");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a capped session whose answer is never acknowledged still ends, after the grace", async () => {
|
|
||||||
await cappedOverRelay(true, async ({ client, replies }) => {
|
|
||||||
await sleep(1200);
|
|
||||||
assert.equal(replies.length, CAP - 1, "fixture: the last answer never arrives");
|
|
||||||
client.send("schema", Uint8Array.from([0xc1])); // still connected: the server is waiting for the acknowledgement
|
|
||||||
await sleep(1500);
|
|
||||||
assert.throws(() => client.send("schema", Uint8Array.from([0xc1])), /before the session is connected/, "the grace ran out and the goodbye went out");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("nothing behind the capped frame is served while its answer is delivered", async () => {
|
|
||||||
const r = await rawRig(new Recording(grant));
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < CAP; i++) r.send(Uint8Array.from([0xc1]));
|
|
||||||
r.send(encodeWireValue(put(KEY_A)));
|
|
||||||
await sleep(500);
|
|
||||||
assert.equal(r.replies.length, CAP);
|
|
||||||
assert.deepEqual(r.puts, [], "the put behind the cap was not served");
|
|
||||||
} finally { r.close(); }
|
|
||||||
});
|
|
||||||
|
|
@ -6,15 +6,15 @@ import assert from "node:assert/strict";
|
||||||
import { createSocket, type RemoteInfo, type Socket } from "node:dgram";
|
import { createSocket, type RemoteInfo, type Socket } from "node:dgram";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
import { documentPayloadFromBytes } from "@gamecult/cultcache-ts";
|
||||||
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { encodeWireValue } from "cultnet-ts/wire";
|
||||||
import {
|
import {
|
||||||
CultNetDocumentRegistry,
|
CultNetDocumentRegistry,
|
||||||
CultNetRudpSession,
|
CultNetRudpSession,
|
||||||
decodeRudpPacket,
|
decodeRudpPacket,
|
||||||
encodeRudpPacket,
|
encodeRudpPacket,
|
||||||
type CultNetPeer,
|
type CultNetPeer,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
import { CultMesh, CultMeshGrantAllWrites } from "../src/index";
|
import { CultMesh } from "../src/index";
|
||||||
|
|
||||||
async function waitFor(predicate: () => boolean, description: string): Promise<void> {
|
async function waitFor(predicate: () => boolean, description: string): Promise<void> {
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
|
@ -64,7 +64,6 @@ async function startServer(connectionId: number, socket: Socket): Promise<Server
|
||||||
const closed: number[] = [];
|
const closed: number[] = [];
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
const server = CultMesh.createRudpDocumentServer("send-failure-server", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("send-failure-server", connectionId, {
|
||||||
writeAuthority: new CultMeshGrantAllWrites(),
|
|
||||||
documents: new CultNetDocumentRegistry(),
|
documents: new CultNetDocumentRegistry(),
|
||||||
socket,
|
socket,
|
||||||
bindHost: "127.0.0.1",
|
bindHost: "127.0.0.1",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
## Unreleased (breaking; version 0.x, so the next minor)
|
## Unreleased (breaking; version 0.x, so the next minor)
|
||||||
|
|
||||||
- Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.
|
|
||||||
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
|
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
|
||||||
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by
|
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by
|
||||||
`tests/vectors/rudp-multipeer-server/transcript.txt`. The caller drives it (`try_receive_once`, `poll_resends`). Like the C#
|
`tests/vectors/rudp-multipeer-server/transcript.txt`. The caller drives it (`try_receive_once`, `poll_resends`). Like the C#
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ wire contracts that both a node and a bare peer have to agree on.
|
||||||
|
|
||||||
It depends on `cultcache-py` and `msgpack`; AES-GCM helpers on `CultNetSecret`
|
It depends on `cultcache-py` and `msgpack`; AES-GCM helpers on `CultNetSecret`
|
||||||
light up with the optional `crypto` extra. `cultmesh-py` builds on it. The
|
light up with the optional `crypto` extra. `cultmesh-py` builds on it. The
|
||||||
layering mirrors `cultnet-rs` and `@gamecult/cultnet-ts`.
|
layering mirrors `cultnet-rs` and `cultnet-ts`.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ from .messages import (
|
||||||
document_put_raw,
|
document_put_raw,
|
||||||
encode_witness_artifact_bundle_payload,
|
encode_witness_artifact_bundle_payload,
|
||||||
error_message,
|
error_message,
|
||||||
message_invalid_error,
|
|
||||||
unsupported_schema_version_error,
|
unsupported_schema_version_error,
|
||||||
hello,
|
hello,
|
||||||
login,
|
login,
|
||||||
|
|
@ -263,7 +262,6 @@ __all__ = [
|
||||||
"encode_frame",
|
"encode_frame",
|
||||||
"encode_rudp_packet",
|
"encode_rudp_packet",
|
||||||
"error_message",
|
"error_message",
|
||||||
"message_invalid_error",
|
|
||||||
"unsupported_schema_version_error",
|
"unsupported_schema_version_error",
|
||||||
"hello",
|
"hello",
|
||||||
"INTEROP_WIRE_CONTRACT",
|
"INTEROP_WIRE_CONTRACT",
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ class CultNetRawClient:
|
||||||
create_transport: CultNetSchemaTransportFactory | None = None
|
create_transport: CultNetSchemaTransportFactory | None = None
|
||||||
|
|
||||||
def send(self, message: CultNetMessage | dict[str, Any]) -> None:
|
def send(self, message: CultNetMessage | dict[str, Any]) -> None:
|
||||||
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
|
|
||||||
wire = message.to_wire() if isinstance(message, CultNetMessage) else message
|
wire = message.to_wire() if isinstance(message, CultNetMessage) else message
|
||||||
with self.open_transport() as transport:
|
with self.open_transport() as transport:
|
||||||
transport.send("schema", msgpack.packb(wire, use_bin_type=True))
|
transport.send("schema", msgpack.packb(wire, use_bin_type=True))
|
||||||
|
|
@ -320,7 +319,6 @@ class CultNetDatabaseSubscription:
|
||||||
self._transport = None
|
self._transport = None
|
||||||
|
|
||||||
def send(self, message: CultNetMessage | dict[str, Any]) -> None:
|
def send(self, message: CultNetMessage | dict[str, Any]) -> None:
|
||||||
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
|
|
||||||
if self._transport is None:
|
if self._transport is None:
|
||||||
raise RuntimeError("CultNet database subscription is not open")
|
raise RuntimeError("CultNet database subscription is not open")
|
||||||
wire = message.to_wire() if isinstance(message, CultNetMessage) else message
|
wire = message.to_wire() if isinstance(message, CultNetMessage) else message
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ CULTNET_ERROR_CODES: tuple[str, ...] = (
|
||||||
"unowned_schema",
|
"unowned_schema",
|
||||||
"write_not_authorized",
|
"write_not_authorized",
|
||||||
"unsupported_schema_version",
|
"unsupported_schema_version",
|
||||||
"message_invalid",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
|
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
|
||||||
|
|
@ -186,11 +185,6 @@ def unsupported_schema_version_error(schema_version: Any) -> CultNetMessage:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def message_invalid_error(field: str, reason: str) -> CultNetMessage:
|
|
||||||
"""The refusal for a request that lacks a required field or carries an ill-typed one (C# ForMessageInvalid)."""
|
|
||||||
return error_message(f"message_invalid: {reason}", code="message_invalid", details={"field": field})
|
|
||||||
|
|
||||||
|
|
||||||
def login_success(
|
def login_success(
|
||||||
*,
|
*,
|
||||||
nonce: str,
|
nonce: str,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ CULTNET_ERROR_CODES = (
|
||||||
"unowned_schema",
|
"unowned_schema",
|
||||||
"write_not_authorized",
|
"write_not_authorized",
|
||||||
"unsupported_schema_version",
|
"unsupported_schema_version",
|
||||||
"message_invalid",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
WIRE_MESSAGE_SCHEMA_VERSIONS = (
|
WIRE_MESSAGE_SCHEMA_VERSIONS = (
|
||||||
|
|
|
||||||
|
|
@ -1256,7 +1256,6 @@ class CultNetRudpSocketTransportConnection:
|
||||||
self._send_packet(self.session.create_connect(_now_ms(), payload))
|
self._send_packet(self.session.create_connect(_now_ms(), payload))
|
||||||
|
|
||||||
def send(self, channel_id: str, payload: bytes) -> None:
|
def send(self, channel_id: str, payload: bytes) -> None:
|
||||||
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
|
|
||||||
packets = self.session.send_many(
|
packets = self.session.send_many(
|
||||||
channel_id,
|
channel_id,
|
||||||
payload,
|
payload,
|
||||||
|
|
@ -1568,7 +1567,6 @@ class TcpFramedTransportConnection:
|
||||||
)
|
)
|
||||||
|
|
||||||
def send(self, channel_id: str, payload: bytes) -> None:
|
def send(self, channel_id: str, payload: bytes) -> None:
|
||||||
"""Fire-and-forget: an ack or a return is not admission; read the result frame."""
|
|
||||||
if channel_id != "schema":
|
if channel_id != "schema":
|
||||||
raise ValueError(f"tcp_framed transport only supports the schema channel, got {channel_id!r}")
|
raise ValueError(f"tcp_framed transport only supports the schema channel, got {channel_id!r}")
|
||||||
before = len(payload) + 4
|
before = len(payload) + 4
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from pathlib import Path
|
||||||
|
|
||||||
import msgpack
|
import msgpack
|
||||||
|
|
||||||
from cultnet_py import CULTNET_ERROR_CODES, error_message, message_invalid_error, parse_message, unsupported_schema_version_error
|
from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message, unsupported_schema_version_error
|
||||||
|
|
||||||
# contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned
|
# contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned
|
||||||
# by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes).
|
# by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes).
|
||||||
|
|
@ -51,10 +51,6 @@ class CultNetErrorMessageTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"])
|
self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"])
|
||||||
self.assertEqual(unsupported_schema_version_error("cultnet.hello.v1").to_bytes(), by_name["unsupported_schema_version"])
|
self.assertEqual(unsupported_schema_version_error("cultnet.hello.v1").to_bytes(), by_name["unsupported_schema_version"])
|
||||||
self.assertEqual(
|
|
||||||
message_invalid_error("document", "Raw put messages must contain a document map.").to_bytes(),
|
|
||||||
by_name["message_invalid"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None:
|
def test_builder_writes_nil_keys_and_accepts_a_null_code(self) -> None:
|
||||||
wire = error_message("boom").to_wire()
|
wire = error_message("boom").to_wire()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ only in git history.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `CultNetErrorCode::MessageInvalid` (`message_invalid`), the code for a request that lacks a required field or carries an ill-typed one; a captured-C#-bytes case pins it.
|
|
||||||
- The provider half of `gamecult.runtime_presence_health.v2` for Idunn-managed Rust services, in one module that builds and runs on every platform an Idunn driver launches (unix under Idunn's systemd driver, Windows under its host driver). `IdunnRuntimeAuthority::open(target, health_contract, credentials)` reads the runtime bundle, the candidate bind and the write lease from the launch environment Idunn set (no caller supplies a path) and checks every binding Idunn expects (target and health contract, activation bound to Expected, both signing credentials, the write-lease and candidate-bind environment; the candidate bind must equal Expected's route exactly). The credentials' delivery decides the custody the bundle is held to: `IdunnRuntimeCredentials::from_systemd_descriptors` (unix; found by name per sd_listen_fds_with_names(3), `LISTEN_PID` or the `PrivatePIDs` pid-1 rule, root-owned 0400 single-link read-only files, close-on-exec, `LISTEN_*` removed from the environment) carries root custody (bundle directory, `expected.cc`, `activation.cc`, their `.lock` siblings and every parent root-owned, not group- or world-writable, no symlinks, and the same for the write lease), while `from_host_environment` (the host driver's files; the provider identity is opened before the one-shot activation credential is read and removed, and a refusal leaves the credential in place) carries profile custody on every platform (the bundle is a real directory of real files, never a link: std's `is_symlink` refuses symlinks and, on Windows, junctions). No refusal names a path; an unreadable store is reported by its failure kind alone (not decodable, unsupported format, not found, permission denied, I/O error). `IdunnRuntimePresenceSigner` takes the authority by value, owns the reported health (`RuntimePresenceState`, starting Warming; `report_health`), signs validated presence records with `sign` / `sign_at` (returning `SignedRuntimePresence`: record, raw CultNet document and canonical digest), holds Idunn's process write lease through a `ProcessWriteLeaseGuard` (unix only; `try_acquire_write_lease`; the guard holds the lease's `.lock` shared and `require_current` detects replacement; a runtime whose Expected requires a lease is refused at `open` elsewhere with `write_lease_required`), keeps signing Failed, without a lease digest, after the lease is lost, and answers Idunn's route challenge from local authority with `answer_route_observation`. The module owns no network. No wire bytes change: its output passes `authenticate_runtime_presence_claim` and `correlate_runtime_presence_claim`. New unix-only dependencies: `libc`, `fs2`.
|
- The provider half of `gamecult.runtime_presence_health.v2` for Idunn-managed Rust services, in one module that builds and runs on every platform an Idunn driver launches (unix under Idunn's systemd driver, Windows under its host driver). `IdunnRuntimeAuthority::open(target, health_contract, credentials)` reads the runtime bundle, the candidate bind and the write lease from the launch environment Idunn set (no caller supplies a path) and checks every binding Idunn expects (target and health contract, activation bound to Expected, both signing credentials, the write-lease and candidate-bind environment; the candidate bind must equal Expected's route exactly). The credentials' delivery decides the custody the bundle is held to: `IdunnRuntimeCredentials::from_systemd_descriptors` (unix; found by name per sd_listen_fds_with_names(3), `LISTEN_PID` or the `PrivatePIDs` pid-1 rule, root-owned 0400 single-link read-only files, close-on-exec, `LISTEN_*` removed from the environment) carries root custody (bundle directory, `expected.cc`, `activation.cc`, their `.lock` siblings and every parent root-owned, not group- or world-writable, no symlinks, and the same for the write lease), while `from_host_environment` (the host driver's files; the provider identity is opened before the one-shot activation credential is read and removed, and a refusal leaves the credential in place) carries profile custody on every platform (the bundle is a real directory of real files, never a link: std's `is_symlink` refuses symlinks and, on Windows, junctions). No refusal names a path; an unreadable store is reported by its failure kind alone (not decodable, unsupported format, not found, permission denied, I/O error). `IdunnRuntimePresenceSigner` takes the authority by value, owns the reported health (`RuntimePresenceState`, starting Warming; `report_health`), signs validated presence records with `sign` / `sign_at` (returning `SignedRuntimePresence`: record, raw CultNet document and canonical digest), holds Idunn's process write lease through a `ProcessWriteLeaseGuard` (unix only; `try_acquire_write_lease`; the guard holds the lease's `.lock` shared and `require_current` detects replacement; a runtime whose Expected requires a lease is refused at `open` elsewhere with `write_lease_required`), keeps signing Failed, without a lease digest, after the lease is lost, and answers Idunn's route challenge from local authority with `answer_route_observation`. The module owns no network. No wire bytes change: its output passes `authenticate_runtime_presence_claim` and `correlate_runtime_presence_claim`. New unix-only dependencies: `libc`, `fs2`.
|
||||||
- The launch-contract names both sides of an Idunn launch read: `IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`, `IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`, `IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`, `IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`, `RUNTIME_PRESENCE_IDENTITY_BINDING` and `RUNTIME_PRESENCE_IDENTITY_FD_NAME`.
|
- The launch-contract names both sides of an Idunn launch read: `IDUNN_RUNTIME_BUNDLE_ENVIRONMENT`, `IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT`, `IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT`, `IDUNN_ACTIVATION_CREDENTIAL_ENVIRONMENT`, `RUNTIME_PRESENCE_IDENTITY_BINDING` and `RUNTIME_PRESENCE_IDENTITY_FD_NAME`.
|
||||||
- Version 0.3.0: the unreleased `register` and `CultMeshCdnChunkRef` breaking changes below follow the 0.y.z minor lane.
|
- Version 0.3.0: the unreleased `register` and `CultMeshCdnChunkRef` breaking changes below follow the 0.y.z minor lane.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# CultNet RS
|
# CultNet RS
|
||||||
|
|
||||||
`cultnet-rs` is the Rust sibling of `@gamecult/cultnet-ts`: typed MessagePack messages,
|
`cultnet-rs` is the Rust sibling of `cultnet-ts`: typed MessagePack messages,
|
||||||
4-byte length-prefixed direct-pipe framing, CultLib-shaped security helpers, and
|
4-byte length-prefixed direct-pipe framing, CultLib-shaped security helpers, and
|
||||||
CultCache replication without making callers paw through raw envelopes like we
|
CultCache replication without making callers paw through raw envelopes like we
|
||||||
lost a bet.
|
lost a bet.
|
||||||
|
|
@ -100,7 +100,7 @@ game-session authority remain separate responsibilities.
|
||||||
|
|
||||||
## Local Fast Lane
|
## Local Fast Lane
|
||||||
|
|
||||||
`cultnet-rs` now mirrors the raw replication seam from `@gamecult/cultnet-ts`:
|
`cultnet-rs` now mirrors the raw replication seam from `cultnet-ts`:
|
||||||
|
|
||||||
- `cultnet.document_put_raw.v0`
|
- `cultnet.document_put_raw.v0`
|
||||||
- `cultnet.snapshot_response_raw.v0`
|
- `cultnet.snapshot_response_raw.v0`
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
"code": {
|
"code": {
|
||||||
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
|
"description": "Machine-readable refusal code (R-N). Additive: a v0 peer that does not know this field ignores it. The set is a closed enum: Rust and TypeScript refuse a message whose code is not listed, so adding a code is one coordinated change across every runtime (this schema, the C# factory, the Rust CultNetErrorCode, and a captured-bytes case for each).",
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid"]
|
"enum": [null, "selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported", "unowned_schema", "write_not_authorized", "unsupported_schema_version"]
|
||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
"description": "Code-specific structured detail (R-N). Every field is populated only for the code that defines it; the others are null, not omitted.",
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["field", "value", "asOf", "current"],
|
"required": ["field", "value", "asOf", "current"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\". message_invalid: the request field that is missing or ill-typed (\"message\" when the whole frame cannot be read).", "type": ["string", "null"] },
|
"field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] },
|
||||||
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
|
"value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] },
|
||||||
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
"asOf": { "description": "cursor_stale: the cursor's own minted asOf.", "type": ["integer", "null"], "minimum": 0 },
|
||||||
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
"current": { "description": "cursor_stale: the answering server's current asOf.", "type": ["integer", "null"], "minimum": 0 }
|
||||||
|
|
|
||||||
|
|
@ -248,7 +248,6 @@ pub enum CultNetErrorCode {
|
||||||
UnownedSchema,
|
UnownedSchema,
|
||||||
WriteNotAuthorized,
|
WriteNotAuthorized,
|
||||||
UnsupportedSchemaVersion,
|
UnsupportedSchemaVersion,
|
||||||
MessageInvalid,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CultNetErrorCode {
|
impl CultNetErrorCode {
|
||||||
|
|
@ -262,7 +261,6 @@ impl CultNetErrorCode {
|
||||||
Self::UnownedSchema => "unowned_schema",
|
Self::UnownedSchema => "unowned_schema",
|
||||||
Self::WriteNotAuthorized => "write_not_authorized",
|
Self::WriteNotAuthorized => "write_not_authorized",
|
||||||
Self::UnsupportedSchemaVersion => "unsupported_schema_version",
|
Self::UnsupportedSchemaVersion => "unsupported_schema_version",
|
||||||
Self::MessageInvalid => "message_invalid",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +274,6 @@ impl CultNetErrorCode {
|
||||||
"unowned_schema" => Ok(Self::UnownedSchema),
|
"unowned_schema" => Ok(Self::UnownedSchema),
|
||||||
"write_not_authorized" => Ok(Self::WriteNotAuthorized),
|
"write_not_authorized" => Ok(Self::WriteNotAuthorized),
|
||||||
"unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion),
|
"unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion),
|
||||||
"message_invalid" => Ok(Self::MessageInvalid),
|
|
||||||
_ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")),
|
_ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2198,7 +2198,6 @@ impl CultNetRudpSocketTransportConnection {
|
||||||
self.send_packet(&packet)
|
self.send_packet(&packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
|
|
||||||
pub fn send(&mut self, channel_id: &str, payload: Vec<u8>) -> Result<()> {
|
pub fn send(&mut self, channel_id: &str, payload: Vec<u8>) -> Result<()> {
|
||||||
let options =
|
let options =
|
||||||
channel_send_options(
|
channel_send_options(
|
||||||
|
|
@ -2217,8 +2216,6 @@ impl CultNetRudpSocketTransportConnection {
|
||||||
|
|
||||||
/// Sends one frame on a reliable channel whose packets cannot expire and
|
/// Sends one frame on a reliable channel whose packets cannot expire and
|
||||||
/// returns the transport receipt used to observe its exact acknowledgement.
|
/// returns the transport receipt used to observe its exact acknowledgement.
|
||||||
///
|
|
||||||
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
|
|
||||||
pub fn send_reliable(
|
pub fn send_reliable(
|
||||||
&mut self,
|
&mut self,
|
||||||
channel_id: &str,
|
channel_id: &str,
|
||||||
|
|
@ -2312,9 +2309,6 @@ impl CultNetRudpSocketTransportConnection {
|
||||||
self.session.outstanding_reliable_packet_count()
|
self.session.outstanding_reliable_packet_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waits until every reliable write is acknowledged by the transport.
|
|
||||||
///
|
|
||||||
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
|
|
||||||
pub fn flush_reliable(&mut self, timeout: Duration) -> Result<()> {
|
pub fn flush_reliable(&mut self, timeout: Duration) -> Result<()> {
|
||||||
let original_timeout = self.socket.read_timeout()?;
|
let original_timeout = self.socket.read_timeout()?;
|
||||||
let poll_timeout = original_timeout
|
let poll_timeout = original_timeout
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,6 @@ impl<TStream> TcpFramedTransportConnection<TStream>
|
||||||
where
|
where
|
||||||
TStream: Write,
|
TStream: Write,
|
||||||
{
|
{
|
||||||
/// Fire-and-forget: an ack or a return is not admission; read the result frame.
|
|
||||||
pub fn send(&mut self, channel_id: &str, payload: &[u8]) -> Result<()> {
|
pub fn send(&mut self, channel_id: &str, payload: &[u8]) -> Result<()> {
|
||||||
if channel_id != "schema" {
|
if channel_id != "schema" {
|
||||||
return Err(anyhow!(
|
return Err(anyhow!(
|
||||||
|
|
|
||||||
|
|
@ -188,21 +188,6 @@ fn cases() -> Vec<Case> {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Case {
|
|
||||||
name: "message_invalid",
|
|
||||||
// C#: ForMessageInvalid("document", "Raw put messages must contain a document map.")
|
|
||||||
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0",
|
|
||||||
message: || CultNetMessage::Error {
|
|
||||||
error: "message_invalid: Raw put messages must contain a document map.".to_string(),
|
|
||||||
code: Some(CultNetErrorCode::MessageInvalid),
|
|
||||||
details: Some(CultNetErrorDetails {
|
|
||||||
field: Some("document".to_string()),
|
|
||||||
value: None,
|
|
||||||
as_of: None,
|
|
||||||
current: None,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,18 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to `@gamecult/cultnet-ts` are recorded here, following
|
All notable changes to `cultnet-ts` are recorded here, following
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`.
|
|
||||||
- `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority.
|
|
||||||
- `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members.
|
|
||||||
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.
|
- `CultNetRudpSocketTransportServer`: one UDP socket serving many peers, each remote address and port with its own reliable session (`peerConnected`, `frame`, `peerDisconnected` events; `send(peer, ...)` answers on the peer a frame came from). A second client no longer replaces the first; Connects beyond `maxPeers` (default 256) are dropped, and `close()` resolves once every peer's goodbye has left the socket. The shape follows the Rust `CultNetRudpServerHub` and C# `CultNetRudpSocketTransportServer`, and the wire format is unchanged. The optional `peerTimeoutMs` (idle peers ended and told so; default off) exists only in TypeScript. `CultNetRudpSocketTransportConnection` in `server` mode still serves exactly one peer.
|
||||||
|
|
||||||
- The `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1` and `cultnet.snapshot_response_raw.v1` carriers and the selection payload types (`CultNetSelection`, `CultNetRawDocumentHeader`, `CultNetEdge`). `parseCultNetMessage` accepts them (absent members arrive as absent keys; a page carries exactly one of `headers`/`documents`; an empty or blank `schemas`/`keys` entry throws `CultNetSelectionInvalidError` naming its field), and `encodeCultNetMessageForWire` writes the reference's nil-keyed, declaration-ordered map. Judged against bytes the C# reference wrote (`contracts/cultnet/interop/carrier-vectors.cs-written.json`) and the selection vectors C# and Rust wrote. The selection engine (evaluation, cursors, the declaration-dependent door checks) is not ported.
|
- The `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1` and `cultnet.snapshot_response_raw.v1` carriers and the selection payload types (`CultNetSelection`, `CultNetRawDocumentHeader`, `CultNetEdge`). `parseCultNetMessage` accepts them (absent members arrive as absent keys; a page carries exactly one of `headers`/`documents`; an empty or blank `schemas`/`keys` entry throws `CultNetSelectionInvalidError` naming its field), and `encodeCultNetMessageForWire` writes the reference's nil-keyed, declaration-ordered map. Judged against bytes the C# reference wrote (`contracts/cultnet/interop/carrier-vectors.cs-written.json`) and the selection vectors C# and Rust wrote. The selection engine (evaluation, cursors, the declaration-dependent door checks) is not ported.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
- The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged.
|
|
||||||
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.
|
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.
|
||||||
|
|
||||||
- `CultMeshCdnChunkRef` carries `id` (slot 4), the reference's element id. `encodeManifest` writes five-slot chunk references and `packContent` sets the decimal offset as the id, as the reference does; a manifest written before ids reads `""` and is rewritten with the slot. Content vectors regenerated from the reference.
|
- `CultMeshCdnChunkRef` carries `id` (slot 4), the reference's element id. `encodeManifest` writes five-slot chunk references and `packContent` sets the decimal offset as the id, as the reference does; a manifest written before ids reads `""` and is rewritten with the slot. Content vectors regenerated from the reference.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# CultNet TS
|
# CultNet TS
|
||||||
|
|
||||||
`@gamecult/cultnet-ts` is the TypeScript port of the useful shape of GameCult's
|
`cultnet-ts` is the TypeScript port of the useful shape of GameCult's
|
||||||
networking layer: typed MessagePack messages, direct-pipe framing, CultLib-style
|
networking layer: typed MessagePack messages, direct-pipe framing, CultLib-style
|
||||||
security/session helpers, and state replication that hands typed values into a
|
security/session helpers, and state replication that hands typed values into a
|
||||||
`CultCache` instead of making consumer code paw raw database envelopes.
|
`CultCache` instead of making consumer code paw raw database envelopes.
|
||||||
|
|
@ -42,7 +42,7 @@ We want:
|
||||||
So the split is:
|
So the split is:
|
||||||
|
|
||||||
- `cultcache-ts`: typed local persistence
|
- `cultcache-ts`: typed local persistence
|
||||||
- `@gamecult/cultnet-ts`: typed transport, auth, and state replication
|
- `cultnet-ts`: typed transport, auth, and state replication
|
||||||
|
|
||||||
Same family. Different organs. Nobody needs a second spine growing out of the
|
Same family. Different organs. Nobody needs a second spine growing out of the
|
||||||
transport layer.
|
transport layer.
|
||||||
|
|
@ -80,7 +80,7 @@ CultNet without inventing a second ontology first.
|
||||||
|
|
||||||
## Generated Swarm Contracts
|
## Generated Swarm Contracts
|
||||||
|
|
||||||
`@gamecult/cultnet-ts` can consume canonical JSON Schema files and generate:
|
`cultnet-ts` can consume canonical JSON Schema files and generate:
|
||||||
|
|
||||||
- TypeScript contract classes/interfaces
|
- TypeScript contract classes/interfaces
|
||||||
- runtime schema verifiers
|
- runtime schema verifiers
|
||||||
|
|
@ -111,7 +111,7 @@ which exports:
|
||||||
|
|
||||||
## Wire Contracts
|
## Wire Contracts
|
||||||
|
|
||||||
`@gamecult/cultnet-ts` does not sniff random payloads and pretend that counts as rigor.
|
`cultnet-ts` does not sniff random payloads and pretend that counts as rigor.
|
||||||
You choose the contract explicitly.
|
You choose the contract explicitly.
|
||||||
|
|
||||||
Current wire-contract options:
|
Current wire-contract options:
|
||||||
|
|
@ -129,7 +129,7 @@ away from the C# lineage.
|
||||||
|
|
||||||
## Content
|
## Content
|
||||||
|
|
||||||
`@gamecult/cultnet-ts/content` owns the TypeScript shapes of the CultMesh CDN
|
`cultnet-ts/content` owns the TypeScript shapes of the CultMesh CDN
|
||||||
(`src/content.ts`, the twin of `CultMeshCdn.cs`): the artifact manifest and chunk
|
(`src/content.ts`, the twin of `CultMeshCdn.cs`): the artifact manifest and chunk
|
||||||
reference, their MessagePack form, hash normalization, both record-key grammars,
|
reference, their MessagePack form, hash normalization, both record-key grammars,
|
||||||
SHA-256 hashing through WebCrypto, and `packContent`. It is browser-safe: it
|
SHA-256 hashing through WebCrypto, and `packContent`. It is browser-safe: it
|
||||||
|
|
@ -160,7 +160,7 @@ imports no `node:` module, so a browser bundle can take it.
|
||||||
## Local Fast Lane
|
## Local Fast Lane
|
||||||
|
|
||||||
For direct-pipe neighbors that already share the same CultCache formatter
|
For direct-pipe neighbors that already share the same CultCache formatter
|
||||||
contract, `@gamecult/cultnet-ts` now has a raw replication lane:
|
contract, `cultnet-ts` now has a raw replication lane:
|
||||||
|
|
||||||
- `cultnet.document_put_raw.v0`
|
- `cultnet.document_put_raw.v0`
|
||||||
- `cultnet.snapshot_response_raw.v0`
|
- `cultnet.snapshot_response_raw.v0`
|
||||||
|
|
@ -185,7 +185,7 @@ cuts out the dumbest part of the trip.
|
||||||
Apps sharing a CultNet space should be able to ask each other what they can
|
Apps sharing a CultNet space should be able to ask each other what they can
|
||||||
exchange without resorting to folklore.
|
exchange without resorting to folklore.
|
||||||
|
|
||||||
`@gamecult/cultnet-ts` now includes:
|
`cultnet-ts` now includes:
|
||||||
|
|
||||||
- `cultnet.schema_catalog_request.v0`
|
- `cultnet.schema_catalog_request.v0`
|
||||||
- `cultnet.schema_catalog_response.v0`
|
- `cultnet.schema_catalog_response.v0`
|
||||||
|
|
@ -232,7 +232,7 @@ import {
|
||||||
createTcpFramedCultNetPeer,
|
createTcpFramedCultNetPeer,
|
||||||
defineCultNetDocumentBinding,
|
defineCultNetDocumentBinding,
|
||||||
ghostlightAgentStateGeneratedContract,
|
ghostlightAgentStateGeneratedContract,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "cultnet-ts";
|
||||||
|
|
||||||
const ghostlightStateDefinition = defineDocumentType({
|
const ghostlightStateDefinition = defineDocumentType({
|
||||||
type: "ghostlight.agent-state",
|
type: "ghostlight.agent-state",
|
||||||
|
|
@ -270,7 +270,7 @@ That means a TypeScript runtime can follow the same cryptographic and session
|
||||||
story as the C# version instead of inventing one more house religion around
|
story as the C# version instead of inventing one more house religion around
|
||||||
"trust me, this pipe is probably fine."
|
"trust me, this pipe is probably fine."
|
||||||
|
|
||||||
For the legacy `gamecult.networking.v0` contract, `@gamecult/cultnet-ts` normalizes the
|
For the legacy `gamecult.networking.v0` contract, `cultnet-ts` normalizes the
|
||||||
encrypted binary fields as base64url strings in the ergonomic TS surface and
|
encrypted binary fields as base64url strings in the ergonomic TS surface and
|
||||||
maps them back to the exact C# union payload shape at the wire boundary.
|
maps them back to the exact C# union payload shape at the wire boundary.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@gamecult/cultnet-ts",
|
"name": "cultnet-ts",
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.",
|
"description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@msgpack/msgpack": "^3.1.3",
|
"@msgpack/msgpack": "^3.1.3",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"@gamecult/cultcache-ts": "^0.15.0"
|
"@gamecult/cultcache-ts": "^0.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { isNullOrWhiteSpaceCSharp } from "./cultmesh-authority";
|
|
||||||
import type { DocumentPayload } from "@gamecult/cultcache-ts/document";
|
import type { DocumentPayload } from "@gamecult/cultcache-ts/document";
|
||||||
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js";
|
import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js";
|
||||||
|
|
||||||
|
|
@ -196,44 +195,10 @@ export const CULTNET_ERROR_CODES = [
|
||||||
"unowned_schema",
|
"unowned_schema",
|
||||||
"write_not_authorized",
|
"write_not_authorized",
|
||||||
"unsupported_schema_version",
|
"unsupported_schema_version",
|
||||||
"message_invalid",
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
|
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
|
||||||
|
|
||||||
/** A refusal that carries text only: `routingHint`, `code` and `details` are written as null, as the reference writes an absent member. */
|
|
||||||
export function cultNetTextError(error: string): CultNetErrorMessage {
|
|
||||||
return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The refusal for a peer write its server's authority did not grant (C# `CultNetErrorMessage.ForRemoteWriteRefused`):
|
|
||||||
* code `write_not_authorized`, `details` naming the record in `recordKey`. A blank `reason` leaves the text bare.
|
|
||||||
*/
|
|
||||||
export function cultNetWriteNotAuthorizedError(recordKey: string, reason?: string | null): CultNetErrorMessage {
|
|
||||||
return {
|
|
||||||
schemaVersion: "cultnet.error.v0",
|
|
||||||
error: typeof reason === "string" && !isNullOrWhiteSpaceCSharp(reason) ? `write_not_authorized: ${reason}` : "write_not_authorized",
|
|
||||||
routingHint: null,
|
|
||||||
code: "write_not_authorized",
|
|
||||||
details: { field: "recordKey", value: recordKey, asOf: null, current: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The refusal for a request that lacks a required field or carries an ill-typed one (C# `CultNetErrorMessage.ForMessageInvalid`):
|
|
||||||
* code `message_invalid`, `details.field` naming that request field.
|
|
||||||
*/
|
|
||||||
export function cultNetMessageInvalidError(field: string, reason: string): CultNetErrorMessage {
|
|
||||||
return {
|
|
||||||
schemaVersion: "cultnet.error.v0",
|
|
||||||
error: `message_invalid: ${reason}`,
|
|
||||||
routingHint: null,
|
|
||||||
code: "message_invalid",
|
|
||||||
details: { field, value: null, asOf: null, current: null },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CultNetErrorRoutingHint {
|
export interface CultNetErrorRoutingHint {
|
||||||
shard: Record<string, unknown> | null;
|
shard: Record<string, unknown> | null;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
|
@ -993,8 +958,14 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage {
|
||||||
session: requireLegacyBytes(payload[1], "LoginSuccessMessage.Session"),
|
session: requireLegacyBytes(payload[1], "LoginSuccessMessage.Session"),
|
||||||
};
|
};
|
||||||
case 4:
|
case 4:
|
||||||
// The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone.
|
return {
|
||||||
return cultNetTextError(requireLegacyString(payload[0], "ErrorMessage.Error"));
|
schemaVersion: "cultnet.error.v0",
|
||||||
|
error: requireLegacyString(payload[0], "ErrorMessage.Error"),
|
||||||
|
// The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone.
|
||||||
|
routingHint: null,
|
||||||
|
code: null,
|
||||||
|
details: null,
|
||||||
|
};
|
||||||
case 5:
|
case 5:
|
||||||
return {
|
return {
|
||||||
schemaVersion: "cultnet.sample.change_name.v0",
|
schemaVersion: "cultnet.sample.change_name.v0",
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,8 @@ export interface CultMeshAuthorityRouteCertificate {
|
||||||
/**
|
/**
|
||||||
* Maps an advertised route's wire-shaped certificate to the view
|
* Maps an advertised route's wire-shaped certificate to the view
|
||||||
* `verifyAuthorityRoute` and the transports consume. Shared by the QUIC
|
* `verifyAuthorityRoute` and the transports consume. Shared by the QUIC
|
||||||
* realtime session manager (`@gamecult/cultmesh-ts`) and the browser WebSocket client
|
* realtime session manager (`cultmesh-ts`) and the browser WebSocket client
|
||||||
* (`@gamecult/cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage`
|
* (`cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage`
|
||||||
* certificate shape and were computing this mapping independently.
|
* certificate shape and were computing this mapping independently.
|
||||||
*/
|
*/
|
||||||
export function routeCertificateView(
|
export function routeCertificateView(
|
||||||
|
|
|
||||||
|
|
@ -107,36 +107,26 @@ async function publishDocument(
|
||||||
const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0"));
|
const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0"));
|
||||||
const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() });
|
const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() });
|
||||||
for (const packet of packets) await sendPacket(socket, endpoint, packet);
|
for (const packet of packets) await sendPacket(socket, endpoint, packet);
|
||||||
// The transport ack is not admission: the put completes on Odin's result frame. A refusal rejects whether
|
await receiveUntil(
|
||||||
// it arrives before the ack, after it, or inside the result window; a success reply ends the wait early.
|
receiver,
|
||||||
let succeeded = false;
|
session,
|
||||||
const onFrame = (frame: { channelId: string; payload: Uint8Array }) => {
|
endpoint,
|
||||||
if (frame.channelId !== "schema") return;
|
() => session.outstandingReliablePacketCount === 0,
|
||||||
const response = parseCultNetMessage(decode(frame.payload));
|
2000,
|
||||||
if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response);
|
(frame) => {
|
||||||
succeeded = true;
|
if (frame.channelId !== "schema") return;
|
||||||
};
|
const response = parseCultNetMessage(decode(frame.payload));
|
||||||
await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame);
|
if (response.schemaVersion === "cultnet.error.v0") {
|
||||||
if (!succeeded) await receiveUntil(receiver, session, endpoint, () => succeeded, RESULT_WINDOW_MS, onFrame, true);
|
throw new Error(`Odin rejected runtime presence: ${response.error}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
receiver.close();
|
receiver.close();
|
||||||
socket.close();
|
socket.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How long after the transport ack Odin's refusal may still arrive; a success reply ends the wait early. */
|
|
||||||
const RESULT_WINDOW_MS = 500;
|
|
||||||
|
|
||||||
/** Odin refused the presence put: the typed `cultnet.error.v0` it answered with. */
|
|
||||||
export class IdunnPresenceRefusedError extends Error {
|
|
||||||
readonly code: string | undefined;
|
|
||||||
constructor(readonly response: { error: string; code?: string | null }) {
|
|
||||||
super(`Odin rejected runtime presence: ${response.error}`);
|
|
||||||
this.name = "IdunnPresenceRefusedError";
|
|
||||||
this.code = response.code ?? undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type PacketReceiver = {
|
type PacketReceiver = {
|
||||||
socket: dgram.Socket;
|
socket: dgram.Socket;
|
||||||
next(timeoutMs: number): Promise<CultNetRudpPacket>;
|
next(timeoutMs: number): Promise<CultNetRudpPacket>;
|
||||||
|
|
@ -200,7 +190,6 @@ async function receiveUntil(
|
||||||
predicate: (packet: CultNetRudpPacket) => boolean,
|
predicate: (packet: CultNetRudpPacket) => boolean,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void,
|
onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void,
|
||||||
returnOnTimeout = false,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
|
@ -215,7 +204,6 @@ async function receiveUntil(
|
||||||
}
|
}
|
||||||
for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet);
|
for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet);
|
||||||
}
|
}
|
||||||
if (returnOnTimeout) return;
|
|
||||||
throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`);
|
throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,6 @@ export {
|
||||||
type CultNetRawDocumentRecord,
|
type CultNetRawDocumentRecord,
|
||||||
type CultNetRawPayloadEncoding,
|
type CultNetRawPayloadEncoding,
|
||||||
CULTNET_ERROR_CODES,
|
CULTNET_ERROR_CODES,
|
||||||
cultNetTextError,
|
|
||||||
cultNetMessageInvalidError,
|
|
||||||
cultNetWriteNotAuthorizedError,
|
|
||||||
type CultNetErrorCode,
|
type CultNetErrorCode,
|
||||||
type CultNetErrorDetails,
|
type CultNetErrorDetails,
|
||||||
type CultNetErrorMessage,
|
type CultNetErrorMessage,
|
||||||
|
|
|
||||||
|
|
@ -95,10 +95,6 @@ export class CultNetPeer extends EventEmitter {
|
||||||
return this.#transport?.profile;
|
return this.#transport?.profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire-and-forget: an ack or a return is not admission; read the result frame (a write is refused with
|
|
||||||
* `cultnet.error.v0`, `write_not_authorized` or `message_invalid`, as a `message` event).
|
|
||||||
*/
|
|
||||||
send(message: CultNetMessage): void {
|
send(message: CultNetMessage): void {
|
||||||
const wireValue = encodeCultNetMessageForWire(message, this.#wireContract);
|
const wireValue = encodeCultNetMessageForWire(message, this.#wireContract);
|
||||||
const payload = encode(wireValue);
|
const payload = encode(wireValue);
|
||||||
|
|
@ -110,7 +106,6 @@ export class CultNetPeer extends EventEmitter {
|
||||||
this.#stream?.write(encodeFrame(payload));
|
this.#stream?.write(encodeFrame(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves when the transport has acknowledged what was sent. An ack is not admission; read the result frame. */
|
|
||||||
async flush(timeoutMs?: number): Promise<void> {
|
async flush(timeoutMs?: number): Promise<void> {
|
||||||
await this.#transport?.flush?.(timeoutMs);
|
await this.#transport?.flush?.(timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -270,20 +270,6 @@ export class CultNetDocumentRegistry {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The canonical schema id a put or delete naming `schemaId` writes under, from the id alone, or `undefined` when no
|
|
||||||
* binding takes it (or several claim it). A server asks its write authority about this id, not the alias a peer sent.
|
|
||||||
*/
|
|
||||||
canonicalSchemaId(schemaId: string): string | undefined {
|
|
||||||
try {
|
|
||||||
const binding = this.getBySchemaId(schemaId);
|
|
||||||
return binding ? schemaIdForBinding(binding) : undefined;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof SchemaConflictError) return undefined;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async applyDocumentDeleteMessage(
|
async applyDocumentDeleteMessage(
|
||||||
cache: CultCache,
|
cache: CultCache,
|
||||||
message: CultNetDocumentDeleteMessage,
|
message: CultNetDocumentDeleteMessage,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ import { decode, encode } from "@msgpack/msgpack";
|
||||||
import {
|
import {
|
||||||
CULTNET_ERROR_CODES,
|
CULTNET_ERROR_CODES,
|
||||||
cultNetSchemas,
|
cultNetSchemas,
|
||||||
cultNetMessageInvalidError,
|
|
||||||
cultNetWriteNotAuthorizedError,
|
|
||||||
encodeCultNetMessageForWire,
|
encodeCultNetMessageForWire,
|
||||||
parseCultNetMessage,
|
parseCultNetMessage,
|
||||||
type CultNetErrorMessage,
|
type CultNetErrorMessage,
|
||||||
|
|
@ -83,25 +81,3 @@ test("a code outside the closed enum is refused, as is a malformed details objec
|
||||||
/cultnet\.error\.v0/,
|
/cultnet\.error\.v0/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the write_not_authorized factory writes the C# vector's bytes and names the key", () => {
|
|
||||||
const vector = vectors.find((v) => v.name === "write_not_authorized")!;
|
|
||||||
assert.equal(hexOf(wireOf(cultNetWriteNotAuthorizedError("some:key", "not granted"))), vector.messagePackHex);
|
|
||||||
assert.deepEqual(cultNetWriteNotAuthorizedError("some:key", "not granted"), vector.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the message_invalid factory writes the C# vector's bytes and names the field", () => {
|
|
||||||
const vector = vectors.find((v) => v.name === "message_invalid")!;
|
|
||||||
const built = cultNetMessageInvalidError("document", "Raw put messages must contain a document map.");
|
|
||||||
assert.equal(hexOf(wireOf(built)), vector.messagePackHex);
|
|
||||||
assert.deepEqual(built, vector.message);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a write_not_authorized refusal with no reason, or a blank one, says only the code", () => {
|
|
||||||
assert.equal(cultNetWriteNotAuthorizedError("k").error, "write_not_authorized");
|
|
||||||
assert.equal(cultNetWriteNotAuthorizedError("k", " ").error, "write_not_authorized");
|
|
||||||
assert.equal(cultNetWriteNotAuthorizedError("k", null).error, "write_not_authorized");
|
|
||||||
// C# string.IsNullOrWhiteSpace counts U+0085 as white space and not U+FEFF; JS trim() disagrees on both.
|
|
||||||
assert.equal(cultNetWriteNotAuthorizedError("k", "\u0085").error, "write_not_authorized");
|
|
||||||
assert.equal(cultNetWriteNotAuthorizedError("k", "\ufeff").error, "write_not_authorized: \ufeff");
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -3078,15 +3078,3 @@ test("document put message omits unset optional keys so the encoded wire has no
|
||||||
assert.equal(stamped.document.sourceRuntimeId, "r");
|
assert.equal(stamped.document.sourceRuntimeId, "r");
|
||||||
assert.deepEqual(stamped.document.tags, ["t"]);
|
assert.deepEqual(stamped.document.tags, ["t"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("CultNet registry names the canonical schema id of a compatible alias, and none for an unknown or conflicted id", () => {
|
|
||||||
const shape = z.object({ body: z.string() });
|
|
||||||
const owner = defineDocumentType({ type: "p3.owner", schemaId: "p3.owner.v2", compatibleSchemaIds: ["p3.owner.v1"], schema: shape });
|
|
||||||
const first = defineDocumentType({ type: "p3.first", schemaId: "p3.first", compatibleSchemaIds: ["p3.shared"], schema: shape });
|
|
||||||
const second = defineDocumentType({ type: "p3.second", schemaId: "p3.second", compatibleSchemaIds: ["p3.shared"], schema: shape });
|
|
||||||
const registry = new CultNetDocumentRegistry([owner, first, second].map(definition => defineCultNetDocumentBinding({ definition })));
|
|
||||||
assert.equal(registry.canonicalSchemaId("p3.owner.v1"), "p3.owner.v2");
|
|
||||||
assert.equal(registry.canonicalSchemaId("p3.owner.v2"), "p3.owner.v2");
|
|
||||||
assert.equal(registry.canonicalSchemaId("p3.nobody"), undefined);
|
|
||||||
assert.equal(registry.canonicalSchemaId("p3.shared"), undefined, "an id several bindings claim resolves to none");
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import test from "node:test";
|
||||||
// The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it
|
// The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it
|
||||||
// over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show
|
// over a loopback TCP pipe as a cultnet.document_put_raw.v0 message, receive it into a second cache. The docs show
|
||||||
// the region between the `snippet` markers verbatim, except that the package import reads
|
// the region between the `snippet` markers verbatim, except that the package import reads
|
||||||
// "@gamecult/cultnet-ts" where this file reads "../src"; the docs test below fails when they differ.
|
// "cultnet-ts" where this file reads "../src"; the docs test below fails when they differ.
|
||||||
// snippet:begin
|
// snippet:begin
|
||||||
import { createServer, connect } from "node:net";
|
import { createServer, connect } from "node:net";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -72,7 +72,7 @@ test("docs show the tested snippet", async () => {
|
||||||
const snippet = source
|
const snippet = source
|
||||||
.split("// snippet:begin\n")[1]
|
.split("// snippet:begin\n")[1]
|
||||||
.split("// snippet:end\n")[0]
|
.split("// snippet:end\n")[0]
|
||||||
.replace('"../src"', '"@gamecult/cultnet-ts"');
|
.replace('"../src"', '"cultnet-ts"');
|
||||||
const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n");
|
const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n");
|
||||||
assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts");
|
assert.ok(docs.includes("```ts\n" + snippet + "```"), "docs/start/README.md no longer shows test/first-call.test.ts");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -276,14 +276,9 @@ async function startOdinPeer(context: TestContext, endpoint: string) {
|
||||||
peer.once("error", reject);
|
peer.once("error", reject);
|
||||||
peer.bind(port, "127.0.0.1", resolve);
|
peer.bind(port, "127.0.0.1", resolve);
|
||||||
});
|
});
|
||||||
// The second packet of a split delivery is a timer; the test's end cancels it rather than racing the closed socket.
|
context.after(() => peer.close());
|
||||||
const timers = new Set<NodeJS.Timeout>();
|
|
||||||
const later = (send: () => void) => { timers.add(setTimeout(send, 120)); };
|
|
||||||
context.after(() => { for (const timer of timers) clearTimeout(timer); peer.close(); });
|
|
||||||
const state = {
|
const state = {
|
||||||
reject: false,
|
reject: false,
|
||||||
// How a refusal and the ack of the put reach the publisher: in one packet, or the ack first, or the refusal first.
|
|
||||||
order: "piggyback" as "piggyback" | "ack-then-refusal" | "refusal-then-ack",
|
|
||||||
received: [] as Array<{ schemaVersion?: string; document: { payload: DocumentPayload } }>,
|
received: [] as Array<{ schemaVersion?: string; document: { payload: DocumentPayload } }>,
|
||||||
responseAck: undefined as number | undefined,
|
responseAck: undefined as number | undefined,
|
||||||
failure: undefined as Error | undefined,
|
failure: undefined as Error | undefined,
|
||||||
|
|
@ -310,15 +305,7 @@ async function startOdinPeer(context: TestContext, endpoint: string) {
|
||||||
});
|
});
|
||||||
assert.ok(response);
|
assert.ok(response);
|
||||||
state.responseAck = response.ack;
|
state.responseAck = response.ack;
|
||||||
if (state.reject && state.order === "ack-then-refusal") {
|
peer.send(encodeRudpPacket(response), remote.port, remote.address);
|
||||||
peer.send(encodeRudpPacket(serverSession.createAckForReceived(packet.sequence)), remote.port, remote.address);
|
|
||||||
later(() => peer.send(encodeRudpPacket(response), remote.port, remote.address));
|
|
||||||
} else if (state.reject && state.order === "refusal-then-ack") {
|
|
||||||
peer.send(encodeRudpPacket({ ...response, ack: 0, ackMask: 0 }), remote.port, remote.address);
|
|
||||||
later(() => peer.send(encodeRudpPacket(serverSession!.createAckForReceived(packet.sequence)), remote.port, remote.address));
|
|
||||||
} else {
|
|
||||||
peer.send(encodeRudpPacket(response), remote.port, remote.address);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.failure = error as Error;
|
state.failure = error as Error;
|
||||||
}
|
}
|
||||||
|
|
@ -345,19 +332,6 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte
|
||||||
assert.equal(peer.failure, undefined, peer.failure?.message);
|
assert.equal(peer.failure, undefined, peer.failure?.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const order of ["ack-then-refusal", "refusal-then-ack"] as const) {
|
|
||||||
test(`the Odin publisher does not complete on the ack of a refused presence (${order})`, async (context) => {
|
|
||||||
const endpoint = expectedFacts("service").odinEndpoint!;
|
|
||||||
const peer = await startOdinPeer(context, endpoint);
|
|
||||||
peer.reject = true;
|
|
||||||
peer.order = order;
|
|
||||||
const signer = signerFor(context, "service");
|
|
||||||
signer.reportHealth("active");
|
|
||||||
const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint });
|
|
||||||
await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => {
|
test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => {
|
||||||
const endpoint = expectedFacts("service").odinEndpoint!;
|
const endpoint = expectedFacts("service").odinEndpoint!;
|
||||||
const peer = await startOdinPeer(context, endpoint);
|
const peer = await startOdinPeer(context, endpoint);
|
||||||
|
|
@ -487,13 +461,13 @@ function loadUnderDgramStub(modulePath: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
test("the idunn-runtime subpath loads the signer and loader without loading dgram; the package root does load it", () => {
|
test("the idunn-runtime subpath loads the signer and loader without loading dgram; the package root does load it", () => {
|
||||||
const subpath = require.resolve("@gamecult/cultnet-ts/idunn-runtime");
|
const subpath = require.resolve("cultnet-ts/idunn-runtime");
|
||||||
assert.equal(loadUnderDgramStub(subpath), "loaded");
|
assert.equal(loadUnderDgramStub(subpath), "loaded");
|
||||||
assert.equal(loadUnderDgramStub(require.resolve("@gamecult/cultnet-ts")), "failed: dgram was loaded");
|
assert.equal(loadUnderDgramStub(require.resolve("cultnet-ts")), "failed: dgram was loaded");
|
||||||
const exported = require(subpath) as Record<string, unknown>;
|
const exported = require(subpath) as Record<string, unknown>;
|
||||||
assert.equal(typeof exported.createIdunnRuntimeSigner, "function");
|
assert.equal(typeof exported.createIdunnRuntimeSigner, "function");
|
||||||
assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function");
|
assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function");
|
||||||
assert.equal(exported.createIdunnRuntimePresencePublisher, undefined);
|
assert.equal(exported.createIdunnRuntimePresencePublisher, undefined);
|
||||||
assert.equal(exported.signIdunnRuntimePresence, undefined);
|
assert.equal(exported.signIdunnRuntimePresence, undefined);
|
||||||
assert.equal((require("@gamecult/cultnet-ts") as Record<string, unknown>).signIdunnRuntimePresence, undefined);
|
assert.equal((require("cultnet-ts") as Record<string, unknown>).signIdunnRuntimePresence, undefined);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ This is the real-process continuation of `samples/eve-two-runtime`:
|
||||||
|
|
||||||
- one C# provider owns a durable `.cc` counter, the Eve surface, command
|
- one C# provider owns a durable `.cc` counter, the Eve surface, command
|
||||||
idempotency, and canonical receipts;
|
idempotency, and canonical receipts;
|
||||||
- one real Chromium page leases both documents through `@gamecult/cultmesh-browser` and
|
- one real Chromium page leases both documents through `cultmesh-browser` and
|
||||||
lowers the provider-owned Eve surface;
|
lowers the provider-owned Eve surface;
|
||||||
- one retained C# headless client discovers the provider through the same Odin
|
- one retained C# headless client discovers the provider through the same Odin
|
||||||
identity, observes the typed counter and canonical receipt ids, survives the
|
identity, observes the typed counter and canonical receipt ids, survives the
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
CultMeshBrowserOdinRendezvous,
|
CultMeshBrowserOdinRendezvous,
|
||||||
decodeCultNetOperationPayload,
|
decodeCultNetOperationPayload,
|
||||||
decodeCultNetPayload,
|
decodeCultNetPayload,
|
||||||
} from "@gamecult/cultmesh-browser";
|
} from "cultmesh-browser";
|
||||||
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
|
import { defineDocumentType } from "@gamecult/cultcache-ts/document";
|
||||||
|
|
||||||
// A payload is read through the definition that says how its bytes are laid out. These are plain-map definitions: the
|
// A payload is read through the definition that says how its bytes are laid out. These are plain-map definitions: the
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
||||||
import { CultMesh } from "@gamecult/cultmesh-ts";
|
import { CultMesh } from "cultmesh-ts";
|
||||||
import {
|
import {
|
||||||
parseEveCommandReceipt,
|
parseEveCommandReceipt,
|
||||||
parseEveSurfaceDocument,
|
parseEveSurfaceDocument,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ const npmCli = process.env.npm_execpath ??
|
||||||
// moves when a package is scoped: `8cb3b72` renamed `cultcache-ts` to
|
// moves when a package is scoped: `8cb3b72` renamed `cultcache-ts` to
|
||||||
// `@gamecult/cultcache-ts` and left this entry behind, which broke `build:ts` and
|
// `@gamecult/cultcache-ts` and left this entry behind, which broke `build:ts` and
|
||||||
// `test:ts` for every workspace from that commit on.
|
// `test:ts` for every workspace from that commit on.
|
||||||
const workspaces = ["@gamecult/cultcache-ts", "@gamecult/cultnet-ts", "@gamecult/cultmesh-ts", "@gamecult/cultmesh-browser"];
|
const workspaces = ["@gamecult/cultcache-ts", "cultnet-ts", "cultmesh-ts", "cultmesh-browser"];
|
||||||
const childEnv = process.platform === "win32"
|
const childEnv = process.platform === "win32"
|
||||||
? {
|
? {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
|
|
||||||
|
|
@ -79,8 +79,8 @@ try {
|
||||||
const assert = require("node:assert/strict");
|
const assert = require("node:assert/strict");
|
||||||
const cache = require("@gamecult/cultcache-ts");
|
const cache = require("@gamecult/cultcache-ts");
|
||||||
const documentEntry = require("@gamecult/cultcache-ts/document");
|
const documentEntry = require("@gamecult/cultcache-ts/document");
|
||||||
const net = require("@gamecult/cultnet-ts");
|
const net = require("cultnet-ts");
|
||||||
const mesh = require("@gamecult/cultmesh-ts");
|
const mesh = require("cultmesh-ts");
|
||||||
assert.equal(typeof cache.defineDocumentType, "function");
|
assert.equal(typeof cache.defineDocumentType, "function");
|
||||||
// The document owner is also an entry of its own, which the browser client imports without the Node-only cache.
|
// The document owner is also an entry of its own, which the browser client imports without the Node-only cache.
|
||||||
assert.equal(documentEntry.decodeDocumentPayload, cache.decodeDocumentPayload);
|
assert.equal(documentEntry.decodeDocumentPayload, cache.decodeDocumentPayload);
|
||||||
|
|
@ -147,8 +147,8 @@ assert.deepEqual(mesh.CultMesh.vec2(3, 4), { x: 3, y: 4 });
|
||||||
|
|
||||||
writeFileSync(join(consumerRoot, "browser-runtime-smoke.mjs"), `
|
writeFileSync(join(consumerRoot, "browser-runtime-smoke.mjs"), `
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { CultMeshBrowserClient, decodeCultNetPayload } from "@gamecult/cultmesh-browser";
|
import { CultMeshBrowserClient, decodeCultNetPayload } from "cultmesh-browser";
|
||||||
import { parseCultNetMessage } from "@gamecult/cultnet-ts/contracts";
|
import { parseCultNetMessage } from "cultnet-ts/contracts";
|
||||||
assert.equal(typeof CultMeshBrowserClient.connect, "function");
|
assert.equal(typeof CultMeshBrowserClient.connect, "function");
|
||||||
assert.equal(typeof decodeCultNetPayload, "function");
|
assert.equal(typeof decodeCultNetPayload, "function");
|
||||||
assert.equal(typeof parseCultNetMessage, "function");
|
assert.equal(typeof parseCultNetMessage, "function");
|
||||||
|
|
@ -159,14 +159,14 @@ assert.equal("putDocument" in CultMeshBrowserClient.prototype, false);
|
||||||
writeFileSync(join(consumerRoot, "types-smoke.ts"), `
|
writeFileSync(join(consumerRoot, "types-smoke.ts"), `
|
||||||
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
||||||
import { decodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts/document";
|
import { decodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts/document";
|
||||||
import { CultNetPeer } from "@gamecult/cultnet-ts";
|
import { CultNetPeer } from "cultnet-ts";
|
||||||
import {
|
import {
|
||||||
CultMesh,
|
CultMesh,
|
||||||
CultMeshProviderSession,
|
CultMeshProviderSession,
|
||||||
type CultMeshProviderIdentity,
|
type CultMeshProviderIdentity,
|
||||||
type CultMeshProviderTransport,
|
type CultMeshProviderTransport,
|
||||||
type CultMeshVec2,
|
type CultMeshVec2,
|
||||||
} from "@gamecult/cultmesh-ts";
|
} from "cultmesh-ts";
|
||||||
|
|
||||||
const point: CultMeshVec2 = CultMesh.vec2(3, 4);
|
const point: CultMeshVec2 = CultMesh.vec2(3, 4);
|
||||||
const defineDocument: typeof defineDocumentType = defineDocumentType;
|
const defineDocument: typeof defineDocumentType = defineDocumentType;
|
||||||
|
|
@ -209,7 +209,7 @@ import {
|
||||||
CultMeshBrowserClient,
|
CultMeshBrowserClient,
|
||||||
type CultMeshBrowserIdentity,
|
type CultMeshBrowserIdentity,
|
||||||
type CultMeshRawDocumentLeaseOptions,
|
type CultMeshRawDocumentLeaseOptions,
|
||||||
} from "@gamecult/cultmesh-browser";
|
} from "cultmesh-browser";
|
||||||
|
|
||||||
const identity: CultMeshBrowserIdentity = {
|
const identity: CultMeshBrowserIdentity = {
|
||||||
verseId: "smoke",
|
verseId: "smoke",
|
||||||
|
|
|
||||||
|
|
@ -46,8 +46,8 @@ try {
|
||||||
const moduleMap = {
|
const moduleMap = {
|
||||||
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
|
"@gamecult/eve-contracts": join(eveRoot, "packages", "eve-contracts", "src", "index.ts"),
|
||||||
"@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"),
|
"@gamecult/eve-browser-lowering": join(eveRoot, "packages", "eve-browser-lowering", "src", "index.ts"),
|
||||||
"@gamecult/cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"),
|
"cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"),
|
||||||
"@gamecult/cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"),
|
"cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"),
|
||||||
};
|
};
|
||||||
const sampleTsconfig = join(workRoot, "tsconfig.json");
|
const sampleTsconfig = join(workRoot, "tsconfig.json");
|
||||||
await writeFile(sampleTsconfig, JSON.stringify({
|
await writeFile(sampleTsconfig, JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,6 @@ namespace GameCult.Mesh
|
||||||
|
|
||||||
/// <summary>Gets or sets the deadline for one correlated typed operation response.</summary>
|
/// <summary>Gets or sets the deadline for one correlated typed operation response.</summary>
|
||||||
public TimeSpan OperationResponseTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
public TimeSpan OperationResponseTimeout { get; set; } = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets how long after a submitted document the provider's refusal may still arrive. The provider
|
|
||||||
/// sends no success frame for an admitted submission, so this window bounds <c>SubmitDocumentAsync</c>.
|
|
||||||
/// </summary>
|
|
||||||
public TimeSpan SubmitResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -155,7 +149,6 @@ namespace GameCult.Mesh
|
||||||
private readonly CultMeshSessionManager _sessions;
|
private readonly CultMeshSessionManager _sessions;
|
||||||
private readonly TimeSpan _subscriptionResponseTimeout;
|
private readonly TimeSpan _subscriptionResponseTimeout;
|
||||||
private readonly TimeSpan _operationResponseTimeout;
|
private readonly TimeSpan _operationResponseTimeout;
|
||||||
private readonly TimeSpan _submitResultWindow;
|
|
||||||
private readonly ConcurrentDictionary<string, SharedResource> _documents = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, SharedResource> _documents = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<string, SharedResource> _collections = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, SharedResource> _collections = new(StringComparer.Ordinal);
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
@ -198,7 +191,6 @@ namespace GameCult.Mesh
|
||||||
options.Sessions);
|
options.Sessions);
|
||||||
_subscriptionResponseTimeout = options.SubscriptionResponseTimeout;
|
_subscriptionResponseTimeout = options.SubscriptionResponseTimeout;
|
||||||
_operationResponseTimeout = options.OperationResponseTimeout;
|
_operationResponseTimeout = options.OperationResponseTimeout;
|
||||||
_submitResultWindow = options.SubmitResultWindow;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Connects to one stable Verse/provider target using one application protocol.</summary>
|
/// <summary>Connects to one stable Verse/provider target using one application protocol.</summary>
|
||||||
|
|
@ -327,10 +319,7 @@ namespace GameCult.Mesh
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Submits one typed document to a provider by stable identity. Submission is uncommitted input;
|
/// Submits one typed document to a provider by stable identity. Submission is uncommitted input;
|
||||||
/// only a provider-authored receipt or resulting state can establish acceptance. The task completes on
|
/// only a provider-authored receipt or resulting state can establish acceptance.
|
||||||
/// the provider's result frame: a refusal (<c>write_not_authorized</c>, <c>message_invalid</c>, ...) throws
|
|
||||||
/// <see cref="CultNetWriteRefusedException"/>, and a submission not refused within
|
|
||||||
/// <see cref="CultMeshClientOptions.SubmitResultWindow"/> completes.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task SubmitDocumentAsync<TDocument>(
|
public async Task SubmitDocumentAsync<TDocument>(
|
||||||
CultMeshSessionTarget target,
|
CultMeshSessionTarget target,
|
||||||
|
|
@ -362,11 +351,7 @@ namespace GameCult.Mesh
|
||||||
var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken)
|
var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
await CultNetWriteCompletion.SendAndAwaitResultAsync(
|
session.OpenSchemaClient().SendCultNet(message);
|
||||||
refuse => session.OnCultNet(refuse),
|
|
||||||
() => session.SendCultNet(message),
|
|
||||||
_submitResultWindow,
|
|
||||||
cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
|
|
@ -352,7 +352,6 @@ namespace GameCult.Mesh
|
||||||
internal long PhysicalGeneration => Interlocked.Read(ref _physicalGeneration);
|
internal long PhysicalGeneration => Interlocked.Read(ref _physicalGeneration);
|
||||||
public Observable<CultMeshSessionState> WatchState() => _states;
|
public Observable<CultMeshSessionState> WatchState() => _states;
|
||||||
public ICultNetSchemaClient OpenSchemaClient() => new SessionSchemaClient(this);
|
public ICultNetSchemaClient OpenSchemaClient() => new SessionSchemaClient(this);
|
||||||
/// <summary>Fire-and-forget: an ack or a return is not admission; read the result frame.</summary>
|
|
||||||
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage => Channel.SendCultNet(message);
|
public void SendCultNet<T>(T message) where T : ICultNetSchemaMessage => Channel.SendCultNet(message);
|
||||||
public IDisposable OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
public IDisposable OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue