diff --git a/.github/workflows/cultmesh-portability.yml b/.github/workflows/cultmesh-portability.yml index 19bf1b34..3648ba2b 100644 --- a/.github/workflows/cultmesh-portability.yml +++ b/.github/workflows/cultmesh-portability.yml @@ -9,6 +9,45 @@ on: workflow_dispatch: jobs: + getting-started: + name: ${{ matrix.os }} one-command multiplayer onboarding + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The forge runner is Ubuntu-only: Windows legs run on workflow_dispatch (GitHub or a Windows runner), Ubuntu gates push and PR. + os: ${{ fromJSON(github.event_name == 'workflow_dispatch' && '["windows-latest", "ubuntu-latest"]' || '["ubuntu-latest"]') }} + + steps: + - name: Check out CultLib + uses: actions/checkout@v4 + with: + path: CultLib + + - name: Check out the matching Eve contract + uses: actions/checkout@v4 + with: + repository: GameCult/Eve + # Eve main is the contract this sample is written against; the codex + # branch this used to pin was never merged and stopped on 2026-08-25. + ref: ${{ vars.EVE_INTEGRATION_REF || 'main' }} + path: Eve + + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Run the documented one-command checkpoint + shell: pwsh + run: ./CultLib/scripts/verify-eve-getting-started.ps1 -EveRoot ./Eve + reactive-and-resource-lifetime: name: ${{ matrix.os }} reactive semantics and leases runs-on: ${{ matrix.os }} diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index caa24336..eb798bdf 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -8,26 +8,15 @@ name: Publish packages # directly, which is already how Ghostlight, Epiphany, CodexConnector and Odin # consume the core stack. # -# Tag a Python release to publish, for example `cultnet-py-v0.1.0`. The tag's -# version must match the version in the package manifest; -# scripts/release-after-suite.mjs refuses the release otherwise rather 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. +# Tag a release to publish, for example `cultcache-ts-v0.13.6` or +# `cultnet-py-v0.1.0`. The tag's version must match the version in the package +# manifest; scripts/release-after-suite.mjs refuses the release otherwise rather +# than shipping a mislabelled artifact. on: 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: + - "cultcache-ts-v*" - "cultcache-py-v*" - "cultnet-py-v*" - "cultmesh-py-v*" diff --git a/README.md b/README.md index b916cabe..6e6de55c 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,7 @@ start with the maintained series. Its first executable package-consumer checkpoint is [`samples/eve-two-runtime`](samples/eve-two-runtime/README.md). The real-process browser continuation is -[`samples/eve-browser-network`](samples/eve-browser-network/README.md); it -needs a sibling Eve checkout (`--eve-root`) and is not in `CultLib.sln`. +[`samples/eve-browser-network`](samples/eve-browser-network/README.md). One documented command runs both layers: ```powershell diff --git a/contracts/cultnet/cultnet-distributed-database.md b/contracts/cultnet/cultnet-distributed-database.md index 686f4892..a170835e 100644 --- a/contracts/cultnet/cultnet-distributed-database.md +++ b/contracts/cultnet/cultnet-distributed-database.md @@ -112,11 +112,8 @@ not guess. Adding a code is therefore a coordinated change across every runtime, copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and a captured-bytes case in `packages/cultnet-rs/tests/error_message.rs` for it. TypeScript reads the schema. The current codes are `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), -`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 -`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. +`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns) and +`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant). ## Live Invariants diff --git a/contracts/cultnet/cultnet.error.schema.json b/contracts/cultnet/cultnet.error.schema.json index 07befacc..e3b94c2b 100644 --- a/contracts/cultnet/cultnet.error.schema.json +++ b/contracts/cultnet/cultnet.error.schema.json @@ -28,7 +28,7 @@ "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).", "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"] }, "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.", @@ -36,8 +36,8 @@ "additionalProperties": false, "required": ["field", "value", "asOf", "current"], "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"] }, - "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"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\".", "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.", "type": ["string", "null"] }, "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 } } diff --git a/contracts/cultnet/interop/carrier-vectors.cs-written.json b/contracts/cultnet/interop/carrier-vectors.cs-written.json deleted file mode 100644 index 394724cf..00000000 --- a/contracts/cultnet/interop/carrier-vectors.cs-written.json +++ /dev/null @@ -1,346 +0,0 @@ -{ - "vectors": [ - { - "name": "snapshot_request_v1_defaults", - "schemaVersion": "cultnet.snapshot_request.v1", - "messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWShbalzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywKdzaGFyZElkwKpzaGFyZEVwb2NowA==", - "expected": { - "schemaVersion": "cultnet.snapshot_request.v1", - "messageId": "m", - "selection": { - "schemas": null, - "keys": null, - "fields": null, - "cites": null, - "cited": null, - "projection": "header", - "descending": false, - "limit": null, - "cursor": null - }, - "shardId": null, - "shardEpoch": null - } - }, - { - "name": "snapshot_request_v1_full_selection_and_shard", - "schemaVersion": "cultnet.snapshot_request.v1", - "messagePackBase64": "ha1zY2hlbWFWZXJzaW9uu2N1bHRuZXQuc25hcHNob3RfcmVxdWVzdC52MaltZXNzYWdlSWSmbS1mdWxsqXNlbGVjdGlvbomnc2NoZW1hc5KpbGVhZl9hLnY52UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqRrZXlzkqRhLWVxqPCfmIAtcm93pmZpZWxkc5KEpWluZGV4pGtpbmSib3CmYW55X29mpnZhbHVlc5Kmd2VhcG9upHRvb2ymbnVtYmVywISlaW5kZXikbWFzc6JvcKJnZaZ2YWx1ZXPApm51bWJlcqstMjIzMzc1OS4yNaVjaXRlc4KmdGFyZ2V0gqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGkcm9sZaZEZXNpZ26lY2l0ZWSCpHJvbGWmRGVzaWdupmV4aXN0c8KqcHJvamVjdGlvbqhkb2N1bWVudKpkZXNjZW5kaW5nw6VsaW1pdMzIpmN1cnNvcq1vcGFxdWUtY3Vyc29yp3NoYXJkSWSnc2hhcmQtMapzaGFyZEVwb2NoBw==", - "expected": { - "schemaVersion": "cultnet.snapshot_request.v1", - "messageId": "m-full", - "selection": { - "schemas": [ - "leaf_a.v9", - "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff" - ], - "keys": [ - "a-eq", - "\uD83D\uDE00-row" - ], - "fields": [ - { - "index": "kind", - "op": "any_of", - "values": [ - "weapon", - "tool" - ], - "number": null - }, - { - "index": "mass", - "op": "ge", - "values": null, - "number": "-2233759.25" - } - ], - "cites": { - "target": { - "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", - "recordKey": "a-eq" - }, - "role": "Design" - }, - "cited": { - "role": "Design", - "exists": false - }, - "projection": "document", - "descending": true, - "limit": 200, - "cursor": "opaque-cursor" - }, - "shardId": "shard-1", - "shardEpoch": 7 - } - }, - { - "name": "database_subscribe_v1_defaults", - "schemaVersion": "cultnet.database_subscribe.v1", - "messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKFtrnN1YnNjcmlwdGlvbklko3N1YqlzZWxlY3Rpb26Jp3NjaGVtYXPApGtleXPApmZpZWxkc8ClY2l0ZXPApWNpdGVkwKpwcm9qZWN0aW9upmhlYWRlcqpkZXNjZW5kaW5nwqVsaW1pdMCmY3Vyc29ywK9pbmNsdWRlU25hcHNob3TDsWNvbnN1bWVyUnVudGltZUlkwKdib2R5SWRzwLdzdXBwb3J0ZWRCb2R5VHJhbnNwb3J0c8A=", - "expected": { - "schemaVersion": "cultnet.database_subscribe.v1", - "messageId": "m", - "subscriptionId": "sub", - "selection": { - "schemas": null, - "keys": null, - "fields": null, - "cites": null, - "cited": null, - "projection": "header", - "descending": false, - "limit": null, - "cursor": null - }, - "includeSnapshot": true, - "consumerRuntimeId": null, - "bodyIds": null, - "supportedBodyTransports": null - } - }, - { - "name": "database_subscribe_v1_full", - "schemaVersion": "cultnet.database_subscribe.v1", - "messagePackBase64": "iK1zY2hlbWFWZXJzaW9uvWN1bHRuZXQuZGF0YWJhc2Vfc3Vic2NyaWJlLnYxqW1lc3NhZ2VJZKZtLWZ1bGyuc3Vic2NyaXB0aW9uSWSoc3ViLWZ1bGypc2VsZWN0aW9uiadzY2hlbWFzkqlsZWFmX2EudjnZR3NoYTI1NjpmZjM1ZjNkOWQ2ZGUzZjEwNjZiOTM2OWU3M2ZlNmM3NjQ4ZTg4MzY4ZDE4ZWZjMjI3NWM5OTQ3MzBjZDg0Y2ZmpGtleXOSpGEtZXGo8J\u002BYgC1yb3emZmllbGRzkoSlaW5kZXika2luZKJvcKZhbnlfb2amdmFsdWVzkqZ3ZWFwb26kdG9vbKZudW1iZXLAhKVpbmRleKRtYXNzom9womdlpnZhbHVlc8CmbnVtYmVyqy0yMjMzNzU5LjI1pWNpdGVzgqZ0YXJnZXSCqHNjaGVtYUlk2UdzaGEyNTY6ZmYzNWYzZDlkNmRlM2YxMDY2YjkzNjllNzNmZTZjNzY0OGU4ODM2OGQxOGVmYzIyNzVjOTk0NzMwY2Q4NGNmZqlyZWNvcmRLZXmkYS1lcaRyb2xlpkRlc2lnbqVjaXRlZIKkcm9sZaZEZXNpZ26mZXhpc3Rzwqpwcm9qZWN0aW9uqGRvY3VtZW50qmRlc2NlbmRpbmfDpWxpbWl0zMimY3Vyc29yrW9wYXF1ZS1jdXJzb3KvaW5jbHVkZVNuYXBzaG90wrFjb25zdW1lclJ1bnRpbWVJZKhjb25zdW1lcqdib2R5SWRzkaZib2R5LWG3c3VwcG9ydGVkQm9keVRyYW5zcG9ydHOSpHJ1ZHCkcXVpYw==", - "expected": { - "schemaVersion": "cultnet.database_subscribe.v1", - "messageId": "m-full", - "subscriptionId": "sub-full", - "selection": { - "schemas": [ - "leaf_a.v9", - "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff" - ], - "keys": [ - "a-eq", - "\uD83D\uDE00-row" - ], - "fields": [ - { - "index": "kind", - "op": "any_of", - "values": [ - "weapon", - "tool" - ], - "number": null - }, - { - "index": "mass", - "op": "ge", - "values": null, - "number": "-2233759.25" - } - ], - "cites": { - "target": { - "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", - "recordKey": "a-eq" - }, - "role": "Design" - }, - "cited": { - "role": "Design", - "exists": false - }, - "projection": "document", - "descending": true, - "limit": 200, - "cursor": "opaque-cursor" - }, - "includeSnapshot": false, - "consumerRuntimeId": "consumer", - "bodyIds": [ - "body-a" - ], - "supportedBodyTransports": [ - "rudp", - "quic" - ] - } - }, - { - "name": "snapshot_response_raw_v1_headers", - "schemaVersion": "cultnet.snapshot_response_raw.v1", - "messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAqRhc09mBaRuZXh0q25leHQtY3Vyc29yp2hlYWRlcnOSiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1lcahzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3PAiqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZaZsZWFmX2Gtc2NoZW1hVmVyc2lvbqExsXNjaGVtYUNvbnRlbnRIYXNoo2FiY6lyZWNvcmRLZXmkYS1sb6hzdG9yZWRBdLgxOTcwLTAxLTAxVDAwOjAwOjAwLjAwMFqvc291cmNlUnVudGltZUlkonJ0rXNvdXJjZUFnZW50SWSlYWdlbnSqc291cmNlUm9sZaRyb2xlpHRhZ3OSpWFscGhhpGJldGGpZG9jdW1lbnRzwKVlZGdlc8Cnc2hhcmRJZKdzaGFyZC0xqnNoYXJkRXBvY2gDsHNoYXJkTG9nU2VxdWVuY2Up", - "expected": { - "schemaVersion": "cultnet.snapshot_response_raw.v1", - "messageId": "m", - "matched": 2, - "asOf": 5, - "next": "next-cursor", - "headers": [ - { - "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", - "schemaName": "leaf_a", - "schemaVersion": "1", - "schemaContentHash": "abc", - "recordKey": "a-eq", - "storedAt": "1970-01-01T00:00:00.000Z", - "sourceRuntimeId": "rt", - "sourceAgentId": "agent", - "sourceRole": "role", - "tags": null - }, - { - "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", - "schemaName": "leaf_a", - "schemaVersion": "1", - "schemaContentHash": "abc", - "recordKey": "a-lo", - "storedAt": "1970-01-01T00:00:00.000Z", - "sourceRuntimeId": "rt", - "sourceAgentId": "agent", - "sourceRole": "role", - "tags": [ - "alpha", - "beta" - ] - } - ], - "documents": null, - "edges": null, - "shardId": "shard-1", - "shardEpoch": 3, - "shardLogSequence": 41 - } - }, - { - "name": "snapshot_response_raw_v1_documents_and_edges", - "schemaVersion": "cultnet.snapshot_response_raw.v1", - "messagePackBase64": "i61zY2hlbWFWZXJzaW9u2SBjdWx0bmV0LnNuYXBzaG90X3Jlc3BvbnNlX3Jhdy52MaltZXNzYWdlSWShbadtYXRjaGVkAaRhc09mBaRuZXh0wKdoZWFkZXJzwKlkb2N1bWVudHORjKhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmaqc2NoZW1hTmFtZcCtc2NoZW1hVmVyc2lvbsCxc2NoZW1hQ29udGVudEhhc2jAqXJlY29yZEtleaRhLWVxqHN0b3JlZEF0uDE5NzAtMDEtMDFUMDA6MDA6MDAuMDAwWq9wYXlsb2FkRW5jb2RpbmerbWVzc2FnZXBhY2uncGF5bG9hZMQEgaFrAa9zb3VyY2VSdW50aW1lSWTArXNvdXJjZUFnZW50SWTAqnNvdXJjZVJvbGXApHRhZ3PApWVkZ2VzkoWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nwKdwYXlsb2FkwIWkZnJvbYKoc2NoZW1hSWTZR3NoYTI1Njo5ZDBiYzlhZWJjMzYxOWIwN2FhYmY1NjRmOTg2NjdhZmRlNDE0NDRjMWI4NjY2NjFkNzc3Njg3ZDQ0MzYwOGRjqXJlY29yZEtleadjaXRlci0xpHJvbGWmRGVzaWduonRvgqhzY2hlbWFJZNlHc2hhMjU2OmZmMzVmM2Q5ZDZkZTNmMTA2NmI5MzY5ZTczZmU2Yzc2NDhlODgzNjhkMThlZmMyMjc1Yzk5NDczMGNkODRjZmapcmVjb3JkS2V5pGEtZXGvcGF5bG9hZEVuY29kaW5nq21lc3NhZ2VwYWNrp3BheWxvYWTEAwECA6dzaGFyZElkwKpzaGFyZEVwb2NowLBzaGFyZExvZ1NlcXVlbmNlwA==", - "expected": { - "schemaVersion": "cultnet.snapshot_response_raw.v1", - "messageId": "m", - "matched": 1, - "asOf": 5, - "next": null, - "headers": null, - "documents": [ - { - "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", - "schemaName": null, - "schemaVersion": null, - "schemaContentHash": null, - "recordKey": "a-eq", - "storedAt": "1970-01-01T00:00:00.000Z", - "payloadEncoding": "messagepack", - "payload": "gaFrAQ==", - "sourceRuntimeId": null, - "sourceAgentId": null, - "sourceRole": null, - "tags": null - } - ], - "edges": [ - { - "from": { - "schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc", - "recordKey": "citer-1" - }, - "role": "Design", - "to": { - "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", - "recordKey": "a-eq" - }, - "payloadEncoding": null, - "payload": null - }, - { - "from": { - "schemaId": "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc", - "recordKey": "citer-1" - }, - "role": "Design", - "to": { - "schemaId": "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff", - "recordKey": "a-eq" - }, - "payloadEncoding": "messagepack", - "payload": "AQID" - } - ], - "shardId": null, - "shardEpoch": null, - "shardLogSequence": null - } - }, - { - "name": "error_plain", - "schemaVersion": "cultnet.error.v0", - "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KkYm9vbatyb3V0aW5nSGludMCkY29kZcCnZGV0YWlsc8A=", - "expected": { - "schemaVersion": "cultnet.error.v0", - "error": "boom", - "routingHint": null, - "code": null, - "details": null - } - }, - { - "name": "error_selection_invalid", - "schemaVersion": "cultnet.error.v0", - "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3LZOnNlbGVjdGlvbl9pbnZhbGlkOiBmaWVsZHNbMF0uaW5kZXggImtpbmQiIGlzIG5vdCBkZWNsYXJlZC6rcm91dGluZ0hpbnTApGNvZGWxc2VsZWN0aW9uX2ludmFsaWSnZGV0YWlsc4SlZmllbGSvZmllbGRzWzBdLmluZGV4pXZhbHVlpGtpbmSkYXNPZsCnY3VycmVudMA=", - "expected": { - "schemaVersion": "cultnet.error.v0", - "error": "selection_invalid: fields[0].index \u0022kind\u0022 is not declared.", - "routingHint": null, - "code": "selection_invalid", - "details": { - "field": "fields[0].index", - "value": "kind", - "asOf": null, - "current": null - } - } - }, - { - "name": "error_cursor_stale", - "schemaVersion": "cultnet.error.v0", - "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX3N0YWxlOiBzdGFsZatyb3V0aW5nSGludMCkY29kZaxjdXJzb3Jfc3RhbGWnZGV0YWlsc4SlZmllbGTApXZhbHVlwKRhc09mA6djdXJyZW50CQ==", - "expected": { - "schemaVersion": "cultnet.error.v0", - "error": "cursor_stale: stale", - "routingHint": null, - "code": "cursor_stale", - "details": { - "field": null, - "value": null, - "asOf": 3, - "current": 9 - } - } - }, - { - "name": "error_cursor_invalid_no_details", - "schemaVersion": "cultnet.error.v0", - "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3KzY3Vyc29yX2ludmFsaWQ6IGJhZKtyb3V0aW5nSGludMCkY29kZa5jdXJzb3JfaW52YWxpZKdkZXRhaWxzwA==", - "expected": { - "schemaVersion": "cultnet.error.v0", - "error": "cursor_invalid: bad", - "routingHint": null, - "code": "cursor_invalid", - "details": null - } - }, - { - "name": "error_write_not_authorized", - "schemaVersion": "cultnet.error.v0", - "messagePackBase64": "ha1zY2hlbWFWZXJzaW9usGN1bHRuZXQuZXJyb3IudjClZXJyb3K0d3JpdGVfbm90X2F1dGhvcml6ZWSrcm91dGluZ0hpbnTApGNvZGW0d3JpdGVfbm90X2F1dGhvcml6ZWSnZGV0YWlsc4SlZmllbGSpcmVjb3JkS2V5pXZhbHVloWukYXNPZsCnY3VycmVudMA=", - "expected": { - "schemaVersion": "cultnet.error.v0", - "error": "write_not_authorized", - "routingHint": null, - "code": "write_not_authorized", - "details": { - "field": "recordKey", - "value": "k", - "asOf": null, - "current": null - } - } - } - ] -} \ No newline at end of file diff --git a/contracts/cultnet/interop/error-vectors.json b/contracts/cultnet/interop/error-vectors.json deleted file mode 100644 index 50ebcbb0..00000000 --- a/contracts/cultnet/interop/error-vectors.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "provenance": "Captured from MessagePack C# 3.1.7 (CultNetErrorMessage/CultNetErrorDetails, Options = Standard.WithSecurity(UntrustedData)); pinned byte-for-byte by packages/cultnet-rs/tests/error_message.rs, whose inline hex equals messagePackHex here (test error_vectors_file_matches_the_inline_csharp_captures). Rust encodes to these exact bytes, so C#-written and Rust-written bytes are one set. Every key is written on the wire, nil included.", - "vectors": [ - { - "name": "selection_invalid_schemas_null", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "selection_invalid: must not be empty", - "routingHint": null, - "code": "selection_invalid", - "details": { - "field": "schemas", - "value": null, - "asOf": null, - "current": null - } - } - }, - { - "name": "selection_invalid_field_value", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "selection_invalid: blank key entry", - "routingHint": null, - "code": "selection_invalid", - "details": { - "field": "keys", - "value": " ", - "asOf": null, - "current": null - } - } - }, - { - "name": "cursor_stale", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "cursor_stale: cursor is stale", - "routingHint": null, - "code": "cursor_stale", - "details": { - "field": null, - "value": null, - "asOf": 1, - "current": 2 - } - } - }, - { - "name": "cursor_invalid", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "cursor_invalid: cannot parse cursor", - "routingHint": null, - "code": "cursor_invalid", - "details": null - } - }, - { - "name": "reference_outside_target", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "reference_outside_target: outside declared target", - "routingHint": null, - "code": "reference_outside_target", - "details": null - } - }, - { - "name": "variant_unsupported_subscription_ended", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C7384A56669656C64AE737562736372697074696F6E4964A576616C7565A57375622D31A461734F66C0A763757272656E74C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "variant unsupported here", - "routingHint": null, - "code": "variant_unsupported", - "details": { - "field": "subscriptionId", - "value": "sub-1", - "asOf": null, - "current": null - } - } - }, - { - "name": "variant_unsupported_no_subscription", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B876617269616E7420756E737570706F727465642068657265AB726F7574696E6748696E74C0A4636F6465B376617269616E745F756E737570706F72746564A764657461696C73C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "variant unsupported here", - "routingHint": null, - "code": "variant_unsupported", - "details": null - } - }, - { - "name": "unowned_schema", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D95C756E6F776E65645F736368656D613A204E6F207368617264206F776E7320736368656D612027736F6D652E736368656D612E763127206B65792027736F6D653A6B6579273B207468652077726974652077617320726566757365642EAB726F7574696E6748696E74C0A4636F6465AE756E6F776E65645F736368656D61A764657461696C7384A56669656C64A8736368656D614964A576616C7565AE736F6D652E736368656D612E7631A461734F66C0A763757272656E74C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "unowned_schema: No shard owns schema 'some.schema.v1' key 'some:key'; the write was refused.", - "routingHint": null, - "code": "unowned_schema", - "details": { - "field": "schemaId", - "value": "some.schema.v1", - "asOf": null, - "current": null - } - } - }, - { - "name": "write_not_authorized", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "write_not_authorized: not granted", - "routingHint": null, - "code": "write_not_authorized", - "details": { - "field": "recordKey", - "value": "some:key", - "asOf": null, - "current": null - } - } - }, - { - "name": "unsupported_schema_version", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "unsupported_schema_version: cultnet.hello.v1", - "routingHint": null, - "code": "unsupported_schema_version", - "details": { - "field": "schemaVersion", - "value": "cultnet.hello.v1", - "asOf": 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 - } - } - } - ] -} diff --git a/docs/cultmesh-ts-content-map.md b/docs/cultmesh-ts-content-map.md index 6bf9db49..9aae6eba 100644 --- a/docs/cultmesh-ts-content-map.md +++ b/docs/cultmesh-ts-content-map.md @@ -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 | | 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 | -| 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 | | 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 | @@ -276,10 +276,11 @@ remains: history (Keep a Changelog headings). - 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`. - These go in **two pushes** (at most three tags per push, SKILL.md). No - `-ts-v*` tag triggers `publish-packages.yml` on main (F16), so these tags - publish nothing; - follow-up `npm-publish-ts-packages` owns npm publishing. + These go in **two pushes** (at most three tags per push, SKILL.md): + `cultcache-ts` first, because its tag triggers `publish-packages.yml` + (F16), and the three plane tags second. Whether the `cultcache-ts` tag's + 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 tagged commit) is StreamPixels' B-cuts, not this campaign. - Size: about 60 lines of version and CHANGELOG text; a short Hands pass. diff --git a/docs/semver-policy.md b/docs/semver-policy.md index 17083e21..324b1154 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -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.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 | -| `@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` | | `cultnet-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 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 - must be a workflow trigger or, for a package the workflow does not tag-trigger - (the Caching Unity package, `cultcache-ts`), the prefix of tags that hold its own manifest at the tag's own + must be a workflow trigger or, for the Caching Unity package the workflow does + 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 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 @@ -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 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 ) -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 -`publish-packages.yml` only triggers on `cultcache-py-v*`, `cultnet-py-v*`, and -`cultmesh-py-v*` tag pushes (`cultcache-ts-v*` is deliberately not a trigger: npm -publishing is off until ruled on, and the cultcache-ts job runs from -`workflow_dispatch` only, publishing nothing) — it does not build or gate +`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`, +`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate the Unity UPM packages at all; those are consumed directly by git tag (`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`, `src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags diff --git a/docs/start/README.md b/docs/start/README.md index e033c935..99b66354 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -16,10 +16,10 @@ this page. | | CultCache | CultMath | Geometry | CultNet | CultMesh | | --- | --- | --- | --- | --- | --- | -| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [first call, tested](#c-cultnet-first-call); [package](../../src/GameCult.Networking) | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | -| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [first call, tested](#typescript-cultnet-first-call); [package](../../packages/cultnet-ts/README.md) | [package](../../packages/cultmesh-ts/README.md); snippet not yet; [recipe: two-client .io game](io-game.md), tested with two live clients, browser page not yet run | -| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [first call, tested](#rust-cultnet-first-call); [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples) | [package](../../packages/cultmesh-rs); snippet not yet | -| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [first call, tested](#python-cultnet-first-call); [package](../../packages/cultnet-py/README.md) | [package](../../packages/cultmesh-py/README.md); snippet not yet | +| **C#** | [first call, tested](#c-first-call) | [package](../../packages/cultmath/README.md); snippet not yet | [package](../../src/GameCult.Geometry/README.md); snippet not yet | [package](../../src/GameCult.Networking); snippet not yet | [quickstart](../../src/GameCult.Mesh/docs/durable-node-quickstart.md); run by `scripts/verify-eve-getting-started.ps1` | +| **TypeScript** | [first call, tested](#typescript-first-call) | not yet | not yet | [package](../../packages/cultnet-ts/README.md); snippet not yet | [package](../../packages/cultmesh-ts/README.md); snippet not yet | +| **Rust** | [first call, tested](#rust-first-call) | [native core](../../packages/cultmath/native/cultmath-core); snippet not yet | not yet | [package](../../packages/cultnet-rs/README.md), [examples](../../packages/cultnet-rs/examples); snippet not yet | [package](../../packages/cultmesh-rs); snippet not yet | +| **Python** | [first call, tested](#python-first-call) | not yet | not yet | [package](../../packages/cultnet-py/README.md); snippet not yet | [package](../../packages/cultmesh-py/README.md); snippet not yet | For the whole picture of what each piece is for, see the @@ -222,244 +222,6 @@ def first_call() -> Item: return reopened.get_required(item_doc, "item:potion") ``` -## CultNet first calls - -CultNet is schema-v0 messages in length-prefixed frames. The first call in each runtime is the same -shape: define a typed document, build a `cultnet.document_put_raw.v0` message carrying its MessagePack -payload, send one frame over a loopback pipe, and apply the received message to a second cache. Each -block is verbatim from the test that runs it; a second test in the same file fails if the block stops -matching. - -### C# CultNet first call - -Source: [`tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs`](../../tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs), -run by `dotnet test tests/GameCult.Networking.Tests --filter CultNetFirstCallTests`. More: `src/GameCult.Networking/README.md`. - -```csharp -using System.Net; -using System.Net.Sockets; -using System.Threading.Tasks; -using GameCult.Caching; -using GameCult.Networking; -using MessagePack; - -[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")] -[MessagePackObject] -public sealed class Note -{ - [Key(0)] [CultName] public string Title = string.Empty; - [Key(1)] public string Body = string.Empty; -} - -public static class CultNetFirstCall -{ - public static async Task Run() - { - var inbox = new CultCache(); // the receiver's cache - var documents = new CultNetDocumentRegistry(); - var received = new TaskCompletionSource(); - - // Receive: a loopback listener that applies each raw document put to the inbox. - using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0)); - server.OnCultNet(async (put, peer) => - { - await documents.ApplyRawDocumentPutMessageAsync(inbox, put); - received.TrySetResult(true); - }); - - // Send: a typed document as a cultnet.document_put_raw.v0 message. - using var client = new TcpFramedCultNetSchemaClient(); - client.Connect("127.0.0.1", server.LocalEndPoint.Port); - var handle = new CultRecordHandle(new CultRecordKey("note:hello")); - client.SendCultNet(documents.CreateRawDocumentPutMessage( - "put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" })); - - await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); - return inbox.Get(handle.Key); - } -} -``` - -### TypeScript CultNet first call - -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 -from `../src` where the block reads `@gamecult/cultnet-ts`. More: `packages/cultnet-ts/README.md`. - -```ts -import { createServer, connect } from "node:net"; -import { z } from "zod"; -import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts"; -import { - CultNetDocumentRegistry, - createTcpFramedCultNetPeer, - defineCultNetDocumentBinding, -} from "@gamecult/cultnet-ts"; - -const noteDocument = defineDocumentType({ - type: "note", - schemaId: "gamecult.first_call_note.v1", - schemaName: "gamecult.first_call_note", - schemaVersion: "gamecult.first_call_note.v1", - schema: z.object({ title: z.string(), body: z.string() }), - name: "title", -}); -const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]); - -async function firstCall() { - const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const; - const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache - - // Receiver: a loopback listener whose peer applies each document put to the inbox. - const received = new Promise((resolve, reject) => { - const server = createServer((socket) => { - const peer = createTcpFramedCultNetPeer(socket, options); - peer.on("invalidMessage", reject); - peer.on("message", async (message) => { - if (message.schemaVersion !== "cultnet.document_put_raw.v0") return; - await documents.applyRawDocumentPutMessage(inbox, message); - peer.close(); - server.close(); - resolve(); - }); - }).listen(0, "127.0.0.1", () => { - // Sender: connect, build a typed put message, send it. - const { port } = server.address() as { port: number }; - const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options); - sender.send( - documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", { - title: "Hello", - body: "sent over CultNet", - }), - ); - received.finally(() => sender.close()); - }); - }); - await received; - return inbox.get(noteDocument, "note:hello"); -} -``` - -### Rust CultNet first call - -Source: [`packages/cultnet-rs/tests/first_call.rs`](../../packages/cultnet-rs/tests/first_call.rs), run by -`cargo test --test first_call` in `packages/cultnet-rs`. More: `packages/cultnet-rs/README.md`. - -```rust -use std::net::{TcpListener, TcpStream}; -use std::thread; - -use cultnet_rs::{ - CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract, - TcpFramedTransportConnection, create_tcp_framed_transport_profile, - decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, -}; - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "note")] -struct Note { - #[cultcache(key = 0)] - title: String, - #[cultcache(key = 1)] - body: String, -} - -fn first_call() -> anyhow::Result { - let mut documents = CultNetDocumentRegistry::new(); - documents.register(CultNetDocumentBinding::for_entry_with_schema_id::( - "gamecult.first_call_note.v1".to_string(), - "gamecult.first_call_note.v1".to_string(), - ))?; - let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default()); - - // Receiver: a loopback listener that applies the document put it receives to its own cache. - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - let receiver = thread::spawn({ - let (documents, profile) = (documents.clone(), profile("receiver")); - move || -> anyhow::Result { - let (stream, _) = listener.accept()?; - let frame = TcpFramedTransportConnection::new(stream, profile).receive()?; - let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; - let mut inbox = CultCache::new(); - inbox.register_entry_type::()?; - documents.apply_raw_document_put_message::(&mut inbox, &message) - } - }); - - // Sender: build a typed put message, encode it for the wire, send one frame. - let put = documents.create_raw_document_put_message( - "put-1", - "note:hello", - &Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }, - CultNetDocumentPutOptions::default(), - )?; - let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?; - TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender")) - .send("schema", &bytes)?; - - receiver.join().expect("receiver thread") -} -``` - -### Python CultNet first call - -Source: [`packages/cultnet-py/tests/test_first_call.py`](../../packages/cultnet-py/tests/test_first_call.py), run by -`python -m unittest tests.test_first_call` in `packages/cultnet-py` with both `src` and `../cultcache-py/src` on -`PYTHONPATH` and `msgpack` installed. The socket pair stands in for a TCP connection. More: `packages/cultnet-py/README.md`. - -```python -import socket -from dataclasses import asdict, dataclass - -from cultcache_py import CultCache, define_document_type -from cultnet_py import ( - apply_raw_document_record, - document_put_raw, - encode_frame, - parse_message, - read_frame, - schema_document_map, -) - - -@dataclass -class Note: - title: str - body: str - - -note_doc = define_document_type( - "note", - encode=lambda note: asdict(note), - decode=lambda raw: Note(**raw), - name="title", -) - - -def first_call() -> Note: - sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP - - # Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame. - put = document_put_raw( - message_id="put-1", - key="note:hello", - schema_id=note_doc.catalog_entry().schema_id, - stored_at="2026-01-01T00:00:00Z", - payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")), - ) - sender.sendall(encode_frame(put.to_bytes())) - - # Receive: read the frame, parse the message, apply its record to a second cache. - message = parse_message(read_frame(receiver.makefile("rb"))) - assert message.schema_version == "cultnet.document_put_raw.v0" - inbox = CultCache.builder().register_document_type(note_doc).build() - apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"]) - sender.close() - receiver.close() - return inbox.get_required(note_doc, "note:hello") -``` - ## Where next - Smallest durable mesh path: `src/GameCult.Mesh/docs/durable-node-quickstart.md`. diff --git a/docs/start/io-game.md b/docs/start/io-game.md deleted file mode 100644 index e7f701a7..00000000 --- a/docs/start/io-game.md +++ /dev/null @@ -1,196 +0,0 @@ -# Recipe: a two-client .io game over CultCache + CultMesh (TypeScript) - -A tiny arena. One process is the authority: it holds a single `io.arena_state` document in a CultCache -and serves it over CultMesh. Each client discovers the authority through the Odin catalog, leases the -document, and invokes `join`, `move` and `leave` operations. Two clients see each other join, move and -leave because both lease the same document. - -## What ran, what did not - -| Piece | Status | -| --- | --- | -| Two clients, real WebSockets, real `@gamecult/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** | -| 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 | -| Run by CI / `npm run test:ts` | **not yet**: the recipe has its own `package.json` (for `ws`) outside the workspaces; run it by hand as below | -| Authority state on disk (`.cc` file) | **not yet**: the arena cache is in memory | -| Remote play: Odin roots and a signed route | **not yet**: the client uses `local-development` trust, loopback only | -| 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 | - -No `@gamecult/cultmesh-ts` or `@gamecult/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. - -## Run it - -``` -npm ci --ignore-scripts # repo root -(cd packages/cultnet-ts && npm run build) # then cultcache-ts, cultmesh-browser, same command -cd docs/start/io-game && npm install -npm test # node --test arena.test.ts, Node 24 -node serve.ts # http://127.0.0.1:8080, open it twice -``` - -Tested on Node v24.14, which runs `.ts` files directly (no type check; `tsc` is not part of this recipe). - -## schema.ts - -```ts -import { defineDocumentType } from "@gamecult/cultcache-ts/document"; - -export interface Player { x: number; y: number } -export interface ArenaState { players: Record } - -// The one shared document: every player's position. The server owns it; clients lease it. -export const arenaDocument = defineDocumentType({ - type: "io.arena_state", - schemaId: "io.arena_state.v1", - schema: { parse: (value: unknown) => value as ArenaState }, -}); - -export const arenaKey = "arena:main"; -export const verseId = "io.arena"; -export const authorityRuntimeId = "io.arena-daemon"; -export const arenaSize = 100; -``` - -## server.ts - -```ts -import { createServer, type RequestListener, type Server } from "node:http"; -import { CultCache } from "@gamecult/cultcache-ts"; -import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document"; -import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; -import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts"; -import { WebSocketServer, type WebSocket } from "ws"; -import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; - -const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64"); -const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64")); - -// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh). -export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) { - const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build(); - await cache.put(arenaDocument, arenaKey, { players: {} }); - const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); })); - await new Promise(resolve => http.listen(port, host, resolve)); - const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`; - - const subscribers = new Map(); - const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m))); - const record = () => ({ - schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId, - schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(), - payloadEncoding: "messagepack" as const, - payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never, - sourceRuntimeId: authorityRuntimeId, - }); - const publish = async (next: ArenaState) => { - await cache.put(arenaDocument, arenaKey, next); - for (const [ws, ids] of subscribers) for (const subscriptionId of ids) { - send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() }); - } - }; - - let nextId = 0; - const operations: Record Promise> = { - join: async () => { - const id = `p${++nextId}`; - const state = cache.getRequired(arenaDocument, arenaKey); - await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } }); - return { id }; - }, - move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => { - const state = cache.getRequired(arenaDocument, arenaKey); - const me = state.players[id]; - if (!me) return { ok: false }; - const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v)); - await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } }); - return { ok: true }; - }, - leave: async ({ id }: { id: string }) => { - const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players; - await publish({ players: rest }); - return { ok: true }; - }, - }; - - const wss = new WebSocketServer({ server: http }); - wss.on("connection", (ws, req) => { - const odin = req.url === "/odin"; - ws.on("close", () => subscribers.delete(ws)); - ws.on("message", async raw => { - const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer))); - if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") { - send(ws, { - schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId, - verses: [{ - verseId, displayName: "Arena", authorityModel: "ServerAuthoritative", - compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, - discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId], - authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }], - }], - }); - } else if (m.schemaVersion === "cultmesh.session_open.v2") { - send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId, - protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" }); - } else if (m.schemaVersion === "cultnet.database_subscribe.v0") { - subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]); - send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] }); - } else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") { - subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId)); - } else if (m.schemaVersion === "cultnet.operation_request.v0") { - const handler = operations[m.operation]; - const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined; - send(ws, { - schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation, - status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64", - payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId, - }); - } - }); - }); - - return { - odinUrl: `${endpoint}/odin`, - state: () => cache.getRequired(arenaDocument, arenaKey), - close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); }, - }; -} -``` - -## client.ts - -```ts -import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser"; -import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; - -// Runs unchanged in a browser or in Node 22+: both have a global WebSocket. -export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) { - const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` }); - const mesh = await CultMeshBrowserClient.connect({ - verseId, authorityRuntimeId, runtimeId, rendezvous, - trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots - }); - let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document - const call = async (operation: string, payload: object) => - decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload })); - - const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey }); - const { id } = await call("join", {}); - lease.watch(record => { - if (!record) return; - latest = decodeCultNetPayload(record, arenaDocument); - onState(latest, id!); - }); - return { - id: id!, - move: (dx: number, dy: number) => call("move", { id, dx, dy }), - state: () => latest, - leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); }, - }; -} -``` - -`browser.ts`, `index.html` and `serve.ts` in [`io-game/`](io-game/) draw the canvas and bundle the client with esbuild. diff --git a/docs/start/io-game/arena.test.ts b/docs/start/io-game/arena.test.ts deleted file mode 100644 index 67049907..00000000 --- a/docs/start/io-game/arena.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import test from "node:test"; -import { joinArena } from "./client.ts"; -import { startArena } from "./server.ts"; -import { arenaSize, type ArenaState } from "./schema.ts"; - -const until = async (cond: () => boolean) => { - for (let i = 0; i < 200 && !cond(); i++) await new Promise(r => setTimeout(r, 10)); - assert.ok(cond(), "condition not reached in 2s"); -}; - -test("two clients join one arena, see each other, and see each other move", async () => { - const arena = await startArena(); - const seenByA: ArenaState[] = []; - const seenByB: ArenaState[] = []; - const a = await joinArena(arena.odinUrl, "client-a", s => seenByA.push(structuredClone(s))); - const b = await joinArena(arena.odinUrl, "client-b", s => seenByB.push(structuredClone(s))); - try { - assert.notEqual(a.id, b.id); - await until(() => Object.keys(a.state()?.players ?? {}).length === 2); - await until(() => Object.keys(b.state()?.players ?? {}).length === 2); - - const before = structuredClone(b.state()!.players[a.id]!); - // Moves clamp at the arena edge, so pick a direction that always has room to move. - const dx = before.x > 0 ? -1 : 1; - await a.move(dx, 0); - await until(() => b.state()!.players[a.id]!.x !== before.x); - assert.equal(b.state()!.players[a.id]!.x, before.x + dx); - assert.deepEqual(a.state(), arena.state()); - assert.deepEqual(b.state(), arena.state()); - - await b.leave(); - await until(() => !(a.id !== b.id && b.id in a.state()!.players)); - assert.deepEqual(Object.keys(a.state()!.players), [a.id]); - } finally { - await a.leave().catch(() => undefined); - await arena.close(); - } -}); - -test("a player cannot walk out of the arena", async () => { - const arena = await startArena(); - const a = await joinArena(arena.odinUrl, "client-a", () => undefined); - try { - for (let i = 0; i <= arenaSize; i++) await a.move(-1, 0); - assert.equal(arena.state().players[a.id]!.x, 0); - } finally { - await a.leave(); - await arena.close(); - } -}); - -test("serve.ts bundles the browser client and serves the page", async () => { - const { serveArena } = await import("./serve.ts"); - const site = await serveArena(0); - try { - const html = await (await fetch(site.url)).text(); - assert.match(html, / { - const page = readFileSync(new URL("../io-game.md", import.meta.url), "utf8").replace(/\r\n/g, "\n"); - for (const file of ["schema.ts", "server.ts", "client.ts"]) { - const text = readFileSync(new URL(`./${file}`, import.meta.url), "utf8").replace(/\r\n/g, "\n"); - assert.ok(page.includes(text), `docs/start/io-game.md no longer shows ${file} verbatim`); - } -}); diff --git a/docs/start/io-game/browser.ts b/docs/start/io-game/browser.ts deleted file mode 100644 index 4120285f..00000000 --- a/docs/start/io-game/browser.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { joinArena } from "./client.ts"; -import { arenaSize, type ArenaState } from "./schema.ts"; - -const canvas = document.querySelector("canvas")!; -const ctx = canvas.getContext("2d")!; -const scale = canvas.width / arenaSize; - -const game = await joinArena(`ws://${location.host}/odin`, `browser-${crypto.randomUUID()}`, (state: ArenaState, me: string) => { - ctx.clearRect(0, 0, canvas.width, canvas.height); - for (const [id, p] of Object.entries(state.players)) { - ctx.fillStyle = id === me ? "#f5c542" : "#6aa9ff"; - ctx.fillRect(p.x * scale, p.y * scale, scale * 2, scale * 2); - } -}); -const keys: Record = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }; -addEventListener("keydown", e => { const d = keys[e.key]; if (d) void game.move(...d); }); -addEventListener("pagehide", () => void game.leave()); diff --git a/docs/start/io-game/client.ts b/docs/start/io-game/client.ts deleted file mode 100644 index 10c4b4cd..00000000 --- a/docs/start/io-game/client.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser"; -import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; - -// Runs unchanged in a browser or in Node 22+: both have a global WebSocket. -export async function joinArena(odinUrl: string, runtimeId: string, onState: (state: ArenaState, myId: string) => void) { - const rendezvous = new CultMeshBrowserOdinRendezvous({ endpoints: [odinUrl], runtimeId: `${runtimeId}.discovery` }); - const mesh = await CultMeshBrowserClient.connect({ - verseId, authorityRuntimeId, runtimeId, rendezvous, - trust: { mode: "local-development" }, // loopback only; remote Verses need Odin roots - }); - let latest: ArenaState | undefined; // CultCache itself pulls in Node built-ins, so the browser client keeps the decoded document - const call = async (operation: string, payload: object) => - decodeCultNetOperationPayload<{ id?: string }>(await mesh.invoke({ serviceId: "io.arena", operation, payloadSchema: "io.request.v1", payload })); - - const lease = await mesh.leaseRawDocument({ schemaId: arenaDocument.schemaId, recordKey: arenaKey }); - const { id } = await call("join", {}); - lease.watch(record => { - if (!record) return; - latest = decodeCultNetPayload(record, arenaDocument); - onState(latest, id!); - }); - return { - id: id!, - move: (dx: number, dy: number) => call("move", { id, dx, dy }), - state: () => latest, - leave: async () => { await call("leave", { id }); lease.dispose(); await mesh.dispose(); }, - }; -} diff --git a/docs/start/io-game/index.html b/docs/start/io-game/index.html deleted file mode 100644 index bcc77648..00000000 --- a/docs/start/io-game/index.html +++ /dev/null @@ -1,6 +0,0 @@ - - -arena - -

Arrow keys. Open this page in a second window.

- diff --git a/docs/start/io-game/package-lock.json b/docs/start/io-game/package-lock.json deleted file mode 100644 index 48afe0a7..00000000 --- a/docs/start/io-game/package-lock.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "name": "cultlib-start-io-game", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cultlib-start-io-game", - "dependencies": { - "ws": "^8.18.0" - }, - "devDependencies": { - "@types/ws": "^8.5.0" - } - }, - "node_modules/@types/node": { - "version": "26.6.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", - "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~8.9.0" - } - }, - "node_modules/@types/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/undici-types": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.22.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.1.tgz", - "integrity": "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/docs/start/io-game/package.json b/docs/start/io-game/package.json deleted file mode 100644 index d8f1281f..00000000 --- a/docs/start/io-game/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "cultlib-start-io-game", - "private": true, - "type": "module", - "description": "docs/start recipe: a tiny two-client browser game over CultCache + CultMesh (TypeScript).", - "scripts": { "test": "node --test arena.test.ts" }, - "dependencies": { "ws": "^8.18.0" }, - "devDependencies": { "@types/ws": "^8.5.0" } -} diff --git a/docs/start/io-game/schema.ts b/docs/start/io-game/schema.ts deleted file mode 100644 index d0c1a59b..00000000 --- a/docs/start/io-game/schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineDocumentType } from "@gamecult/cultcache-ts/document"; - -export interface Player { x: number; y: number } -export interface ArenaState { players: Record } - -// The one shared document: every player's position. The server owns it; clients lease it. -export const arenaDocument = defineDocumentType({ - type: "io.arena_state", - schemaId: "io.arena_state.v1", - schema: { parse: (value: unknown) => value as ArenaState }, -}); - -export const arenaKey = "arena:main"; -export const verseId = "io.arena"; -export const authorityRuntimeId = "io.arena-daemon"; -export const arenaSize = 100; diff --git a/docs/start/io-game/serve.ts b/docs/start/io-game/serve.ts deleted file mode 100644 index 82bf20be..00000000 --- a/docs/start/io-game/serve.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { readFileSync } from "node:fs"; -import { build } from "esbuild"; -import { startArena } from "./server.ts"; - -// Serves the page, the bundled browser client, and the arena's WebSocket on one port. -export async function serveArena(port = 8080, host = "127.0.0.1") { - const bundle = await build({ entryPoints: [new URL("./browser.ts", import.meta.url).pathname], bundle: true, format: "esm", target: "es2022", write: false }); - const html = readFileSync(new URL("./index.html", import.meta.url)); - const arena = await startArena(port, host, (req, res) => { - if (req.url === "/") res.writeHead(200, { "content-type": "text/html" }).end(html); - else if (req.url === "/browser.js") res.writeHead(200, { "content-type": "text/javascript" }).end(bundle.outputFiles[0]!.contents); - else res.writeHead(404).end(); - }); - return { url: arena.odinUrl.replace("ws:", "http:").replace("/odin", ""), close: arena.close }; -} - -if (import.meta.main) console.log(`arena at ${(await serveArena()).url}`); diff --git a/docs/start/io-game/server.ts b/docs/start/io-game/server.ts deleted file mode 100644 index 7ef552b9..00000000 --- a/docs/start/io-game/server.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { createServer, type RequestListener, type Server } from "node:http"; -import { CultCache } from "@gamecult/cultcache-ts"; -import { defineDocumentRegistry } from "@gamecult/cultcache-ts/document"; -import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; -import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "@gamecult/cultnet-ts/contracts"; -import { WebSocketServer, type WebSocket } from "ws"; -import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; - -const bytesToBase64 = (b: Uint8Array) => Buffer.from(b).toString("base64"); -const base64ToBytes = (s: string) => new Uint8Array(Buffer.from(s, "base64")); - -// One process plays both CultMesh roles: Odin (/odin tells clients where the authority is) and the authority (/mesh). -export async function startArena(port = 0, host = "127.0.0.1", onRequest?: RequestListener) { - const cache = CultCache.builder().withRegistry(defineDocumentRegistry(arenaDocument)).build(); - await cache.put(arenaDocument, arenaKey, { players: {} }); - const http: Server = createServer(onRequest ?? ((_, res) => { res.writeHead(404).end(); })); - await new Promise(resolve => http.listen(port, host, resolve)); - const endpoint = `ws://${host}:${(http.address() as { port: number }).port}`; - - const subscribers = new Map(); - const send = (ws: WebSocket, m: CultNetMessage) => ws.send(encodeWireValue(encodeCultNetMessageForWire(m))); - const record = () => ({ - schemaId: arenaDocument.schemaId, schemaName: arenaDocument.type, schemaVersion: arenaDocument.schemaId, - schemaContentHash: arenaDocument.schemaId, recordKey: arenaKey, storedAt: new Date().toISOString(), - payloadEncoding: "messagepack" as const, - payload: encodeWireValue(cache.getRequired(arenaDocument, arenaKey)) as never, - sourceRuntimeId: authorityRuntimeId, - }); - const publish = async (next: ArenaState) => { - await cache.put(arenaDocument, arenaKey, next); - for (const [ws, ids] of subscribers) for (const subscriptionId of ids) { - send(ws, { schemaVersion: "cultnet.database_change_raw.v0", messageId: crypto.randomUUID(), subscriptionId, changeKind: "updated", document: record() }); - } - }; - - let nextId = 0; - const operations: Record Promise> = { - join: async () => { - const id = `p${++nextId}`; - const state = cache.getRequired(arenaDocument, arenaKey); - await publish({ players: { ...state.players, [id]: { x: Math.floor(Math.random() * arenaSize), y: Math.floor(Math.random() * arenaSize) } } }); - return { id }; - }, - move: async ({ id, dx, dy }: { id: string; dx: number; dy: number }) => { - const state = cache.getRequired(arenaDocument, arenaKey); - const me = state.players[id]; - if (!me) return { ok: false }; - const clamp = (v: number) => Math.max(0, Math.min(arenaSize, v)); - await publish({ players: { ...state.players, [id]: { x: clamp(me.x + Math.sign(dx)), y: clamp(me.y + Math.sign(dy)) } } }); - return { ok: true }; - }, - leave: async ({ id }: { id: string }) => { - const { [id]: _gone, ...rest } = cache.getRequired(arenaDocument, arenaKey).players; - await publish({ players: rest }); - return { ok: true }; - }, - }; - - const wss = new WebSocketServer({ server: http }); - wss.on("connection", (ws, req) => { - const odin = req.url === "/odin"; - ws.on("close", () => subscribers.delete(ws)); - ws.on("message", async raw => { - const m = parseCultNetMessage(decodeWireValue(new Uint8Array(raw as Buffer))); - if (odin && m.schemaVersion === "cultmesh.verse_catalog_request.v0") { - send(ws, { - schemaVersion: "cultmesh.verse_catalog_response.v0", messageId: m.messageId, - verses: [{ - verseId, displayName: "Arena", authorityModel: "ServerAuthoritative", - compatibility: { transportVersion: "cultmesh.v1", rulesHash: "arena-v1", compatibleVerseIds: [], requiredPluginIds: [], optionalPluginIds: [] }, - discoveryEndpoints: [`${endpoint}/mesh`], authorityRuntimeIds: [authorityRuntimeId], - authorityRoutes: [{ authorityRuntimeId, endpoint: `${endpoint}/mesh`, protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: "arena-1" }], - }], - }); - } else if (m.schemaVersion === "cultmesh.session_open.v2") { - send(ws, { schemaVersion: "cultmesh.session_accepted.v2", messageId: m.messageId, accepted: true, verseId: m.verseId, authorityRuntimeId, - protocolId: m.protocolId, routeGeneration: m.routeGeneration, clientNonce: m.clientNonce, providerKeyId: "", providerSignature: "" }); - } else if (m.schemaVersion === "cultnet.database_subscribe.v0") { - subscribers.set(ws, [...(subscribers.get(ws) ?? []), m.subscriptionId]); - send(ws, { schemaVersion: "cultnet.snapshot_response_raw.v0", messageId: m.messageId, documents: [record()] }); - } else if (m.schemaVersion === "cultnet.database_unsubscribe.v0") { - subscribers.set(ws, (subscribers.get(ws) ?? []).filter(id => id !== m.subscriptionId)); - } else if (m.schemaVersion === "cultnet.operation_request.v0") { - const handler = operations[m.operation]; - const receipt = handler ? await handler(decodeWireValue(base64ToBytes(m.payload))) : undefined; - send(ws, { - schemaVersion: "cultnet.operation_response.v0", messageId: m.messageId, serviceId: m.serviceId, operation: m.operation, - status: handler ? "accepted" : "invalid", payloadSchema: "io.receipt.v1", payloadEncoding: "messagepack-base64", - payload: bytesToBase64(encodeWireValue(receipt ?? { ok: false })), sourceRuntimeId: authorityRuntimeId, - }); - } - }); - }); - - return { - odinUrl: `${endpoint}/odin`, - state: () => cache.getRequired(arenaDocument, arenaKey), - close: async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise(r => http.close(r)); }, - }; -} diff --git a/package-lock.json b/package-lock.json index 0a1558da..9100d8db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -482,18 +482,6 @@ "resolved": "packages/cultcache-ts", "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": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", @@ -877,6 +865,18 @@ "dev": true, "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": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -1184,7 +1184,7 @@ }, "packages/cultcache-ts": { "name": "@gamecult/cultcache-ts", - "version": "0.15.0", + "version": "0.14.2", "license": "MIT", "dependencies": { "@msgpack/msgpack": "^3.1.3", @@ -1200,16 +1200,15 @@ } }, "packages/cultmesh-browser": { - "name": "@gamecult/cultmesh-browser", "version": "0.4.0", "license": "MIT", "dependencies": { - "@gamecult/cultcache-ts": "^0.15.0", - "@gamecult/cultnet-ts": "^0.3.0" + "@gamecult/cultcache-ts": "^0.14.0", + "cultnet-ts": "^0.3.0" }, "devDependencies": { - "@gamecult/cultmesh-ts": "^0.2.0", "@types/node": "^24.0.0", + "cultmesh-ts": "^0.2.0", "typescript": "^5.9.0" }, "engines": { @@ -1217,12 +1216,11 @@ } }, "packages/cultmesh-ts": { - "name": "@gamecult/cultmesh-ts", "version": "0.2.0", "license": "MIT", "dependencies": { - "@gamecult/cultcache-ts": "^0.15.0", - "@gamecult/cultnet-ts": "^0.3.0", + "@gamecult/cultcache-ts": "^0.14.0", + "cultnet-ts": "^0.3.0", "koffi": "3.3.0" }, "devDependencies": { @@ -1235,11 +1233,10 @@ } }, "packages/cultnet-ts": { - "name": "@gamecult/cultnet-ts", "version": "0.3.0", "license": "MIT", "dependencies": { - "@gamecult/cultcache-ts": "^0.15.0", + "@gamecult/cultcache-ts": "^0.14.1", "@msgpack/msgpack": "^3.1.3", "ajv": "^8.17.1" }, diff --git a/packages/cultcache-py/CHANGELOG.md b/packages/cultcache-py/CHANGELOG.md index b460ab05..d2f76628 100644 --- a/packages/cultcache-py/CHANGELOG.md +++ b/packages/cultcache-py/CHANGELOG.md @@ -2,11 +2,6 @@ ## Unreleased -### Changed - -- `msgpack` is a hard dependency, as `pyproject.toml` declared it. The optional-import fallbacks and the `cultcache-py[msgpack]` extra they named (which never existed) are gone: a missing msgpack is an `ImportError` at import. -- The single-file store's `push`, `push_all` and `delete` take the cross-process lock on `.lock` (flock; LockFileEx over the whole file on Windows) across the read and the replace, waiting up to `lock_timeout` seconds (30 by default, `TimeoutError` after), and exclude the C#, Rust and TypeScript runtimes' writers. The staged file is `.tmp--`, fsynced before the replace, with a directory fsync after it off Windows. A fixed `.tmp` is no longer used. - ### 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. diff --git a/packages/cultcache-py/src/cultcache_py/documents.py b/packages/cultcache-py/src/cultcache_py/documents.py index 23849713..91d09ab8 100644 --- a/packages/cultcache-py/src/cultcache_py/documents.py +++ b/packages/cultcache-py/src/cultcache_py/documents.py @@ -6,9 +6,6 @@ from collections.abc import Mapping, Sequence, Set from itertools import chain from typing import Any, Callable, Generic, Iterable, TypeVar -import msgpack -from msgpack.exceptions import StackError as _MsgpackStackError - from .backing_store import CultCacheSchemaCatalogEntry, CultCacheSchemaCatalogMember from .leaves import Float32, SlotCodec from .leaves import any as _any_leaf @@ -27,7 +24,12 @@ or skipped, the outermost one is depth 1; depth 256 is accepted and 257 refused, MAX_SLOT_KEY = 65535 """The largest slot key a record may define: the largest MessagePack array16 length.""" -_STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,) +try: + from msgpack.exceptions import StackError as _MsgpackStackError + + _STACK_FAULTS: tuple[type[BaseException], ...] = (_MsgpackStackError,) +except ModuleNotFoundError: + _STACK_FAULTS = () _CONTAINERS = (Mapping, Sequence, Set) @@ -83,10 +85,22 @@ def _json_payload_decoder(payload: bytes) -> Any: return json.loads(payload.decode("utf-8")) +def _msgpack() -> Any: + try: + import msgpack # type: ignore + except ModuleNotFoundError as exc: + raise RuntimeError( + "MessagePack payload encoding requires the optional 'msgpack' dependency. " + "Install with: python -m pip install cultcache-py[msgpack]" + ) from exc + return msgpack + + def _pack_tree(tree: Any) -> bytes: """The bytes of the tree the leaves produced. `packb` writes everything but a `Float32`, which it refuses with a TypeError; only then does an iterative walk pack each array and map header and each value, a `Float32` through a single-float packer.""" + msgpack = _msgpack() try: return msgpack.packb(tree, use_bin_type=True) # type: ignore[no-any-return] except TypeError: @@ -111,6 +125,13 @@ def _pack_tree(tree: Any) -> bytes: def _messagepack_payload_decoder(payload: bytes) -> Any: + try: + import msgpack # type: ignore + except ModuleNotFoundError as exc: + raise RuntimeError( + "MessagePack payload decoding requires the optional 'msgpack' dependency. " + "Install with: python -m pip install cultcache-py[msgpack]" + ) from exc return msgpack.unpackb(payload, raw=False) diff --git a/packages/cultcache-py/src/cultcache_py/leaves.py b/packages/cultcache-py/src/cultcache_py/leaves.py index 71bc6204..2d76078e 100644 --- a/packages/cultcache-py/src/cultcache_py/leaves.py +++ b/packages/cultcache-py/src/cultcache_py/leaves.py @@ -16,7 +16,10 @@ from collections.abc import Mapping from dataclasses import dataclass from typing import Any, Callable, NoReturn -from msgpack import ExtType as _ExtType +try: + from msgpack import ExtType as _ExtType +except ModuleNotFoundError: + _ExtType = () # type: ignore[assignment,misc] _bool, _bytes, _list, _int, _float, _str = bool, bytes, list, int, float, str _BYTE_LIKE = (_bytes, bytearray, memoryview) diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 77d671ad..c9c76b06 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -3,15 +3,9 @@ import base64 import json import os -import sys import threading -import time -import uuid -from contextlib import contextmanager from pathlib import Path -from typing import Any, Callable, Iterator, TypeVar - -import msgpack +from typing import Any, Callable, TypeVar from .backing_store import ( CultCacheEnvelope, @@ -27,8 +21,6 @@ STORE_FORMAT_ELEMENT_IDS = "cultcache.store.v3" _READABLE_STORE_FORMATS = (STORE_FORMAT_VERSION, STORE_FORMAT_ELEMENT_IDS) _STORE_FORMAT_PREFIX = "cultcache.store." _PERSISTED_RECORD_SLOTS = 4 -_LOCK_POLL_SECONDS = 0.01 -LOCK_TIMEOUT_SECONDS = 30.0 _Contents = TypeVar("_Contents") @@ -122,14 +114,14 @@ class JsonLinesBackingStore: class SingleFileMessagePackBackingStore: - def __init__(self, path: str | os.PathLike[str], lock_timeout: float = LOCK_TIMEOUT_SECONDS) -> None: + def __init__(self, path: str | os.PathLike[str]) -> None: self.path = Path(path) - self.lock_timeout = lock_timeout self._lock = threading.RLock() # The header this store read; a rewrite keeps it, so a store that holds element ids never sheds its marker. self._format = STORE_FORMAT_VERSION def pull_all(self) -> list[CultCacheEnvelope]: + msgpack = self._msgpack() with self._lock: # An empty file is not a store (no writer leaves one), so it is decoded and refused. # The disk decides the header: a file that is gone is not marked. @@ -143,34 +135,39 @@ class SingleFileMessagePackBackingStore: if snapshot is not None: self._format, envelopes = snapshot return envelopes - return _decode_legacy_envelopes(decoded) + return _decode_legacy_envelopes(decoded, msgpack) except Exception as exc: raise StoreUnreadableError(self.path, exc) from exc def push(self, envelope: CultCacheEnvelope) -> None: - with self._write_transaction(): + with self._lock: self._replace_all([envelope], self.pull_all()) def push_all(self, envelopes: list[CultCacheEnvelope]) -> None: - with self._write_transaction(): + with self._lock: self._replace_all(list(envelopes), self.pull_all()) def delete(self, type: str, key: str) -> None: - with self._write_transaction(): + with self._lock: self._replace_all([], [item for item in self.pull_all() if not (item.type == type and item.key == key)]) - @contextmanager - def _write_transaction(self) -> Iterator[None]: - """The thread lock, then the cross-process lock on `.lock`, held across the read and the replace so two - processes never rewrite from the same stale read.""" - with self._lock: - self.path.parent.mkdir(parents=True, exist_ok=True) - with store_write_lock(self.path, self.lock_timeout): - yield - def _replace_all(self, supplied: list[CultCacheEnvelope], arrived: list[CultCacheEnvelope]) -> None: - commit_store_file(self.path, msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True)) + msgpack = self._msgpack() + self.path.parent.mkdir(parents=True, exist_ok=True) + temp = self.path.with_suffix(self.path.suffix + ".tmp") + temp.write_bytes(msgpack.packb(_encode_snapshot(supplied, arrived, self._format), use_bin_type=True)) + temp.replace(self.path) + @staticmethod + def _msgpack() -> Any: + try: + import msgpack # type: ignore + except ModuleNotFoundError as exc: + raise RuntimeError( + "SingleFileMessagePackBackingStore requires the optional 'msgpack' dependency. " + "Install with: python -m pip install cultcache-py[msgpack]" + ) from exc + return msgpack def canonical(candidates: Any) -> list[CultCacheSchemaCatalogEntry]: @@ -264,85 +261,6 @@ def _encode_snapshot( ] return [format_version, catalog, records] -def commit_store_file(path: Path, data: bytes) -> None: - """Writes `data` to a unique staged file beside the store, fsyncs it, replaces the store with it and, off Windows, - fsyncs the directory so the replace is durable. A failure before the replace removes the staged file and leaves the - store untouched. The caller holds the store lock.""" - staged = path.with_name(f"{path.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}") - try: - fd = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o666) - try: - view = memoryview(data) - while view: - view = view[os.write(fd, view):] - os.fsync(fd) - finally: - os.close(fd) - os.replace(staged, path) - except BaseException: - try: - os.unlink(staged) - except OSError: - pass - raise - if sys.platform != "win32": - directory = os.open(path.parent, os.O_RDONLY) - try: - os.fsync(directory) - finally: - os.close(directory) - - -@contextmanager -def store_write_lock(path: Path, timeout: float = LOCK_TIMEOUT_SECONDS) -> Iterator[None]: - """Holds the exclusive store lock on `.lock`, the lock the C#, Rust and TypeScript runtimes take: flock on Unix, - LockFileEx over the whole file on Windows, polled every 10 ms with no handle held between polls. The lock file is - created if absent and never written or deleted.""" - lock_path = path.with_name(path.name + ".lock") - deadline = time.monotonic() + timeout - while True: - release = _try_lock(lock_path) - if release is not None: - break - if time.monotonic() >= deadline: - raise TimeoutError(f"CultCache timed out after {timeout} s waiting for the store lock {lock_path}.") - time.sleep(_LOCK_POLL_SECONDS) - try: - yield - finally: - release() - - -def _try_lock(lock_path: Path) -> Callable[[], None] | None: - if sys.platform == "win32": - return _try_lock_windows(lock_path) - import fcntl - - fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o666) - try: - fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: - os.close(fd) - return None - return lambda: os.close(fd) # closing drops the flock - - -def _try_lock_windows(lock_path: Path) -> Callable[[], None] | None: - import ctypes - import msvcrt - from ctypes import wintypes - - kernel = ctypes.WinDLL("kernel32", use_last_error=True) - kernel.LockFileEx.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p] - fd = os.open(lock_path, os.O_RDWR | os.O_CREAT | os.O_BINARY, 0o666) - handle = msvcrt.get_osfhandle(fd) - overlapped = ctypes.create_string_buffer(32) - # LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY over the whole range, as the TypeScript runtime takes it. - if not kernel.LockFileEx(handle, 0x3, 0, 0xFFFFFFFF, 0xFFFFFFFF, overlapped): - os.close(fd) - return None - return lambda: os.close(fd) # closing drops the lock - def _read_store(path: Path, read: Callable[[Path], _Contents]) -> _Contents | None: """Reads a store file, or returns None when nothing is at its path. @@ -433,7 +351,7 @@ def _decode_snapshot(decoded: Any) -> tuple[str, list[CultCacheEnvelope]] | None return decoded[0], envelopes -def _decode_legacy_envelopes(decoded: Any) -> list[CultCacheEnvelope]: +def _decode_legacy_envelopes(decoded: Any, msgpack: Any) -> list[CultCacheEnvelope]: if not isinstance(decoded, list): raise ValueError("CultCache MessagePack store is not a recognized snapshot") envelopes: list[CultCacheEnvelope] = [] diff --git a/packages/cultcache-py/tests/test_slot_nil.py b/packages/cultcache-py/tests/test_slot_nil.py deleted file mode 100644 index dca207bb..00000000 --- a/packages/cultcache-py/tests/test_slot_nil.py +++ /dev/null @@ -1,54 +0,0 @@ -from __future__ import annotations - -import unittest -from pathlib import Path - -import msgpack - -from cultcache_py import database_entry_field, define_database_entry_type, define_record_codec, leaves as t - -# tests/vectors/slot-nil is written only by the C# reference (SlotNilVectorTests): what it reads and rewrites for a nil, a -# str in a byte[] slot, and a record short of its last slots. This runtime accepts what the reference accepts and rewrites -# the same bytes. -VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "slot-nil" - - -def _probe(): - inner = define_record_codec("SlotNilInner", [database_entry_field("x", 0, default="", codec=t.string)]) - return define_database_entry_type( - "tests.slot-nil", - [ - database_entry_field("s", 0, default="", codec=t.nullable(t.string)), - database_entry_field("i", 1, default=0, codec=t.int32), - database_entry_field("b", 2, default=b"", codec=t.nullable(t.bytes)), - database_entry_field("r", 3, default=None, codec=t.nullable(inner)), - database_entry_field("l", 4, default=[], codec=t.nullable(t.list(t.string))), - ], - ) - - -class SlotNilVectorTests(unittest.TestCase): - def test_slots_read_and_rewrite_exactly_what_the_reference_does_for_nil_str_in_bytes_and_short_records(self) -> None: - rows = msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False) - probe = _probe() - self.assertEqual(17, len(rows)) - accepted = 0 - for name, payload, was_accepted, rewritten in rows: - with self.subTest(name): - if was_accepted: - self.assertEqual(rewritten.hex(), probe.encode_payload(probe.decode_payload(payload)).hex()) - accepted += 1 - else: - with self.assertRaises(Exception): - probe.decode_payload(payload) - self.assertEqual(15, accepted) - - def test_a_character_outside_the_bmp_is_four_utf8_bytes(self) -> None: - rows = {name: (payload, rewritten) for name, payload, _, rewritten in msgpack.unpackb((VECTORS / "reads.msgpack").read_bytes(), raw=False)} - probe = _probe() - self.assertEqual("\U0001F600", probe.decode_payload(rows["string-astral"][0])["s"]) - self.assertEqual(b"\xf0\x9f\x98\x80", probe.decode_payload(rows["bytes-str-astral"][0])["b"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultcache-py/tests/test_store_lock.py b/packages/cultcache-py/tests/test_store_lock.py deleted file mode 100644 index 5ee29aa2..00000000 --- a/packages/cultcache-py/tests/test_store_lock.py +++ /dev/null @@ -1,135 +0,0 @@ -from __future__ import annotations - -import os -import shutil -import subprocess -import sys -import tempfile -import time -import unittest -from pathlib import Path -from unittest import mock - -from cultcache_py import SingleFileMessagePackBackingStore, stores -from cultcache_py.backing_store import CultCacheEnvelope - -REPO = Path(__file__).resolve().parents[3] -TS_STORE_FILE = REPO / "packages" / "cultcache-ts" / "dist" / "store-file.js" -HOLD_SECONDS = 1.5 - -# A holder that takes the lock the way the Rust runtime does (an exclusive file lock), not through this package. -PY_HOLDER = """ -import fcntl, os, sys, time -fd = os.open(sys.argv[1] + '.lock', os.O_RDWR | os.O_CREAT, 0o666) -fcntl.flock(fd, fcntl.LOCK_EX) -print('HELD', flush=True) -time.sleep(float(sys.argv[2])) -""" -# A holder that is the TypeScript runtime's own withStoreWriteLock, built from packages/cultcache-ts. -TS_HOLDER = """ -const { withStoreWriteLock } = require(process.argv[1]); -withStoreWriteLock(process.argv[2], async () => { - console.log('HELD'); - await new Promise((r) => setTimeout(r, Number(process.argv[3]) * 1000)); -}); -""" - - -def envelope(key: str) -> CultCacheEnvelope: - return CultCacheEnvelope(key=key, type="t", payload=b"\x01", stored_at="2026-11-06T00:00:00Z") - - -def start_holder(argv: list[str]) -> subprocess.Popen[str]: - holder = subprocess.Popen(argv, stdout=subprocess.PIPE, text=True) - assert holder.stdout is not None and holder.stdout.readline().strip() == "HELD" - holder.held_at = time.monotonic() # type: ignore[attr-defined] - return holder - - -@unittest.skipIf(sys.platform == "win32", "the foreign holders here use flock; Windows is covered by the TS holder") -class StoreLockTests(unittest.TestCase): - def setUp(self) -> None: - self.dir = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) - self.path = self.dir / "state.cc" - - def assert_write_waits_for(self, holder: subprocess.Popen[str]) -> None: - self.addCleanup(holder.wait) - self.addCleanup(holder.stdout.close) # type: ignore[union-attr] - refused = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2) - with self.assertRaises(TimeoutError): - refused.push(envelope("blocked")) - self.assertFalse(self.path.exists(), "a write that could not take the lock must not touch the store") - - SingleFileMessagePackBackingStore(self.path, lock_timeout=10).push(envelope("after")) - self.assertGreaterEqual(time.monotonic() - holder.held_at, HOLD_SECONDS - 0.1, "the write finished while the holder still held the lock") # type: ignore[attr-defined] - self.assertEqual(["after"], [e.key for e in SingleFileMessagePackBackingStore(self.path).pull_all()]) - - def test_write_waits_while_a_foreign_process_holds_the_lock(self) -> None: - self.assert_write_waits_for(start_holder([sys.executable, "-c", PY_HOLDER, str(self.path), str(HOLD_SECONDS)])) - - @unittest.skipUnless(shutil.which("node") and TS_STORE_FILE.exists(), "needs node and a built packages/cultcache-ts (npm run build)") - def test_write_waits_while_the_typescript_runtime_holds_the_lock(self) -> None: - holder = start_holder(["node", "-e", TS_HOLDER, str(TS_STORE_FILE), str(self.path), str(HOLD_SECONDS)]) - self.assert_write_waits_for(holder) - - def test_the_lock_is_released_after_each_write(self) -> None: - store = SingleFileMessagePackBackingStore(self.path, lock_timeout=0.2) - for key in "abc": - store.push(envelope(key)) - self.assertEqual(["a", "b", "c"], sorted(e.key for e in store.pull_all())) - - def test_concurrent_processes_lose_no_writes(self) -> None: - script = ( - "import sys\n" - "from cultcache_py import SingleFileMessagePackBackingStore as S\n" - "from cultcache_py.backing_store import CultCacheEnvelope as E\n" - "s = S(sys.argv[1])\n" - "for i in range(25):\n" - " s.push(E(key=f'{sys.argv[2]}{i}', type='t', payload=b'\\x01', stored_at='x'))\n" - ) - env = {**os.environ, "PYTHONPATH": str(Path(stores.__file__).parents[1])} - procs = [subprocess.Popen([sys.executable, "-c", script, str(self.path), tag], env=env) for tag in "pqr"] - for proc in procs: - self.assertEqual(0, proc.wait()) - self.assertEqual(75, len(SingleFileMessagePackBackingStore(self.path).pull_all())) - - -class StagedWriteTests(unittest.TestCase): - def setUp(self) -> None: - self.dir = Path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, self.dir, ignore_errors=True) - self.path = self.dir / "state.cc" - - def test_the_staged_file_is_fsynced_before_the_replace(self) -> None: - order: list[str] = [] - real_fsync, real_replace = os.fsync, os.replace - with mock.patch("os.fsync", side_effect=lambda fd: (order.append("fsync"), real_fsync(fd))[1]), mock.patch( - "os.replace", side_effect=lambda a, b: (order.append("replace"), real_replace(a, b))[1] - ): - SingleFileMessagePackBackingStore(self.path).push(envelope("a")) - self.assertEqual("fsync", order[0]) - self.assertLess(order.index("fsync"), order.index("replace")) - - def test_the_staged_name_is_unique_per_write(self) -> None: - names: list[str] = [] - real_replace = os.replace - with mock.patch("os.replace", side_effect=lambda a, b: (names.append(os.fspath(a)), real_replace(a, b))[1]): - store = SingleFileMessagePackBackingStore(self.path) - store.push(envelope("a")) - store.push(envelope("b")) - self.assertEqual(2, len(set(names))) - self.assertNotIn(os.fspath(self.path) + ".tmp", names) - - def test_a_failed_replace_leaves_the_store_and_no_staged_file(self) -> None: - store = SingleFileMessagePackBackingStore(self.path) - store.push(envelope("a")) - before = self.path.read_bytes() - with mock.patch("os.replace", side_effect=OSError("boom")), self.assertRaises(OSError): - store.push(envelope("b")) - self.assertEqual(before, self.path.read_bytes()) - self.assertEqual([], [p.name for p in self.dir.iterdir() if ".tmp" in p.name]) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultcache-rs/CHANGELOG.md b/packages/cultcache-rs/CHANGELOG.md index c417d535..f15fcee2 100644 --- a/packages/cultcache-rs/CHANGELOG.md +++ b/packages/cultcache-rs/CHANGELOG.md @@ -13,7 +13,6 @@ only in git history. ### Added -- Lookups, matching the C# `GetByName`, `GetByIndex` and `GetGlobal`. `#[cultcache(name)]` and `#[cultcache(index)]` / `#[cultcache(index = "alias")]` on `DatabaseEntry` fields and `#[cultcache(global)]` on the struct give `CultCache::get_by_name`, `get_by_index` and `get_global`. An empty name and a blank index value are not found; records of one type sharing a name or value find the one with the smallest key. A second record of a global type under another key is refused on every write path and on load with `CultCacheSecondGlobal`. Two `name` members, or two members with one index alias, are refused at compile time. A member's text comes from `CultLookupValue` (`String`, `Option` of one, integers, `bool`, `char`). - `SchemaConflictError`, the typed refusal of a write the catalog cannot describe: records of different types under one schema id, or an arrived schema and a registered one that share an id and disagree on the schema name. A whole-store write (`push_all`) refuses with it, names the schema id, the names involved and a record key, writes nothing and leaves the file as it is. A caller tells it apart with `error.downcast_ref::()`. ## [0.3.1] diff --git a/packages/cultcache-rs/README.md b/packages/cultcache-rs/README.md index 2c5c8027..afae7285 100644 --- a/packages/cultcache-rs/README.md +++ b/packages/cultcache-rs/README.md @@ -174,9 +174,6 @@ without pretending Rust has runtime reflection hiding under the floorboards. - `get_required_envelope::` - `get_all::` - `get_all_with_keys::` -- `get_by_name::` -- `get_by_index::` -- `get_global::` - `soa::` - `put::` - `put_envelope::` diff --git a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs index fe4e57c6..65e41b92 100644 --- a/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs +++ b/packages/cultcache-rs/cultcache-rs-derive/src/lib.rs @@ -22,7 +22,6 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result = None; let mut schema_name: Option = None; - let mut global = false; for attribute in input .attrs @@ -38,12 +37,6 @@ fn database_entry_codec(input: DeriveInput) -> syn::Result syn::Result ::std::option::Option<::std::string::String> { - ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) - } - } - }); - let index_members = slots.iter().filter_map(|slot| slot.index.as_ref().map(|alias| (alias, &slot.ident))); - let index_pushes = index_members.map(|(alias, member)| { - quote! { - if let ::std::option::Option::Some(value) = ::cultcache_rs::CultLookupValue::lookup_value(&self.#member) { - held.push((#alias, value)); - } - } - }); - let index_pushes: Vec<_> = index_pushes.collect(); - let indexes_fn = (!index_pushes.is_empty()).then(|| { - quote! { - fn cult_indexes(&self) -> ::std::vec::Vec<(&'static str, ::std::string::String)> { - let mut held = ::std::vec::Vec::new(); - #(#index_pushes)* - held - } - } - }); Ok(quote! { impl ::cultcache_rs::DatabaseEntry for #ident { const TYPE: &'static str = #entry_type; const SCHEMA_NAME: &'static str = #schema_name; - const GLOBAL: bool = #global; - #name_fn - #indexes_fn } #codec @@ -169,8 +133,6 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result = None; let mut default = false; let mut bytes = false; - let mut name = false; - let mut index: Option = None; for attribute in field .attrs .iter() @@ -195,22 +157,6 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result()?.value() - } else { - String::new() - }; - index = Some(if alias.trim().is_empty() { field_ident.to_string() } else { alias }); - Ok(()) } else { Err(meta.error("unsupported field cultcache attribute")) } @@ -234,28 +180,8 @@ fn named_slots(data: Data, ident: &syn::Ident, owner: &str) -> syn::Result, } fn union_codec(input: DeriveInput) -> syn::Result { @@ -920,89 +842,4 @@ mod tests { let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); named_slots(input.data, &input.ident, "CultSlots").expect("the fixture is accepted"); } - fn entry_error(source: &str) -> String { - let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); - database_entry_codec(input).expect_err("the fixture is refused").to_string() - } - - fn entry_tokens(source: &str) -> String { - let input: DeriveInput = syn::parse_str(source).expect("the fixture parses"); - database_entry_codec(input).expect("the fixture is accepted").to_string().replace(' ', "") - } - - #[test] - fn name_index_and_global_generate_the_lookup_surface() { - let tokens = entry_tokens( - r#"#[cultcache(type = "t", global)] struct S { - #[cultcache(key = 0, name)] label: String, - #[cultcache(key = 1, index)] code: String, - #[cultcache(key = 2, index = "sku")] stock: Option, - }"#, - ); - assert!(tokens.contains("constGLOBAL:bool=true"), "{tokens}"); - assert!(tokens.contains("fncult_name"), "{tokens}"); - assert!(tokens.contains("(\"code\",value)"), "{tokens}"); - assert!(tokens.contains("(\"sku\",value)"), "{tokens}"); - } - - #[test] - fn a_record_without_lookups_generates_none_of_them() { - let tokens = entry_tokens(r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0)] a: String }"#); - assert!(tokens.contains("constGLOBAL:bool=false"), "{tokens}"); - assert!(!tokens.contains("fncult_name") && !tokens.contains("fncult_indexes"), "{tokens}"); - } - - // ResolveIndexAlias: a blank alias is the member's name. - #[test] - fn a_blank_index_alias_is_the_member_name() { - let tokens = entry_tokens( - r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = " ")] code: String }"#, - ); - assert!(tokens.contains("(\"code\",value)"), "{tokens}"); - } - - #[test] - fn two_names_are_refused_for_that_reason() { - let message = entry_error( - r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name)] a: String, #[cultcache(key = 1, name)] b: String }"#, - ); - assert!(message.contains("more than one member"), "{message}"); - } - - // D10: two members that declare one alias; a bare index is the member name, so it collides with an explicit alias of it. - #[test] - fn two_members_with_one_index_alias_are_refused_for_that_reason() { - let message = entry_error( - r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index = "k")] a: String, #[cultcache(key = 1, index = "k")] b: String }"#, - ); - assert!(message.contains("both declare index alias \"k\""), "{message}"); - let message = entry_error( - r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index)] a: String, #[cultcache(key = 1, index = "a")] b: String }"#, - ); - assert!(message.contains("both declare index alias \"a\""), "{message}"); - } - - #[test] - fn a_repeated_lookup_attribute_is_refused() { - let message = entry_error( - r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, name, name)] a: String }"#, - ); - assert!(message.contains("repeated `name`"), "{message}"); - let message = entry_error( - r#"#[cultcache(type = "t")] struct S { #[cultcache(key = 0, index, index = "x")] a: String }"#, - ); - assert!(message.contains("repeated `index`"), "{message}"); - let message = entry_error(r#"#[cultcache(type = "t", global, global)] struct S { #[cultcache(key = 0)] a: String }"#); - assert!(message.contains("repeated `global`"), "{message}"); - } - - // Only a DatabaseEntry is looked up: a nested slot record carrying the attributes is refused as before. - #[test] - fn lookup_attributes_are_refused_on_a_slots_record() { - let message = slots_error(r#"struct S { #[cultcache(key = 0, name)] a: String }"#); - assert!(message.contains("unsupported field cultcache attribute"), "{message}"); - let message = slots_error(r#"struct S { #[cultcache(key = 0, index)] a: String }"#); - assert!(message.contains("unsupported field cultcache attribute"), "{message}"); - } - } diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index 4ceb43f2..b77f4971 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -162,68 +162,8 @@ pub use cultcache_rs_derive::DatabaseEntry; pub trait DatabaseEntry: Serialize + DeserializeOwned + Clone + Send + 'static { const TYPE: &'static str; const SCHEMA_NAME: &'static str = "DatabaseEntry"; - /// `#[cultcache(global)]`: the cache holds at most one record of this type, found by `get_global`. - const GLOBAL: bool = false; - /// The `#[cultcache(name)]` member's value, which `get_by_name` finds the record by. `None` is no name. - fn cult_name(&self) -> Option { - None - } - /// Each `#[cultcache(index)]` member as (alias, value), which `get_by_index` finds the record by. - fn cult_indexes(&self) -> Vec<(&'static str, String)> { - Vec::new() - } } -/// What a `#[cultcache(name)]` or `#[cultcache(index)]` member says it is called: its text, or `None` for a member -/// that holds no value (an `Option` that is `None`). The cache treats an empty name and a blank index value as none. -pub trait CultLookupValue { - fn lookup_value(&self) -> Option; -} - -impl CultLookupValue for String { - fn lookup_value(&self) -> Option { - Some(self.clone()) - } -} - -impl CultLookupValue for Option { - fn lookup_value(&self) -> Option { - self.as_ref().and_then(CultLookupValue::lookup_value) - } -} - -macro_rules! lookup_value_by_display { - ($($ty:ty),* $(,)?) => {$( - impl CultLookupValue for $ty { - fn lookup_value(&self) -> Option { - Some(self.to_string()) - } - } - )*}; -} - -lookup_value_by_display!(bool, char, i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize); - -/// A global type already holds a record under another key; a second would leave `get_global` with two answers. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CultCacheSecondGlobal { - pub schema_name: String, - pub existing_key: String, - pub key: String, -} - -impl std::fmt::Display for CultCacheSecondGlobal { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - formatter, - "{} is a global and already has record {}; {} would be a second.", - self.schema_name, self.existing_key, self.key - ) - } -} - -impl std::error::Error for CultCacheSecondGlobal {} - /// A type written as a positional slots array: what the `CultSlots` and `DatabaseEntry` /// derives implement, and what a `CultUnion` arm must carry. #[diagnostic::on_unimplemented( @@ -2715,8 +2655,6 @@ struct CultCacheStoreRegistration { pub struct CultCache { definitions: BTreeMap, - /// The registered types declared `#[cultcache(global)]`. - globals: BTreeSet, schema_name_definitions: BTreeMap, entries: BTreeMap<(String, String), CultCacheEnvelope>, stores: Vec, @@ -2726,7 +2664,6 @@ impl CultCache { pub fn new() -> Self { Self { definitions: BTreeMap::new(), - globals: BTreeSet::new(), schema_name_definitions: BTreeMap::new(), entries: BTreeMap::new(), stores: Vec::new(), @@ -2757,11 +2694,6 @@ impl CultCache { )); } self.definitions.insert(T::TYPE.to_string(), T::SCHEMA_NAME); - if T::GLOBAL { - self.globals.insert(T::TYPE.to_string()); - } else { - self.globals.remove(T::TYPE); - } self.schema_name_definitions .insert(T::SCHEMA_NAME.to_string(), T::TYPE.to_string()); Ok(()) @@ -2868,7 +2800,6 @@ impl CultCache { loaded.insert(entry_id(&entry), entry); } } - self.refuse_second_globals(&loaded)?; self.entries = loaded; Ok(()) } @@ -2952,65 +2883,6 @@ impl CultCache { Ok(values) } - /// The one record of a `#[cultcache(global)]` type, or `None` before it is written (and for a type that is - /// not a global). - pub fn get_global(&self) -> Result> { - self.require_entry_type::()?; - if !T::GLOBAL { - return Ok(None); - } - self.find_record::(|_| true) - } - - /// The record whose `#[cultcache(name)]` member is `name`. An empty name is no name, so it finds nothing. Records - /// of one type sharing a name find the one with the smallest key (ordinal), whatever order they were written or - /// loaded in; removing it finds the next. - pub fn get_by_name(&self, name: &str) -> Result> { - self.require_entry_type::()?; - if name.is_empty() { - return Ok(None); - } - self.find_record::(|document| document.cult_name().as_deref() == Some(name)) - } - - /// The record whose `#[cultcache(index)]` member `alias` holds `value`. A blank value is not indexed, so it finds - /// nothing. Records of one type sharing a value find the one with the smallest key (ordinal), whatever order they - /// were written or loaded in; removing it finds the next. - pub fn get_by_index(&self, alias: &str, value: &str) -> Result> { - self.require_entry_type::()?; - if value.trim().is_empty() { - return Ok(None); - } - self.find_record::(|document| { - document - .cult_indexes() - .iter() - .any(|(held_alias, held_value)| *held_alias == alias && held_value == value) - }) - } - - /// The first record of `T`, in key order, that `matches`. - fn find_record(&self, matches: impl Fn(&T) -> bool) -> Result> { - let held = self - .entries - .range((T::TYPE.to_string(), String::new())..) - .take_while(|((held_type, _), _)| held_type == T::TYPE); - for (_, entry) in held { - let document: T = decode_payload(&entry.payload).with_context(|| { - format!( - "failed to decode CultCache entry {:?} at key {:?} as {}", - T::TYPE, - entry.key, - T::SCHEMA_NAME - ) - })?; - if matches(&document) { - return Ok(Some(document)); - } - } - Ok(None) - } - pub fn soa(&self) -> Result> { let keyed_rows = self.get_all_with_keys::()?; let (keys, documents): (Vec<_>, Vec<_>) = keyed_rows.into_iter().unzip(); @@ -3028,7 +2900,6 @@ impl CultCache { pub fn put(&mut self, key: impl Into, value: &T) -> Result { let (entry, parsed) = self.prepare_entry(key, value)?; - self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; } @@ -3140,7 +3011,6 @@ impl CultCache { for entry in &staged { next.insert(entry_id(entry), entry.clone()); } - self.refuse_second_globals(&next)?; if let Some(store_index) = store_index { // The home store receives its own records only: those it already // holds in this cache plus the staged ones. @@ -3159,7 +3029,6 @@ impl CultCache { pub fn put_envelope(&mut self, entry: CultCacheEnvelope) -> Result { let parsed = self.validate_envelope::(&entry)?; - self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(T::TYPE)? { self.stores[home].store.push(&entry)?; } @@ -3192,7 +3061,6 @@ impl CultCache { )); } require_payload_depth(&entry)?; - self.refuse_second_global(&self.entries, &entry.r#type, &entry.key)?; if let Some(home) = self.home_index(&entry.r#type)? { self.stores[home].store.push(&entry)?; } @@ -3278,43 +3146,10 @@ impl CultCache { self.home_index(&entry.r#type)?; loaded.insert(entry_id(&entry), entry); } - self.refuse_second_globals(&loaded)?; self.entries = loaded; Ok(()) } - fn refuse_second_global( - &self, - entries: &BTreeMap<(String, String), CultCacheEnvelope>, - type_id: &str, - key: &str, - ) -> Result<()> { - if !self.globals.contains(type_id) { - return Ok(()); - } - let held_other = entries - .range((type_id.to_string(), String::new())..) - .take_while(|((held_type, _), _)| held_type == type_id) - .map(|((_, held_key), _)| held_key) - .find(|held_key| held_key.as_str() != key); - match held_other { - Some(existing_key) => Err(CultCacheSecondGlobal { - schema_name: self.definitions.get(type_id).copied().unwrap_or(type_id).to_string(), - existing_key: existing_key.clone(), - key: key.to_string(), - } - .into()), - None => Ok(()), - } - } - - fn refuse_second_globals(&self, entries: &BTreeMap<(String, String), CultCacheEnvelope>) -> Result<()> { - for (type_id, key) in entries.keys() { - self.refuse_second_global(entries, type_id, key)?; - } - Ok(()) - } - fn require_entry_type(&self) -> Result<()> { match self.definitions.get(T::TYPE) { Some(schema_name) if *schema_name == T::SCHEMA_NAME => Ok(()), diff --git a/packages/cultcache-rs/tests/lookups.rs b/packages/cultcache-rs/tests/lookups.rs deleted file mode 100644 index ca5b459d..00000000 --- a/packages/cultcache-rs/tests/lookups.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! Lookups parity with the C# reference: `GetByName`, `GetByIndex` and `GetGlobal` -//! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces. - -use cultcache_rs::{ - CacheBackingStore, CultCache, CultCacheSecondGlobal, CultCacheSoaTable, DatabaseEntry, - SingleFileMessagePackBackingStore, -}; - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "item")] -struct Item { - #[cultcache(key = 0, name)] - name: String, - #[cultcache(key = 1, index)] - category: String, - #[cultcache(key = 2, index = "sku")] - code: Option, - #[cultcache(key = 3, default)] - value: u32, -} - -fn item(name: &str, category: &str, code: Option<&str>) -> Item { - Item { name: name.into(), category: category.into(), code: code.map(Into::into), value: 0 } -} - -/// Same names and values as `Item`, a different type: lookups never cross types. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "gadget")] -struct Gadget { - #[cultcache(key = 0, name)] - name: String, - #[cultcache(key = 1, index)] - category: String, -} - -/// Sorts after `Item` and has its exact shape: a lookup that ran past the end of its own type would answer from it. -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "zed")] -struct Zed { - #[cultcache(key = 0, name)] - name: String, - #[cultcache(key = 1, index)] - category: String, - #[cultcache(key = 2, index = "sku")] - code: Option, - #[cultcache(key = 3, default)] - value: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "settings", global)] -struct Settings { - #[cultcache(key = 0)] - value: String, -} - -fn settings(value: &str) -> Settings { - Settings { value: value.into() } -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "plain")] -struct Plain { - #[cultcache(key = 0)] - value: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "numbered")] -struct Numbered { - #[cultcache(key = 0, name)] - id: u32, - #[cultcache(key = 1, index)] - owner: u64, -} - -fn cache() -> CultCache { - let mut cache = CultCache::new(); - cache.register_entry_type::().unwrap(); - cache.register_entry_type::().unwrap(); - cache.register_entry_type::().unwrap(); - cache.register_entry_type::().unwrap(); - cache.register_entry_type::().unwrap(); - cache.register_entry_type::().unwrap(); - cache -} - -fn reopened(path: &std::path::Path) -> CultCache { - let mut reopened = cache(); - reopened.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); - reopened.pull_all_backing_stores().unwrap(); - reopened -} - -fn stored_cache(path: &std::path::Path) -> CultCache { - let mut stored = cache(); - stored.add_generic_backing_store(SingleFileMessagePackBackingStore::new(path)).unwrap(); - stored.pull_all_backing_stores().unwrap(); - stored -} - -// The C# FirstCall snippet: a record written to a .cc file is found by its [CultName] through a second cache. -#[test] -fn a_record_is_found_by_name_and_by_index_after_a_reopen() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("lookups.cc"); - let mut written = stored_cache(&path); - written.put("item:potion", &Item { value: 50, ..item("Potion", "Consumable", Some("P-1")) }).unwrap(); - - let reopened = reopened(&path); - let by_name = reopened.get_by_name::("Potion").unwrap().unwrap(); - assert_eq!(by_name.value, 50); - assert_eq!(reopened.get_by_index::("category", "Consumable").unwrap(), Some(by_name.clone())); - assert_eq!(reopened.get_by_index::("sku", "P-1").unwrap(), Some(by_name)); -} - -#[test] -fn a_missing_name_or_value_is_none_and_an_unregistered_type_is_refused() { - let mut cache = cache(); - cache.put("item:a", &item("A", "cat", Some("a-1"))).unwrap(); - - assert_eq!(cache.get_by_name::("B").unwrap(), None); - assert_eq!(cache.get_by_name::("a").unwrap(), None, "names compare ordinally"); - assert_eq!(cache.get_by_index::("category", "other").unwrap(), None); - assert_eq!(cache.get_by_index::("nosuchalias", "cat").unwrap(), None); - assert_eq!(cache.get_by_index::("code", "a-1").unwrap(), None, "the alias is sku, not the member name"); - - let bare = CultCache::new(); - assert!(bare.get_by_name::("A").is_err()); - assert!(bare.get_by_index::("category", "cat").is_err()); - assert!(bare.get_global::().is_err()); -} - -#[test] -fn an_index_without_an_alias_is_named_by_its_member() { - let mut cache = cache(); - cache.put("item:a", &item("A", "cat", None)).unwrap(); - assert!(cache.get_by_index::("category", "cat").unwrap().is_some()); -} - -// A blank name is no name; a blank index value is not indexed; a nil member holds no value. -#[test] -fn blank_and_nil_values_are_not_found() { - let mut cache = cache(); - cache.put("item:blank", &item("", " ", None)).unwrap(); - assert_eq!(cache.get_by_name::("").unwrap(), None); - assert_eq!(cache.get_by_index::("category", " ").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "").unwrap(), None); - assert_eq!(cache.get_by_index::("sku", "").unwrap(), None); - - // Only emptiness voids a name: whitespace is a name, as in C# (Length > 0). - cache.put("item:space", &item(" ", "x", None)).unwrap(); - assert_eq!(cache.get_by_name::(" ").unwrap().unwrap().category, "x"); -} - -#[test] -fn lookups_never_cross_types() { - let mut cache = cache(); - cache.put("item:a", &item("same", "same", None)).unwrap(); - cache.put("gadget:a", &Gadget { name: "same".into(), category: "same".into() }).unwrap(); - cache.put("zed:a", &Zed { name: "same".into(), category: "same".into(), code: None, value: 0 }).unwrap(); - - assert_eq!(cache.get_by_name::("same").unwrap().unwrap().name, "same"); - cache.delete::("item:a").unwrap(); - assert_eq!(cache.get_by_name::("same").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "same").unwrap(), None); - assert!(cache.get_by_name::("same").unwrap().is_some()); - assert!(cache.get_by_index::("category", "same").unwrap().is_some()); -} - -#[test] -fn a_name_and_index_follow_a_rewritten_record_and_leave_a_removed_one() { - let mut cache = cache(); - cache.put("item:a", &item("old", "c1", None)).unwrap(); - cache.put("item:a", &item("new", "c2", None)).unwrap(); - - assert_eq!(cache.get_by_name::("old").unwrap(), None, "a stale name is not found"); - assert_eq!(cache.get_by_index::("category", "c1").unwrap(), None, "a stale value is not found"); - assert!(cache.get_by_name::("new").unwrap().is_some()); - assert!(cache.get_by_index::("category", "c2").unwrap().is_some()); - - cache.delete::("item:a").unwrap(); - assert_eq!(cache.get_by_name::("new").unwrap(), None); - assert_eq!(cache.get_by_index::("category", "c2").unwrap(), None); -} - -#[test] -fn non_string_members_are_found_by_their_text() { - let mut cache = cache(); - let owner = 161_u64; - cache.put("numbered:1", &Numbered { id: 42, owner }).unwrap(); - assert!(cache.get_by_name::("42").unwrap().is_some()); - assert!(cache.get_by_index::("owner", "161").unwrap().is_some()); -} - -// VariantTests.RemovingTheHolderADuplicateValueFindsLeavesTheOtherFindable: records sharing a value find the one with -// the smallest key, whatever the write order, live and after a reopen; removing the holder finds the other. -#[test] -fn records_sharing_a_value_find_the_smallest_key_and_removing_it_finds_the_next() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("shared.cc"); - { - let mut cache = stored_cache(&path); - cache.put("item:p2", &item("p2", "x", None)).unwrap(); - cache.put("item:p1", &item("p1", "x", None)).unwrap(); - let found = |cache: &CultCache| cache.get_by_index::("category", "x").unwrap().map(|found| found.name); - assert_eq!(found(&cache).as_deref(), Some("p1"), "the smallest key, though written second"); - assert_eq!(reopened(&path).get_by_index::("category", "x").unwrap().unwrap().name, "p1"); - - cache.delete::("item:p1").unwrap(); - assert_eq!(found(&cache).as_deref(), Some("p2"), "removing the holder finds the other one"); - - cache.put("item:p1", &item("p1", "x", None)).unwrap(); - assert_eq!(found(&cache).as_deref(), Some("p1")); - cache.delete::("item:p2").unwrap(); - assert_eq!(found(&cache).as_deref(), Some("p1"), "removing the other changes nothing"); - cache.delete::("item:p1").unwrap(); - assert_eq!(found(&cache), None); - } - assert_eq!(reopened(&path).get_by_index::("category", "x").unwrap(), None); -} - -#[test] -fn records_sharing_a_name_find_the_smallest_key() { - let mut cache = cache(); - cache.put("item:b", &Item { value: 2, ..item("twin", "c", None) }).unwrap(); - cache.put("item:a", &Item { value: 1, ..item("twin", "c", None) }).unwrap(); - assert_eq!(cache.get_by_name::("twin").unwrap().unwrap().value, 1); - cache.delete::("item:a").unwrap(); - assert_eq!(cache.get_by_name::("twin").unwrap().unwrap().value, 2); -} - -// BackingStoreTests.HydratesPersistedGlobal: opening a store invents no global; a written one survives a reopen. -#[test] -fn a_global_is_none_until_written_and_survives_a_reopen() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("global.cc"); - { - let mut seed = stored_cache(&path); - assert_eq!(seed.get_global::().unwrap(), None, "opening a store invented a global"); - seed.put("global:settings", &settings("durable authority")).unwrap(); - assert_eq!(seed.get_global::().unwrap(), Some(settings("durable authority"))); - } - assert_eq!(reopened(&path).get_global::().unwrap(), Some(settings("durable authority"))); -} - -#[test] -fn a_type_that_is_not_a_global_has_no_global() { - let mut cache = cache(); - cache.put("plain:one", &Plain { value: "v".into() }).unwrap(); - assert_eq!(cache.get_global::().unwrap(), None); -} - -#[test] -fn a_second_global_under_another_key_is_refused_and_writes_nothing() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("second.cc"); - let mut cache = stored_cache(&path); - cache.put("g1", &settings("one")).unwrap(); - - let refusal = cache.put("g2", &settings("two")).unwrap_err(); - let second = refusal.downcast_ref::().expect("a typed refusal"); - assert_eq!((second.existing_key.as_str(), second.key.as_str()), ("g1", "g2")); - assert!(refusal.to_string().contains("is a global and already has record g1; g2 would be a second.")); - assert_eq!(cache.get_global::().unwrap(), Some(settings("one"))); - assert_eq!(reopened(&path).get_global::().unwrap(), Some(settings("one")), "nothing reached the store"); - - // The same key is the same record, not a second. - cache.put("g1", &settings("one again")).unwrap(); - assert_eq!(cache.get_global::().unwrap(), Some(settings("one again"))); -} - -// RetypeEvictionTests.EvictingAGlobalFreesItsSlot: removing the global frees the slot for another key. -#[test] -fn removing_a_global_frees_its_slot() { - let mut cache = cache(); - cache.put("g1", &settings("one")).unwrap(); - assert!(cache.put("g2", &settings("two")).is_err()); - cache.delete::("g1").unwrap(); - assert_eq!(cache.get_global::().unwrap(), None); - cache.put("g2", &settings("two")).unwrap(); - assert_eq!(cache.get_global::().unwrap(), Some(settings("two"))); -} - -#[test] -fn every_write_path_refuses_a_second_global() { - let mut cache = cache(); - cache.put("g1", &settings("one")).unwrap(); - let (second, _) = cache.prepare_entry("g2", &settings("two")).unwrap(); - - assert!(cache.put_envelope::(second.clone()).unwrap_err().is::()); - assert!(cache.put_raw_envelope(second.clone()).unwrap_err().is::()); - let (plain, _) = cache.prepare_entry("plain:1", &Plain { value: "v".into() }).unwrap(); - assert!(cache.put_prepared_batch(vec![plain, second]).unwrap_err().is::()); - assert_eq!(cache.get::("plain:1").unwrap(), None, "a refused batch lands none of it"); - assert_eq!(cache.get_global::().unwrap(), Some(settings("one"))); -} - -#[test] -fn a_batch_that_stages_two_globals_is_refused_whole() { - let mut cache = cache(); - let (one, _) = cache.prepare_entry("g1", &settings("one")).unwrap(); - let (two, _) = cache.prepare_entry("g2", &settings("two")).unwrap(); - assert!(cache.put_prepared_batch(vec![one, two]).unwrap_err().is::()); - assert_eq!(cache.get_global::().unwrap(), None); -} - -// StoreRoutingTests: a store holding two globals is refused on load rather than answering one of them. -#[test] -fn loading_a_store_that_holds_two_globals_is_refused() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("two.cc"); - let source = cache(); - let mut store = SingleFileMessagePackBackingStore::new(&path); - for (key, value) in [("g1", "one"), ("g2", "two")] { - let (entry, _) = source.prepare_entry(key, &settings(value)).unwrap(); - store.push(&entry).unwrap(); - } - - let mut loading = cache(); - loading.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap(); - assert!(loading.pull_all_backing_stores().unwrap_err().is::()); - assert_eq!(loading.get_global::().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::().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::().unwrap(), Some(settings("prior")), "a refused load keeps the prior view"); - assert_eq!(loading.get::("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::().unwrap(), Some(settings("one"))); - assert_eq!(loading.get::("plain:1").unwrap(), None, "a load replaces the view"); -} diff --git a/packages/cultcache-ts/CHANGELOG.md b/packages/cultcache-ts/CHANGELOG.md index 819df6c4..a2f10c54 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -3,26 +3,27 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). -## [0.15.0] - -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. +## Unreleased ### 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 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`. -- 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. ### 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. - `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. -- 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`). - `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. + +## [0.14.2] + +### Added - `SingleFileMessagePackBackingStore` takes a cross-process write lock on `.lock`, 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 @@ -32,18 +33,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. - 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. -- `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 - - 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 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 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. + +## [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. diff --git a/packages/cultcache-ts/README.md b/packages/cultcache-ts/README.md index b3a85345..192d4b5d 100644 --- a/packages/cultcache-ts/README.md +++ b/packages/cultcache-ts/README.md @@ -61,7 +61,7 @@ const note = decodeDocumentPayload(noteDocument, envelope.payload); // parsed No const payload = encodeDocumentPayload(noteDocument, note); // DocumentPayload ``` -Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. The whole cache without the single-file store is `@gamecult/cultcache-ts/browser`, which is also what bundlers resolve for the package root (`browser` condition); bring your own `CacheBackingStore`. +Browsers import the same names from `@gamecult/cultcache-ts/document`, which holds no Node built-ins. ## `.cc` Inspection diff --git a/packages/cultcache-ts/package.json b/packages/cultcache-ts/package.json index b6803979..c48e54e5 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -1,21 +1,15 @@ { "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.", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "browser": "./dist/browser.js", "require": "./dist/index.js", "default": "./dist/index.js" }, - "./browser": { - "types": "./dist/browser.d.ts", - "require": "./dist/browser.js", - "default": "./dist/browser.js" - }, "./document": { "types": "./dist/document.d.ts", "require": "./dist/document.js", @@ -31,9 +25,6 @@ "*": { "document": [ "dist/document.d.ts" - ], - "browser": [ - "dist/browser.d.ts" ] } }, diff --git a/packages/cultcache-ts/src/browser.ts b/packages/cultcache-ts/src/browser.ts deleted file mode 100644 index bf62976f..00000000 --- a/packages/cultcache-ts/src/browser.ts +++ /dev/null @@ -1,49 +0,0 @@ -// Browser-safe surface: everything the package root exports except the node-only single-file store -// (node:fs, koffi). Bring your own CacheBackingStore, or none. -export * as codecs from "./codec"; -export type { Codec, SlotsCodec } from "./codec"; -export { CultCache, CultCacheBuilder } from "./cult-cache"; -export { inspectCultCacheBytes } from "./cult-cache-inspector"; -export { - decodeDocumentPayload, - defineDocumentRegistry, - defineDocumentType, - documentPayloadBytes, - documentPayloadFromBytes, - encodeDocumentPayload, - schemaIdentityOf, - type CultCacheSchemaIdentity, -} from "./document"; -export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth"; -export type { DocumentPayload } from "./document"; -export { SchemaConflictError, StoreUnreadableError } from "./store-format"; -export type { - AnyCultCacheDocumentDefinition, - CacheBackingStore, - CultCacheDocumentAccessor, - CultCacheDocumentDefinition, - CultCacheDocumentFieldName, - CultCacheDocumentFormatter, - CultCacheDocumentIndexDefinition, - CultCacheDocumentRegistry, - CultCacheSchemaCatalogEntry, - CultCacheSchemaCatalogMember, - CultCacheSchema, - CultCacheDocumentValue, - CultCacheEnvelope, - CultCacheIndexScalar, - InferCultCacheSchemaValue, - CultCacheStoreRegistration, - PushAllOptions, -} from "./types"; -export type { - CultCacheInspection, - InspectedCatalogEntry, - InspectedCatalogMember, - InspectedRecord, -} from "./cult-cache-inspector"; -export { - defineSwarmDocuments, - type DefineDocumentType, - type SwarmDocumentCatalog, -} from "./swarm-documents"; diff --git a/packages/cultcache-ts/src/index.ts b/packages/cultcache-ts/src/index.ts index f55dd049..1facf6e2 100644 --- a/packages/cultcache-ts/src/index.ts +++ b/packages/cultcache-ts/src/index.ts @@ -1,2 +1,48 @@ -export * from "./browser"; +export * as codecs from "./codec"; +export type { Codec, SlotsCodec } from "./codec"; +export { CultCache, CultCacheBuilder } from "./cult-cache"; +export { inspectCultCacheBytes } from "./cult-cache-inspector"; +export { + decodeDocumentPayload, + defineDocumentRegistry, + defineDocumentType, + documentPayloadBytes, + documentPayloadFromBytes, + encodeDocumentPayload, + schemaIdentityOf, + type CultCacheSchemaIdentity, +} from "./document"; +export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth"; +export type { DocumentPayload } from "./document"; export { SingleFileMessagePackBackingStore, readSingleFileStore, type SingleFileStoreRead } from "./single-file-messagepack-backing-store"; +export { SchemaConflictError, StoreUnreadableError } from "./store-format"; +export type { + AnyCultCacheDocumentDefinition, + CacheBackingStore, + CultCacheDocumentAccessor, + CultCacheDocumentDefinition, + CultCacheDocumentFieldName, + CultCacheDocumentFormatter, + CultCacheDocumentIndexDefinition, + CultCacheDocumentRegistry, + CultCacheSchemaCatalogEntry, + CultCacheSchemaCatalogMember, + CultCacheSchema, + CultCacheDocumentValue, + CultCacheEnvelope, + CultCacheIndexScalar, + InferCultCacheSchemaValue, + CultCacheStoreRegistration, + PushAllOptions, +} from "./types"; +export type { + CultCacheInspection, + InspectedCatalogEntry, + InspectedCatalogMember, + InspectedRecord, +} from "./cult-cache-inspector"; +export { + defineSwarmDocuments, + type DefineDocumentType, + type SwarmDocumentCatalog, +} from "./swarm-documents"; diff --git a/packages/cultcache-ts/test/browser-entry.test.ts b/packages/cultcache-ts/test/browser-entry.test.ts deleted file mode 100644 index bbf2e2c4..00000000 --- a/packages/cultcache-ts/test/browser-entry.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import assert from "node:assert/strict"; -import { join } from "node:path"; -import test from "node:test"; -import { runInNewContext } from "node:vm"; -import { build } from "esbuild"; - -// dist-test/test/ -> dist-test/src/ -const srcDir = join(__dirname, "..", "src"); - -async function bundleForBrowser(entry: string): Promise { - const result = await build({ - entryPoints: [join(srcDir, entry)], - bundle: true, - platform: "browser", - format: "iife", - globalName: "cultcache", - write: false, - logLevel: "silent", - }); - return result.outputFiles[0].text; -} - -test("the browser entry bundles for platform=browser and runs without node", async () => { - const code = await bundleForBrowser("browser.js"); - assert.ok(!/node:fs|koffi/.test(code), "no node-only store in the browser bundle"); - // A context with no require/process/Buffer: only what a browser page has. - const sandbox: Record = { TextEncoder, TextDecoder }; - runInNewContext(code + "\nglobalThis.__api = cultcache;", sandbox); - const api = sandbox.__api as Record; - assert.equal(typeof api.CultCache, "function"); - assert.equal(typeof api.defineDocumentType, "function"); - assert.equal(api.SingleFileMessagePackBackingStore, undefined); -}); - -test("the node entry still exports the single-file store and everything the browser entry has", async () => { - const node = await import(join(srcDir, "index.js")); - const browser = await import(join(srcDir, "browser.js")); - assert.equal(typeof node.SingleFileMessagePackBackingStore, "function"); - assert.equal(typeof node.readSingleFileStore, "function"); - for (const name of Object.keys(browser)) assert.ok(name in node, `node entry lost ${name}`); -}); - -test("the package root keeps the node-only store out of the browser build", async () => { - await assert.rejects(bundleForBrowser("index.js"), /node:fs|node:path|koffi/); -}); diff --git a/packages/cultmesh-browser/CHANGELOG.md b/packages/cultmesh-browser/CHANGELOG.md index ccda16a0..0093ccf0 100644 --- a/packages/cultmesh-browser/CHANGELOG.md +++ b/packages/cultmesh-browser/CHANGELOG.md @@ -1,14 +1,8 @@ # 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/). -## 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] ### Added diff --git a/packages/cultmesh-browser/README.md b/packages/cultmesh-browser/README.md index 500b858b..ab29d65f 100644 --- a/packages/cultmesh-browser/README.md +++ b/packages/cultmesh-browser/README.md @@ -1,6 +1,6 @@ # 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 document leases across route replacement, and correlates typed CultNet 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. ```ts -import { fetchVerifiedContent } from "@gamecult/cultmesh-browser"; +import { fetchVerifiedContent } from "cultmesh-browser"; const bytes = await fetchVerifiedContent(endpoint, manifest, { signal: AbortSignal.timeout(30_000), diff --git a/packages/cultmesh-browser/package.json b/packages/cultmesh-browser/package.json index 0984ae6d..6d52c9bf 100644 --- a/packages/cultmesh-browser/package.json +++ b/packages/cultmesh-browser/package.json @@ -1,5 +1,5 @@ { - "name": "@gamecult/cultmesh-browser", + "name": "cultmesh-browser", "version": "0.4.0", "description": "Browser-safe CultMesh document leases and typed operations over CultNet WebSocket transport.", "type": "module", @@ -41,12 +41,12 @@ "node": ">=20" }, "dependencies": { - "@gamecult/cultcache-ts": "^0.15.0", - "@gamecult/cultnet-ts": "^0.3.0" + "@gamecult/cultcache-ts": "^0.14.0", + "cultnet-ts": "^0.3.0" }, "devDependencies": { "@types/node": "^24.0.0", - "@gamecult/cultmesh-ts": "^0.2.0", + "cultmesh-ts": "^0.2.0", "typescript": "^5.9.0" } } diff --git a/packages/cultmesh-browser/src/content.ts b/packages/cultmesh-browser/src/content.ts index c41fc904..d9e09b4f 100644 --- a/packages/cultmesh-browser/src/content.ts +++ b/packages/cultmesh-browser/src/content.ts @@ -3,8 +3,8 @@ // 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. -import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "@gamecult/cultnet-ts/content"; -import { isLoopbackEndpoint } from "@gamecult/cultnet-ts/authority"; +import { EMPTY_SHA256, chunkRecordKey, parseSha256Hex, sha256Hex, validateManifest, type CultMeshCdnArtifactManifest } from "cultnet-ts/content"; +import { isLoopbackEndpoint } from "cultnet-ts/authority"; export type CultMeshContentErrorCode = | "manifest-invalid" | "too-large" | "insecure-endpoint" | "webcrypto-unavailable" | "network" diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index 3852615e..beae9e36 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -1,6 +1,6 @@ import { decodeDocumentPayload } from "@gamecult/cultcache-ts/document"; import type { AnyCultCacheDocumentDefinition, CultCacheDocumentValue } from "@gamecult/cultcache-ts"; -import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { encodeCultNetMessageForWire, parseCultNetMessage, @@ -16,7 +16,7 @@ import { type CultMeshVerseCatalogResponseMessage, type CultMeshSessionAcceptedMessage, type CultMeshSessionOpenMessage, -} from "@gamecult/cultnet-ts/contracts"; +} from "cultnet-ts/contracts"; import { base64ToBytes, bytesToBase64, @@ -31,7 +31,7 @@ import { type CultMeshAuthorityTrustMode, type CultMeshAuthorityTrustPolicy, type CultMeshP256PublicKey, -} from "@gamecult/cultnet-ts/authority"; +} from "cultnet-ts/authority"; export * from "./content.js"; export type CultMeshBrowserIdentity = CultMeshAuthorityIdentity; diff --git a/packages/cultmesh-browser/test/content.test.ts b/packages/cultmesh-browser/test/content.test.ts index d4ec4d6e..cc0bb322 100644 --- a/packages/cultmesh-browser/test/content.test.ts +++ b/packages/cultmesh-browser/test/content.test.ts @@ -11,7 +11,7 @@ import { gzipSync } from "node:zlib"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; 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"; diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index 17ffbc02..1206b32d 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { codecs, defineDocumentType, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; -import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { encodeCultNetMessageForWire, parseCultNetMessage, @@ -12,7 +12,7 @@ import { type CultNetRawDocumentRecord, type CultMeshVerseCatalogResponseMessage, type CultMeshSessionOpenMessage, -} from "@gamecult/cultnet-ts/contracts"; +} from "cultnet-ts/contracts"; import { CultMeshBrowserClient, diff --git a/packages/cultmesh-browser/test/interop/content-https-provider.ts b/packages/cultmesh-browser/test/interop/content-https-provider.ts index f4b15aae..50b5f223 100644 --- a/packages/cultmesh-browser/test/interop/content-https-provider.ts +++ b/packages/cultmesh-browser/test/interop/content-https-provider.ts @@ -5,8 +5,8 @@ import { readFileSync } from "node:fs"; import { createServer } from "node:https"; import type { AddressInfo } from "node:net"; import { createInterface } from "node:readline"; -import { chunkRecordKey } from "@gamecult/cultnet-ts/content"; -import { createContentChunkHandler, createDirectoryChunkStore, toNodeListener, type ContentChunkAnswer, type ContentChunkRequest } from "@gamecult/cultmesh-ts/content"; +import { chunkRecordKey } from "cultnet-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 say = (line: object): void => { process.stdout.write(`${JSON.stringify(line)}\n`); }; diff --git a/packages/cultmesh-browser/test/interop/content-https-reader.ts b/packages/cultmesh-browser/test/interop/content-https-reader.ts index fab02066..693b22a8 100644 --- a/packages/cultmesh-browser/test/interop/content-https-reader.ts +++ b/packages/cultmesh-browser/test/interop/content-https-reader.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; 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"; const [endpointsJson, manifestsDir, only] = process.argv.slice(2) as [string, string, string | undefined]; diff --git a/packages/cultmesh-browser/test/interop/content-https.test.ts b/packages/cultmesh-browser/test/interop/content-https.test.ts index b26621eb..c86cf6ae 100644 --- a/packages/cultmesh-browser/test/interop/content-https.test.ts +++ b/packages/cultmesh-browser/test/interop/content-https.test.ts @@ -12,7 +12,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; 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 here = dirname(fileURLToPath(import.meta.url)); // dist-test/test/interop diff --git a/packages/cultmesh-browser/test/payload-type.test.ts b/packages/cultmesh-browser/test/payload-type.test.ts index bdc6f250..9cc95632 100644 --- a/packages/cultmesh-browser/test/payload-type.test.ts +++ b/packages/cultmesh-browser/test/payload-type.test.ts @@ -2,8 +2,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { documentPayloadBytes, documentPayloadFromBytes, type DocumentPayload } from "@gamecult/cultcache-ts"; -import { decodeWireValue } from "@gamecult/cultnet-ts/wire"; -import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts/contracts"; +import { decodeWireValue } from "cultnet-ts/wire"; +import type { CultNetRawDocumentRecord } from "cultnet-ts/contracts"; import { decodeCultNetPayload } from "../src/index.js"; diff --git a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt index 7d052785..10cc2a58 100644 --- a/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt +++ b/packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/CultMesh.kt @@ -808,7 +808,6 @@ interface CultNetSchemaMessageTransport : AutoCloseable { val profile: CultNetTransportProfile val stats: CultNetTransportStats - /** Fire-and-forget: an ack or a return is not admission; read the result frame. */ fun sendSchemaMessage(message: CultNetMessage) fun receiveSchemaMessage(timeoutMs: Long = 1_000, pollIntervalMs: Long = 5): CultNetMessage? @@ -3168,16 +3167,13 @@ class CultNetRudpSocketTransportConnection( return connected } - /** Fire-and-forget: an ack or a return is not admission; read the result frame. */ fun send(channelId: String, payload: ByteArray) { session.sendMany(channelId, payload, channelSendOptions(channelId, nowMs()), maxFragmentBytes).forEach { sendPacket(it) } 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)) - /** 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: String) = sendSchema(payload.toByteArray(StandardCharsets.UTF_8)) diff --git a/packages/cultmesh-py/README.md b/packages/cultmesh-py/README.md index 60ae4e49..63799e52 100644 --- a/packages/cultmesh-py/README.md +++ b/packages/cultmesh-py/README.md @@ -7,7 +7,7 @@ game sessions with prediction reconciliation, committed simulation facts, the local TCP/RUDP server, and a package daemon. 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 `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 or operator process needs a CultNet endpoint without embedding Python glue: @@ -255,17 +228,6 @@ prediction = session.predict(note_doc, "input:python:move", {"body": "predicted unsubscribe() ``` -## Authority route verification - -`cultmesh_py.authority` ports the C# reference -(`src/GameCult.Mesh/CultMeshAuthorityProof.cs`): `AuthorityTrustPolicy.validate` -judges an Odin-signed ECDSA P-256 route (channel protection, trusted root, -validity window, IEEE P1363 signature over the canonical bytes) and -`verify_session_proof` checks the provider nonce proof. Refusals raise -`AuthorityRouteRefusal` with the C# message text. It needs `cryptography`. -`tests/test_authority_route_vectors.py` judges -`contracts/cultmesh/authority-route-vectors*.json`. - ## Verify `cultmesh_py.verify` checks the public exports and `py.typed` markers of all diff --git a/packages/cultmesh-py/pyproject.toml b/packages/cultmesh-py/pyproject.toml index 5f9bda4d..a1df5ee3 100644 --- a/packages/cultmesh-py/pyproject.toml +++ b/packages/cultmesh-py/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] -dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0", "cryptography>=41"] +dependencies = ["cultcache-py>=0.2.0", "cultnet-py>=0.1.0"] [project.scripts] cultmesh-py-daemon = "cultmesh_py.daemon:main" cultmesh-py-verify = "cultmesh_py.verify:main" diff --git a/packages/cultmesh-py/src/cultmesh_py/__init__.py b/packages/cultmesh-py/src/cultmesh_py/__init__.py index 529a3859..f2dc6b28 100644 --- a/packages/cultmesh-py/src/cultmesh_py/__init__.py +++ b/packages/cultmesh-py/src/cultmesh_py/__init__.py @@ -39,14 +39,6 @@ from .simulation import ( CultMeshSimulationFactCommitter, simulation_fact_document, ) -from .write_authority import ( - CultMeshGrantAllWrites, - CultMeshRemoteWriteAuthority, - CultMeshRemoteWriteDecision, - CultMeshRemoteWriteOperation, - CultMeshRemoteWriteRequest, - CultMeshServerPeer, -) from cultnet_py.cultmesh_contracts import ( PEER_EXCHANGE_REQUEST, PEER_EXCHANGE_RESPONSE, @@ -97,12 +89,6 @@ __all__ = [ "CultMeshSimulationFact", "CultMeshSimulationFactCommit", "CultMeshSimulationFactCommitter", - "CultMeshGrantAllWrites", - "CultMeshRemoteWriteAuthority", - "CultMeshRemoteWriteDecision", - "CultMeshRemoteWriteOperation", - "CultMeshRemoteWriteRequest", - "CultMeshServerPeer", "CultMeshAuthorityLease", "CultMeshAuthorityLeaseCatalog", "CultMeshHmacAuthorityLeaseVerifier", diff --git a/packages/cultmesh-py/src/cultmesh_py/authority.py b/packages/cultmesh-py/src/cultmesh_py/authority.py deleted file mode 100644 index 3c6665ea..00000000 --- a/packages/cultmesh-py/src/cultmesh_py/authority.py +++ /dev/null @@ -1,338 +0,0 @@ -"""CultMesh authority route verification. - -Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of the -route constructors in `CultMeshVerse.cs`. Rule for rule, in the same order, the -same input yields the same refusal text as `CultMeshAuthorityTrustPolicy.Verify`: -unsigned, channel protection, root lookup, validity window, signature. - -Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written). -""" - -from __future__ import annotations - -import base64 -import binascii -import re -import struct -from dataclasses import dataclass -from typing import Iterable, Mapping, Sequence - -from cryptography.exceptions import InvalidSignature -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature - -ROUTE_DOMAIN = "gamecult.cultmesh.route-certificate.v1" -SESSION_DOMAIN = "gamecult.cultmesh.session-proof.v1" - -AUTHENTICATED_REMOTE = "authenticated-remote" -LOCAL_DEVELOPMENT = "local-development" - -# .NET `char.IsWhiteSpace`: Unicode Zs, Zl, Zp, U+0009-U+000D and U+0085. -# `str.strip()` is a different set (it also strips U+001C-U+001F). -_CSHARP_WHITESPACE = ( - "\u0009\u000a\u000b\u000c\u000d \u0085Ā įš€" - "ā€€ā€ā€‚ā€ƒā€„ā€…ā€†ā€‡ā€ˆā€‰ā€Š" - "ā€Øā€©ā€ÆāŸć€€" -) - - -def trim_csharp(value: str) -> str: - return value.strip(_CSHARP_WHITESPACE) - - -def is_blank_csharp(value: str | None) -> bool: - return value is None or trim_csharp(value) == "" - - -def _require(value: str | None, name: str) -> str: - if is_blank_csharp(value): - raise ValueError(f"Value must be non-empty. (Parameter '{name}')") - return trim_csharp(value) # type: ignore[arg-type] - - -class AuthorityRouteRefusal(Exception): - """A route or proof the consumer's trust policy refuses. - - Mirrors `CultMeshSessionException` carrying a `CultMeshSessionFailure` with - reason `Authentication`: `reason` is always "authentication". - """ - - reason = "authentication" - - def __init__(self, message: str, endpoint: str = "") -> None: - super().__init__(message) - self.message = message - self.endpoint = endpoint - - -def _b64_strict(value: str) -> bytes: - # `Convert.FromBase64String`: standard alphabet, padded, whitespace ignored. - stripped = re.sub(r"[ \t\r\n]", "", value) - return base64.b64decode(stripped, validate=True) - - -@dataclass(frozen=True) -class P256PublicKey: - key_id: str - x: str - y: str - - def __post_init__(self) -> None: - object.__setattr__(self, "key_id", _require(self.key_id, "keyId")) - for name in ("x", "y"): - value = _require(getattr(self, name), name) - try: - _b64_strict(value) - except (binascii.Error, ValueError) as error: - raise ValueError(f"Coordinate must be base64. (Parameter '{name}')") from error - object.__setattr__(self, name, value) - - def verify(self, payload: bytes, signature_base64: str) -> bool: - """IEEE P1363 (r || s) ECDSA-SHA256 verification; False on any malformation.""" - try: - x, y = _b64_strict(self.x), _b64_strict(self.y) - signature = _b64_strict(signature_base64) - if len(x) != 32 or len(y) != 32 or len(signature) != 64: - return False - key = ec.EllipticCurvePublicNumbers( - int.from_bytes(x, "big"), int.from_bytes(y, "big"), ec.SECP256R1() - ).public_key() - der = encode_dss_signature( - int.from_bytes(signature[:32], "big"), int.from_bytes(signature[32:], "big") - ) - key.verify(der, payload, ec.ECDSA(hashes.SHA256())) - return True - except (InvalidSignature, ValueError, binascii.Error): - return False - - -@dataclass(frozen=True) -class RouteCertificate: - provider_key: P256PublicKey - odin_key_id: str - issued_at_unix_milliseconds: int - expires_at_unix_milliseconds: int - signature: str = "" - - def __post_init__(self) -> None: - if is_blank_csharp(self.odin_key_id): - raise ValueError("Odin key identity is required. (Parameter 'odinKeyId')") - object.__setattr__(self, "odin_key_id", trim_csharp(self.odin_key_id)) - if self.issued_at_unix_milliseconds < 0: - raise ValueError("issuedAtUnixMilliseconds must not be negative.") - if self.expires_at_unix_milliseconds <= self.issued_at_unix_milliseconds: - raise ValueError("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.") - object.__setattr__(self, "signature", trim_csharp(self.signature or "")) - - -@dataclass(frozen=True) -class AuthorityRoute: - """`CultMeshAuthorityRoute`: construction cleans every field as the C# does.""" - - authority_runtime_id: str - endpoint: str - protocol_ids: Sequence[str] = () - priority: int = 0 - generation: str | None = None - certificate: RouteCertificate | None = None - - def __post_init__(self) -> None: - runtime_id = _require(self.authority_runtime_id, "authorityRuntimeId") - endpoint = _require(self.endpoint, "endpoint") - if self.priority < 0: - raise ValueError("priority must not be negative.") - object.__setattr__(self, "authority_runtime_id", runtime_id) - object.__setattr__(self, "endpoint", endpoint) - object.__setattr__( - self, - "protocol_ids", - tuple( - sorted( - {trim_csharp(v) for v in self.protocol_ids if not is_blank_csharp(v)}, - key=lambda v: v.encode("utf-16-be"), # StringComparer.Ordinal is UTF-16 code-unit order - ) - ), - ) - object.__setattr__( - self, - "generation", - f"{runtime_id}@{endpoint}" if is_blank_csharp(self.generation) else trim_csharp(self.generation), # type: ignore[arg-type] - ) - - -@dataclass(frozen=True) -class SessionOpen: - """The fields of `CultMeshSessionOpenMessage` the session proof covers.""" - - client_nonce: str | None = None - message_id: str | None = None - source_runtime_id: str | None = None - verse_id: str | None = None - authority_runtime_id: str | None = None - protocol_id: str | None = None - route_generation: str | None = None - - -@dataclass(frozen=True) -class SessionAccepted: - client_nonce: str | None - provider_key_id: str | None - provider_signature: str | None - - -@dataclass(frozen=True) -class VerifiedRoute: - route: AuthorityRoute - provider_key: P256PublicKey | None - is_local_development: bool - - -def canonical_fields(*values: str | None) -> bytes: - out = bytearray() - for value in values: - encoded = (value or "").encode("utf-8") - out += struct.pack(">I", len(encoded)) + encoded - return bytes(out) - - -def canonical_route(verse_id: str, route: AuthorityRoute) -> bytes: - certificate = route.certificate - if certificate is None: - raise ValueError("Route certificate is required.") - return canonical_fields( - ROUTE_DOMAIN, - verse_id, - route.authority_runtime_id, - route.endpoint, - "\u001f".join(route.protocol_ids), # already ordinally sorted; Python sorts by code point - str(route.priority), - route.generation, - certificate.provider_key.key_id, - certificate.provider_key.x, - certificate.provider_key.y, - certificate.odin_key_id, - str(certificate.issued_at_unix_milliseconds), - str(certificate.expires_at_unix_milliseconds), - ) - - -def canonical_session(request: SessionOpen, endpoint: str) -> bytes: - return canonical_fields( - SESSION_DOMAIN, - request.client_nonce, - request.message_id, - request.source_runtime_id, - request.verse_id, - request.authority_runtime_id, - request.protocol_id, - endpoint, - request.route_generation, - ) - - -# `System.Uri` refuses spellings WHATWG parsers repair; a lenient parse could call -# a refused endpoint loopback or protected. Same shape rule as the TypeScript port. -_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+\-.]*://([^/?#]+)") - - -def _split_endpoint(value: str) -> tuple[str, str] | None: - """Return (scheme, lowercase host) or None where System.Uri would refuse.""" - raw = re.sub(r"^[\x00-\x20]+|[\x00-\x20]+$", "", value) - if "\\" in raw: - return None - shape = _AUTHORITY.match(raw) - if not shape: - return None - authority = shape.group(1) - after_user = authority[authority.rfind("@") + 1 :] - if after_user.startswith("["): - host = after_user[: after_user.find("]") + 1] - else: - host = after_user.split(":")[0] - if not host or re.search(r"[^!-~]", host) or host.endswith("."): - return None - return raw.split("://", 1)[0].lower(), host.strip("[]").lower() - - -def is_loopback_endpoint(endpoint: str) -> bool: - parts = _split_endpoint(endpoint) - if parts is None: - return False - host = parts[1] - return host in ("localhost", "loopback", "::1", "::ffff:127.0.0.1") or bool( - re.fullmatch(r"127\.\d{1,3}\.\d{1,3}\.\d{1,3}", host) - ) - - -def is_protected_endpoint(endpoint: str) -> bool: - parts = _split_endpoint(endpoint) - return parts is not None and (parts[0] in ("wss", "https") or "quic" in parts[0]) - - -class AuthorityTrustPolicy: - """Consumer-owned policy. Fails closed unless a route is valid under it.""" - - def __init__(self, mode: str, odin_roots: Iterable[P256PublicKey] = ()) -> None: - if mode not in (AUTHENTICATED_REMOTE, LOCAL_DEVELOPMENT): - raise ValueError(f"Unknown trust mode '{mode}'.") - self.mode = mode - self._roots: dict[str, P256PublicKey] = {} - for root in odin_roots: - if root.key_id in self._roots: - raise ValueError(f"Odin root key id '{root.key_id}' is listed more than once in the trust policy.") - self._roots[root.key_id] = root - - def validate(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> None: - self.verify(verse_id, route, now_unix_milliseconds) - - def verify(self, verse_id: str, route: AuthorityRoute, now_unix_milliseconds: int) -> VerifiedRoute: - local = self.mode == LOCAL_DEVELOPMENT and is_loopback_endpoint(route.endpoint) - - def refuse(message: str) -> AuthorityRouteRefusal: - return AuthorityRouteRefusal(message, route.endpoint) - - certificate = route.certificate - if certificate is None or is_blank_csharp(certificate.signature): - if local: - return VerifiedRoute(route, None, True) - raise refuse("Remote CultMesh routes require an Odin-signed authority certificate.") - if not is_protected_endpoint(route.endpoint) and not local: - raise refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection.") - root = self._roots.get(certificate.odin_key_id) - if root is None: - raise refuse(f"Odin key '{certificate.odin_key_id}' is not trusted by this consumer.") - if not (certificate.issued_at_unix_milliseconds <= now_unix_milliseconds < certificate.expires_at_unix_milliseconds): - raise refuse("The Odin route certificate is not currently valid.") - try: - signature = _b64_strict(certificate.signature) - except (binascii.Error, ValueError): - raise refuse("The Odin route signature is not base64.") from None - if len(signature) != 64: - raise refuse("The Odin route signature is not IEEE P1363 P-256.") - if not root.verify(canonical_route(verse_id, route), certificate.signature): - raise refuse("The Odin route certificate signature is invalid.") - return VerifiedRoute(route, certificate.provider_key, False) - - -def verify_session_proof( - request: SessionOpen, - response: SessionAccepted, - verse_id: str, - route: AuthorityRoute, - trust: AuthorityTrustPolicy, - now_unix_milliseconds: int, -) -> bool: - """Verifies the Odin route certificate (raising) and the provider nonce proof.""" - verified = trust.verify(verse_id, route, now_unix_milliseconds) - if verified.is_local_development: - return True - key = verified.provider_key - if ( - key is None - or response.provider_key_id != key.key_id - or response.client_nonce != request.client_nonce - or is_blank_csharp(response.provider_signature) - ): - return False - return key.verify(canonical_session(request, route.endpoint), response.provider_signature) # type: ignore[arg-type] diff --git a/packages/cultmesh-py/src/cultmesh_py/daemon.py b/packages/cultmesh-py/src/cultmesh_py/daemon.py index cdc0a0ea..5b127008 100644 --- a/packages/cultmesh-py/src/cultmesh_py/daemon.py +++ b/packages/cultmesh-py/src/cultmesh_py/daemon.py @@ -13,7 +13,6 @@ from cultnet_py import CultNetSimulationConsensusOptions, CultNetSimulationObser from .facade import CultMesh from .server import CultMeshLocalServer -from .write_authority import CultMeshGrantAllWrites from cultnet_py.cultmesh_contracts import ( CultMeshPeerCard, 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("--verse-id") 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") args = parser.parse_args(argv) @@ -119,7 +117,6 @@ def start_server(args: argparse.Namespace, parser: argparse.ArgumentParser) -> C display_name=args.display_name, max_snapshot_documents=args.max_snapshot_documents, max_snapshot_bytes=args.max_snapshot_bytes, - write_authority=CultMeshGrantAllWrites() if args.allow_peer_writes else None, ) except ValueError as error: # The server owns its option rules; a limit it refuses is a usage error, not a crash. diff --git a/packages/cultmesh-py/src/cultmesh_py/facade.py b/packages/cultmesh-py/src/cultmesh_py/facade.py index 932d704a..84bea2c8 100644 --- a/packages/cultmesh-py/src/cultmesh_py/facade.py +++ b/packages/cultmesh-py/src/cultmesh_py/facade.py @@ -35,7 +35,6 @@ from .node import ( from .server import CULTMESH_RUDP_DEFAULT_MAX_FRAGMENT_BYTES, CultMeshLocalServer from .session import CultMeshGameSession, CultMeshGameSessionOptions from .simulation import CultMeshSimulationFactCommitter -from .write_authority import CultMeshRemoteWriteAuthority from cultnet_py.cultmesh_contracts import ( AuthorityLeaseVerifier, CultMeshAuthorityLeaseCatalog, @@ -401,7 +400,6 @@ class CultMesh: verse_catalog: CultMeshVerseCatalog | None = None, peer_catalog: CultMeshPeerCatalog | None = None, observation_hub: CultNetSimulationObservationHub | None = None, - write_authority: CultMeshRemoteWriteAuthority | None = None, host: str = "127.0.0.1", port: int = 0, display_name: str | None = None, @@ -418,7 +416,6 @@ class CultMesh: verse_catalog=verse_catalog or CultMeshVerseCatalog(), peer_catalog=peer_catalog or CultMeshPeerCatalog(), observation_hub=observation_hub, - write_authority=write_authority, host=host, port=port, display_name=display_name, diff --git a/packages/cultmesh-py/src/cultmesh_py/node.py b/packages/cultmesh-py/src/cultmesh_py/node.py index cee102f0..d251a030 100644 --- a/packages/cultmesh-py/src/cultmesh_py/node.py +++ b/packages/cultmesh-py/src/cultmesh_py/node.py @@ -650,29 +650,6 @@ class CultMeshDatabase: self._append_shard_log_delete(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: prepared = self._raw_put_envelope(message) if prepared is None: diff --git a/packages/cultmesh-py/src/cultmesh_py/server.py b/packages/cultmesh-py/src/cultmesh_py/server.py index ab5267a7..e50def4d 100644 --- a/packages/cultmesh-py/src/cultmesh_py/server.py +++ b/packages/cultmesh-py/src/cultmesh_py/server.py @@ -1,6 +1,5 @@ from __future__ import annotations -import errno import logging import socket import threading @@ -11,9 +10,6 @@ from typing import Any import msgpack from cultnet_py import ( - error_message, - message_invalid_error, - unsupported_schema_version_error, CultNetRawSnapshotResponse, CultNetSimulationObservationHub, CultNetRudpPacket, @@ -31,12 +27,6 @@ from cultnet_py import ( ) 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 # request's messageId as given, an empty one included, so no response it sends is smaller. @@ -60,17 +50,6 @@ from cultnet_py.cultmesh_contracts import ( _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): """A reply the peer's reliable queue cannot take. The peer's session ends, and the peer is @@ -100,10 +79,6 @@ class _DatabaseSubscription: class _RudpPeerConnection: session: CultNetRudpSession 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 @@ -112,9 +87,6 @@ class CultMeshLocalServer: verse_catalog: CultMeshVerseCatalog = field(default_factory=CultMeshVerseCatalog) peer_catalog: CultMeshPeerCatalog = field(default_factory=CultMeshPeerCatalog) 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" port: int = 0 display_name: str | None = None @@ -185,49 +157,26 @@ class CultMeshLocalServer: def start(self) -> "CultMeshLocalServer": if self._socket is not None: 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) self._socket = server self.port = int(server.getsockname()[1]) self._stop.clear() self._thread = threading.Thread(target=self._accept_loop, daemon=True) 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) self._rudp_socket = rudp_socket self._rudp_thread = threading.Thread(target=self._rudp_loop, daemon=True) self._rudp_thread.start() 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: self._stop.set() if self._rudp_socket is not None: @@ -280,7 +229,12 @@ class CultMeshLocalServer: if schema_version == "cultnet.simulation_observation.v0": candidates = self._candidate_responses(message) return candidates[0] if candidates else None - return unsupported_schema_version_error(schema_version).to_wire() + return self._error_response( + f"Unsupported CultNet message schema: {schema_version!r}.", + message_id=str(message.get("messageId") or ""), + code="unsupported_schema_version", + details={"schemaVersion": schema_version}, + ) def _accept_loop(self) -> None: while not self._stop.is_set(): @@ -288,15 +242,14 @@ class CultMeshLocalServer: if server is None: return try: - client, address = server.accept() + client, _ = server.accept() except TimeoutError: continue except OSError: return - peer = CultMeshServerPeer(f"{address[0]}:{address[1]}", "tcp") - threading.Thread(target=self._handle_connection, args=(client, peer), daemon=True).start() + threading.Thread(target=self._handle_connection, args=(client,), daemon=True).start() - def _handle_connection(self, client: socket.socket, peer: CultMeshServerPeer) -> None: + def _handle_connection(self, client: socket.socket) -> None: with client: stream = client.makefile("rwb") transport = TcpFramedTransportConnection( @@ -318,7 +271,7 @@ class CultMeshLocalServer: if not isinstance(message, dict): continue 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 # 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 @@ -360,7 +313,6 @@ class CultMeshLocalServer: if packet.connection_id != self.rudp_connection_id: continue - self._end_answered_peers(rudp_socket, peers) now_ms = _now_ms() peer = peers.get(remote_addr) if packet.packet_type == CultNetRudpPacketType.CONNECT: @@ -409,31 +361,13 @@ class CultMeshLocalServer: for delivered in result.delivered: if delivered.channel_id != "schema": 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: message = msgpack.unpackb(delivered.payload, raw=False) except (msgpack.ExtraData, msgpack.FormatError, msgpack.StackError, ValueError): - message = None - if isinstance(message, dict): - peer.undecodable_frames_in_a_row = 0 - responses = self._handle_connection_message( - 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.")] + continue + if not isinstance(message, dict): + continue + responses = self._handle_connection_message(message, peer.subscriptions, over_rudp=True) for response in responses: if unsendable is None: unsendable = self._send_rudp_schema_frame( @@ -443,11 +377,6 @@ class CultMeshLocalServer: msgpack.packb(response, use_bin_type=True), 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 # 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 @@ -478,26 +407,11 @@ class CultMeshLocalServer: if unsendable is not None: 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( self, rudp_socket: socket.socket, peers: dict[tuple[str, int], _RudpPeerConnection], ) -> None: - self._end_answered_peers(rudp_socket, peers) now_ms = _now_ms() for remote_addr, peer in list(peers.items()): for packet in peer.session.due_resends(now_ms): @@ -609,7 +523,6 @@ class CultMeshLocalServer: subscriptions: dict[str, _DatabaseSubscription], *, over_rudp: bool = False, - peer: CultMeshServerPeer | None = None, ) -> list[dict[str, Any]]: schema_version = message.get("schemaVersion") if schema_version == "cultnet.database_subscribe.v0": @@ -633,9 +546,9 @@ class CultMeshLocalServer: subscriptions.pop(str(message.get("subscriptionId") or ""), None) return [] 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": - return self._handle_raw_delete(message, subscriptions, peer=peer) + return self._handle_raw_delete(message, subscriptions) if schema_version == "cultnet.simulation_observation.v0": return self._candidate_responses(message) response = self.handle_message(message) @@ -647,27 +560,14 @@ class CultMeshLocalServer: subscriptions: dict[str, _DatabaseSubscription], *, over_rudp: bool = False, - peer: CultMeshServerPeer | None = None, ) -> list[dict[str, Any]]: document_record = message.get("document") if not isinstance(document_record, dict): - return [self._message_invalid(message, "document", "Raw put messages must contain a document map.")] - malformed_shard = self._malformed_shard_fields(message) - if malformed_shard is not None: - return [malformed_shard] - # 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] + return [self._error_response( + "Raw put messages must contain a document map.", + message_id=str(message.get("messageId") or ""), + code="malformed_document_put", + )] unservable = self._unservable_put(message, over_rudp=over_rudp) if unservable is not None: return [unservable] @@ -679,7 +579,11 @@ class CultMeshLocalServer: # 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. _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: return [self._error_response( "Raw put message did not apply to a registered document.", @@ -698,22 +602,15 @@ class CultMeshLocalServer: self, message: dict[str, Any], subscriptions: dict[str, _DatabaseSubscription], - *, - peer: CultMeshServerPeer | None = None, ) -> list[dict[str, Any]]: schema_id = str(message.get("schemaId") or "") record_key = str(message.get("recordKey") or "") if not schema_id or not record_key: - return [self._message_invalid( - message, "schemaId" if not schema_id else "recordKey", "Raw delete messages must contain schemaId and recordKey.", + return [self._error_response( + "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) if change is None: return [self._error_response( @@ -723,70 +620,6 @@ class CultMeshLocalServer: )] 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( self, message: dict[str, Any], @@ -974,7 +807,11 @@ class CultMeshLocalServer: and isinstance(document_record.get("recordKey"), str) 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: return None alone = self.node.database.raw_put_served_alone(message, message_id=_SHORTEST_SERVED_MESSAGE_ID) diff --git a/packages/cultmesh-py/src/cultmesh_py/session.py b/packages/cultmesh-py/src/cultmesh_py/session.py index 4126056d..c02696f0 100644 --- a/packages/cultmesh-py/src/cultmesh_py/session.py +++ b/packages/cultmesh-py/src/cultmesh_py/session.py @@ -27,7 +27,6 @@ from cultnet_py.cultmesh_contracts import CultMeshAuthorityLeaseCatalog, CultMes if TYPE_CHECKING: from .server import CultMeshLocalServer - from .write_authority import CultMeshRemoteWriteAuthority @dataclass(frozen=True) @@ -84,7 +83,6 @@ class CultMeshGameSession: def serve( self, *, - write_authority: CultMeshRemoteWriteAuthority | None = None, host: str = "127.0.0.1", port: int = 0, display_name: str | None = None, @@ -98,7 +96,6 @@ class CultMeshGameSession: verse_catalog=self.verse_catalog if self._serve_verse_discovery else CultMeshVerseCatalog(), peer_catalog=self.peer_catalog if self._serve_peer_exchange else CultMeshPeerCatalog(), observation_hub=self.observation_hub if self._serve_simulation_observations else None, - write_authority=write_authority, host=host, port=port, display_name=display_name, diff --git a/packages/cultmesh-py/src/cultmesh_py/write_authority.py b/packages/cultmesh-py/src/cultmesh_py/write_authority.py deleted file mode 100644 index 5902827b..00000000 --- a/packages/cultmesh-py/src/cultmesh_py/write_authority.py +++ /dev/null @@ -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() diff --git a/packages/cultmesh-py/tests/test_authority_route_vectors.py b/packages/cultmesh-py/tests/test_authority_route_vectors.py deleted file mode 100644 index 3f116e2d..00000000 --- a/packages/cultmesh-py/tests/test_authority_route_vectors.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Judges `contracts/cultmesh/authority-route-vectors*.json` in Python. - -`authority-route-vectors.json` is written by the C# reference (signatures are -captured C# bytes); the `.ts-signed.json` file is signed by TypeScript over awkward -inputs. Each negative case fails when the matching check is removed from -`cultmesh_py.authority`. -""" - -from __future__ import annotations - -import base64 -import json -import unittest -from dataclasses import replace -from pathlib import Path - -from cryptography.hazmat.primitives.asymmetric import ec - -from cultmesh_py.authority import ( - AUTHENTICATED_REMOTE, - LOCAL_DEVELOPMENT, - AuthorityRoute, - AuthorityRouteRefusal, - AuthorityTrustPolicy, - P256PublicKey, - RouteCertificate, - SessionAccepted, - SessionOpen, - canonical_route, - verify_session_proof, -) - -CONTRACTS = Path(__file__).resolve().parents[3] / "contracts" / "cultmesh" - - -def _key(element: dict) -> P256PublicKey: - return P256PublicKey(element["keyId"], element["x"], element["y"]) - - -class Vector: - def __init__(self, name: str) -> None: - doc = json.loads((CONTRACTS / name).read_text(encoding="utf-8")) - self.now = doc["nowUnixMilliseconds"] - self.root = _key(doc["odinRoot"]) - r = doc["route"] - self.verse_id = r["verseId"] - c = r["certificate"] - self.certificate = RouteCertificate( - _key(c["providerKey"]), c["odinKeyId"], c["issuedAtUnixMilliseconds"], - c["expiresAtUnixMilliseconds"], c["signature"], - ) - self.route = AuthorityRoute( - r["authorityRuntimeId"], r["endpoint"], r["protocolIds"], r["priority"], - r["generation"], self.certificate, - ) - p = doc["sessionProof"] - q = p["request"] - self.request = SessionOpen( - q["clientNonce"], q["messageId"], q["sourceRuntimeId"], q["verseId"], - q["authorityRuntimeId"], q["protocolId"], q["routeGeneration"], - ) - self.accepted = SessionAccepted(q["clientNonce"], p["providerKeyId"], p["providerSignature"]) - self.trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [self.root]) - - def with_certificate(self, **changes) -> AuthorityRoute: - return replace(self.route, certificate=replace(self.certificate, **changes)) - - -def refusal(test: unittest.TestCase, fn) -> str: - with test.assertRaises(AuthorityRouteRefusal) as raised: - fn() - test.assertEqual("authentication", raised.exception.reason) - return raised.exception.message - - -class AuthorityRouteVectorTests(unittest.TestCase): - def test_cs_written_vector_verifies_route_and_session_proof(self) -> None: - v = Vector("authority-route-vectors.json") - v.trust.validate(v.verse_id, v.route, v.now) - self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now)) - - def test_ts_signed_vector_verifies_and_tamper_is_refused(self) -> None: - v = Vector("authority-route-vectors.ts-signed.json") - v.trust.validate(v.verse_id, v.route, v.now) - self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, v.route, v.trust, v.now)) - tampered = replace(v.route, priority=v.route.priority + 1) - self.assertEqual( - "The Odin route certificate signature is invalid.", - refusal(self, lambda: v.trust.validate(v.verse_id, tampered, v.now)), - ) - - def test_canonical_route_is_length_prefixed_and_sorted(self) -> None: - v = Vector("authority-route-vectors.json") - data = canonical_route(v.verse_id, v.route) - self.assertTrue(data.startswith(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria")) - self.assertIn(b"cultmesh.content.v1\x1fcultmesh.documents.v1", data) - - def test_tampered_payload_fields_are_refused(self) -> None: - v = Vector("authority-route-vectors.json") - invalid = "The Odin route certificate signature is invalid." - for tampered, verse in ( - (replace(v.route, priority=8), v.verse_id), - (replace(v.route, endpoint="wss://evil.example/mesh"), v.verse_id), - (replace(v.route, protocol_ids=("cultmesh.documents.v1",)), v.verse_id), - (replace(v.route, generation="other"), v.verse_id), - (v.with_certificate(expires_at_unix_milliseconds=v.certificate.expires_at_unix_milliseconds + 1), v.verse_id), - (v.route, "other-verse"), - ): - self.assertEqual(invalid, refusal(self, lambda: v.trust.validate(verse, tampered, v.now))) - - def test_wrong_root_key_is_refused(self) -> None: - v = Vector("authority-route-vectors.json") - numbers = ec.generate_private_key(ec.SECP256R1()).public_key().public_numbers() - impostor = P256PublicKey( - v.root.key_id, - base64.b64encode(numbers.x.to_bytes(32, "big")).decode(), - base64.b64encode(numbers.y.to_bytes(32, "big")).decode(), - ) - trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [impostor]) - self.assertEqual( - "The Odin route certificate signature is invalid.", - refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)), - ) - - def test_untrusted_odin_key_id_is_refused(self) -> None: - v = Vector("authority-route-vectors.json") - trust = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, [replace(v.root, key_id="someone-else")]) - self.assertEqual( - "Odin key 'odin-vector' is not trusted by this consumer.", - refusal(self, lambda: trust.validate(v.verse_id, v.route, v.now)), - ) - - def test_expired_and_not_yet_valid_are_refused(self) -> None: - v = Vector("authority-route-vectors.json") - window = "The Odin route certificate is not currently valid." - expires = v.certificate.expires_at_unix_milliseconds - issued = v.certificate.issued_at_unix_milliseconds - v.trust.validate(v.verse_id, v.route, expires - 1) - v.trust.validate(v.verse_id, v.route, issued) - self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, expires))) - self.assertEqual(window, refusal(self, lambda: v.trust.validate(v.verse_id, v.route, issued - 1))) - - def test_unsigned_and_unprotected_routes_are_refused(self) -> None: - v = Vector("authority-route-vectors.json") - unsigned = replace(v.route, certificate=None) - self.assertEqual( - "Remote CultMesh routes require an Odin-signed authority certificate.", - refusal(self, lambda: v.trust.validate(v.verse_id, unsigned, v.now)), - ) - blank = v.with_certificate(signature=" ") - self.assertEqual( - "Remote CultMesh routes require an Odin-signed authority certificate.", - refusal(self, lambda: v.trust.validate(v.verse_id, blank, v.now)), - ) - plain = replace(v.route, endpoint="ws://provider.example/mesh") - self.assertEqual( - "Authenticated remote CultMesh routes require TLS or QUIC channel protection.", - refusal(self, lambda: v.trust.validate(v.verse_id, plain, v.now)), - ) - - def test_local_development_admits_only_unsigned_loopback(self) -> None: - v = Vector("authority-route-vectors.json") - dev = AuthorityTrustPolicy(LOCAL_DEVELOPMENT, []) - loop = AuthorityRoute("a", "ws://127.0.0.1:9/mesh") - dev.validate(v.verse_id, loop, v.now) - self.assertTrue(verify_session_proof(v.request, v.accepted, v.verse_id, loop, dev, v.now)) - for endpoint in ("ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"): - refusal(self, lambda e=endpoint: dev.validate(v.verse_id, AuthorityRoute("a", e), v.now)) - authenticated = AuthorityTrustPolicy(AUTHENTICATED_REMOTE, []) - refusal(self, lambda: authenticated.validate(v.verse_id, loop, v.now)) - - def test_malformed_signatures_get_their_own_refusals(self) -> None: - v = Vector("authority-route-vectors.json") - not_base64 = v.with_certificate(signature="!!not base64!!") - self.assertEqual( - "The Odin route signature is not base64.", - refusal(self, lambda: v.trust.validate(v.verse_id, not_base64, v.now)), - ) - short = v.with_certificate(signature=base64.b64encode(b"x" * 63).decode()) - self.assertEqual( - "The Odin route signature is not IEEE P1363 P-256.", - refusal(self, lambda: v.trust.validate(v.verse_id, short, v.now)), - ) - - def test_high_s_signature_verifies_as_it_does_in_dotnet(self) -> None: - v = Vector("authority-route-vectors.json") - order = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 - raw = base64.b64decode(v.certificate.signature) - s = int.from_bytes(raw[32:], "big") - flipped = raw[:32] + (order - s).to_bytes(32, "big") - high = v.with_certificate(signature=base64.b64encode(flipped).decode()) - v.trust.validate(v.verse_id, high, v.now) - - def test_session_proof_rejects_replay_wrong_key_and_forgery(self) -> None: - v = Vector("authority-route-vectors.json") - replayed = replace(v.request, client_nonce="replayed") - accepted = replace(v.accepted, client_nonce="replayed") - self.assertFalse(verify_session_proof(replayed, accepted, v.verse_id, v.route, v.trust, v.now)) - self.assertFalse(verify_session_proof( - v.request, replace(v.accepted, provider_key_id="other"), v.verse_id, v.route, v.trust, v.now)) - self.assertFalse(verify_session_proof( - v.request, replace(v.accepted, client_nonce="other"), v.verse_id, v.route, v.trust, v.now)) - self.assertFalse(verify_session_proof( - v.request, replace(v.accepted, provider_signature=""), v.verse_id, v.route, v.trust, v.now)) - raw = bytearray(base64.b64decode(v.accepted.provider_signature)) - raw[0] ^= 1 - forged = replace(v.accepted, provider_signature=base64.b64encode(bytes(raw)).decode()) - self.assertFalse(verify_session_proof(v.request, forged, v.verse_id, v.route, v.trust, v.now)) - moved = replace(v.request, protocol_id="cultmesh.content.v1") - self.assertFalse(verify_session_proof(moved, v.accepted, v.verse_id, v.route, v.trust, v.now)) - # An expired route fails closed even with a good proof. - refusal(self, lambda: verify_session_proof( - v.request, v.accepted, v.verse_id, v.route, v.trust, v.certificate.expires_at_unix_milliseconds)) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultmesh-py/tests/test_client_write_completion.py b/packages/cultmesh-py/tests/test_client_write_completion.py deleted file mode 100644 index 0f9d0c70..00000000 --- a/packages/cultmesh-py/tests/test_client_write_completion.py +++ /dev/null @@ -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() diff --git a/packages/cultmesh-py/tests/test_cultmesh.py b/packages/cultmesh-py/tests/test_cultmesh.py index d96c4ab2..67f5fe36 100644 --- a/packages/cultmesh-py/tests/test_cultmesh.py +++ b/packages/cultmesh-py/tests/test_cultmesh.py @@ -24,9 +24,7 @@ from cultcache_py import ( define_document_type, ) 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.write_authority import CultMeshRemoteWriteDecision from cultnet_py import ( compute_simulation_claim_hash, CultNetClientAuthorityScope, @@ -70,7 +68,7 @@ from cultnet_py import ( snapshot_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 ( CultMesh, CultMeshDatabase, @@ -1633,7 +1631,7 @@ class CultMeshTests(unittest.TestCase): ) primary = CultMesh.create_node(runtime_id="forward-primary") primary.database.register_document(document) - server = CultMesh.serve_node(primary, write_authority=CultMeshGrantAllWrites()) + server = CultMesh.serve_node(primary) try: shard = CultNetShardDescriptor( shard_id="forward", @@ -3017,7 +3015,7 @@ class CultMeshTests(unittest.TestCase): source.database.put_raw_message(document, "note:live", CanonicalNote("initial")) target = CultMesh.create_node(runtime_id="subscription-alias-target") target.database.register_document(document) - server = CultMesh.serve_node(source, write_authority=CultMeshGrantAllWrites()) + server = CultMesh.serve_node(source) try: 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: @@ -3535,7 +3533,6 @@ class CultMeshTests(unittest.TestCase): sys.executable, "-m", "cultmesh_py.daemon", - "--allow-peer-writes", "--runtime-id", "live-daemon-peer", "--port", @@ -3726,7 +3723,6 @@ class CultMeshTests(unittest.TestCase): sys.executable, "-m", "cultmesh_py.daemon", - "--allow-peer-writes", "--runtime-id", "forwarder-daemon-peer", "--port", @@ -4002,7 +3998,6 @@ class CultMeshTests(unittest.TestCase): server = CultMesh.serve_node( node, verse_catalog=verses, - write_authority=CultMeshGrantAllWrites(), peer_catalog=peers, observation_hub=observation_hub, display_name="Mesh Server", @@ -4279,7 +4274,7 @@ class CultMeshTests(unittest.TestCase): node = CultMesh.create_node(runtime_id="mesh-alias-sub-server") node.database.register_document(document) - server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) + server = CultMesh.serve_node(node) try: raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) with raw_client.subscribe_database( @@ -4328,7 +4323,7 @@ class CultMeshTests(unittest.TestCase): shard_epoch=1, ) - server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) + server = CultMesh.serve_node(node) try: raw_client = CultMesh.create_client("127.0.0.1", server.port, timeout_seconds=2.0) with raw_client.subscribe_database( @@ -4425,7 +4420,7 @@ class CultMeshTests(unittest.TestCase): def served_alone_bytes(key: str, body_length: int) -> int: node = CultMesh.create_node(runtime_id="mesh-served-bound-probe") 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: response, payload = exchange(server, put("probe", key, body_length), empty_id_snapshot) finally: @@ -4439,7 +4434,7 @@ class CultMeshTests(unittest.TestCase): node = CultMesh.create_node(runtime_id="mesh-served-bound") 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: refused, _ = exchange(server, put("put-over", "note:over", 101)) stored_after_refusal = node.database.get(document, "note:over") @@ -4503,7 +4498,7 @@ class CultMeshTests(unittest.TestCase): document = self._rudp_bound_note() node = CultMesh.create_node(runtime_id="mesh-rudp-large") node.database.register_document(document) - server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) + server = CultMesh.serve_node(node) try: served, payload = self._rudp_exchange( server, @@ -4529,7 +4524,7 @@ class CultMeshTests(unittest.TestCase): def served_alone_bytes(body_length: int) -> int: node = CultMesh.create_node(runtime_id="mesh-rudp-bound-probe") node.database.register_document(document) - server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) + server = CultMesh.serve_node(node) try: _, payload = self._rudp_exchange( 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.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: refusals = [ 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") node = CultMesh.create_node(runtime_id="mesh-rudp-wide") 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: transport = create_rudp_schema_transport( 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() node = CultMesh.create_node(runtime_id="mesh-rudp-malformed") node.database.register_document(document) - server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) + server = CultMesh.serve_node(node) try: refusals = [] for field_name, value in ( @@ -4885,146 +4880,11 @@ class CultMeshTests(unittest.TestCase): for message_id, refusal in refusals: self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id) self.assertEqual(refusal["messageId"], message_id) - self.assertEqual(refusal["code"], "message_invalid", message_id) - self.assertEqual(refusal["details"]["field"], "document", message_id) + self.assertEqual(refusal["code"], "malformed_document_put", message_id) self.assertEqual(after["messageId"], "after") self.assertEqual(after["documents"], []) 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: # 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 @@ -5057,7 +4917,7 @@ class CultMeshTests(unittest.TestCase): node = CultMesh.create_node(runtime_id="mesh-unstorable-put") node.database.register_document(document) node.database.register_document(global_document) - server = CultMesh.serve_node(node, write_authority=CultMeshGrantAllWrites()) + server = CultMesh.serve_node(node) try: 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()) @@ -5079,8 +4939,7 @@ class CultMeshTests(unittest.TestCase): for message_id, refusal in rudp_refusals + tcp_refusals: self.assertEqual(refusal["schemaVersion"], "cultnet.error.v0", message_id) self.assertEqual(refusal["messageId"], message_id) - self.assertEqual(refusal["code"], "message_invalid", message_id) - self.assertEqual(refusal["details"]["field"], "document", message_id) + self.assertEqual(refusal["code"], "malformed_document_put", message_id) self.assertNotIn("note:ok", refusal["error"], message_id) self.assertEqual(len(tcp_refusals), 5) self.assertEqual(rudp_after["schemaVersion"], "cultnet.hello.v0") @@ -5189,18 +5048,17 @@ class CultMeshTests(unittest.TestCase): finally: server.stop() + self.assertEqual(unsupported["messageId"], "bad-schema") self.assertEqual(unsupported["code"], "unsupported_schema_version") - self.assertEqual(unsupported["details"]["field"], "schemaVersion") - self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0") - self.assertIn("unsupported_schema_version", unsupported["error"]) + self.assertEqual(unsupported["details"]["schemaVersion"], "cultnet.nope.v0") + self.assertIn("Unsupported CultNet message schema", unsupported["error"]) self.assertEqual(malformed_put["messageId"], "bad-put") - self.assertEqual(malformed_put["code"], "message_invalid") - self.assertEqual(malformed_put["details"]["field"], "document") + self.assertEqual(malformed_put["code"], "malformed_document_put") self.assertEqual(malformed_delete["messageId"], "bad-delete") - self.assertEqual(malformed_delete["code"], "message_invalid") - self.assertEqual(malformed_delete["details"]["field"], "recordKey") + self.assertEqual(malformed_delete["code"], "malformed_document_delete") + self.assertEqual(unsupported_v1["messageId"], "bad-v1-selection") self.assertEqual(unsupported_v1["code"], "unsupported_schema_version") - self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1") + self.assertEqual(unsupported_v1["details"]["schemaVersion"], "cultnet.snapshot_request.v1") def test_cultmesh_authority_lease_requires_live_matching_lease(self) -> None: peer = CultMeshPeerCard( diff --git a/packages/cultmesh-py/tests/test_remote_write_authority.py b/packages/cultmesh-py/tests/test_remote_write_authority.py deleted file mode 100644 index cafb163f..00000000 --- a/packages/cultmesh-py/tests/test_remote_write_authority.py +++ /dev/null @@ -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() diff --git a/packages/cultmesh-py/tests/test_rudp_server_generation.py b/packages/cultmesh-py/tests/test_rudp_server_generation.py index 8cb7586b..cd84c908 100644 --- a/packages/cultmesh-py/tests/test_rudp_server_generation.py +++ b/packages/cultmesh-py/tests/test_rudp_server_generation.py @@ -10,7 +10,7 @@ from dataclasses import dataclass import msgpack 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 cultnet_py import ( CultNetRudpSendOptions, @@ -49,7 +49,7 @@ class RudpServerGenerationTests(unittest.TestCase): node = create_node(runtime_id="rudp-generation") node.database.register_document(NOTE) # 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.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.sock.bind(("127.0.0.1", 0)) diff --git a/packages/cultmesh-py/tests/test_server_bind.py b/packages/cultmesh-py/tests/test_server_bind.py deleted file mode 100644 index 89444b47..00000000 --- a/packages/cultmesh-py/tests/test_server_bind.py +++ /dev/null @@ -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() diff --git a/packages/cultmesh-rs/Cargo.lock b/packages/cultmesh-rs/Cargo.lock index de7c506e..d056b52e 100644 --- a/packages/cultmesh-rs/Cargo.lock +++ b/packages/cultmesh-rs/Cargo.lock @@ -67,12 +67,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.22.1" @@ -167,18 +161,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -232,14 +214,11 @@ name = "cultmesh-rs" version = "0.2.0" dependencies = [ "anyhow", - "base64", "cultcache-rs", "cultnet-rs", - "p256", "pretty_assertions", "rmp-serde", "serde", - "serde_json", "tempfile", ] @@ -319,25 +298,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", - "const-oid", "crypto-common", "subtle", ] -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - [[package]] name = "ed25519" version = "2.2.3" @@ -362,25 +326,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "errno" version = "0.3.14" @@ -397,16 +342,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "fiat-crypto" version = "0.2.9" @@ -461,7 +396,6 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -508,17 +442,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "hmac" version = "0.12.1" @@ -632,18 +555,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -691,15 +602,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -787,16 +689,6 @@ dependencies = [ "libc", ] -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac", - "subtle", -] - [[package]] name = "rmp" version = "0.8.15" @@ -855,20 +747,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "semver" version = "1.0.28" @@ -962,7 +840,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", "rand_core 0.6.4", ] diff --git a/packages/cultmesh-rs/Cargo.toml b/packages/cultmesh-rs/Cargo.toml index 984b74ab..af75e419 100644 --- a/packages/cultmesh-rs/Cargo.toml +++ b/packages/cultmesh-rs/Cargo.toml @@ -13,12 +13,9 @@ path = "src/lib.rs" anyhow = "1" cultcache-rs = { path = "../cultcache-rs" } cultnet-rs = { path = "../cultnet-rs" } -base64 = "0.22" -p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] } serde = { version = "1", features = ["derive"] } [dev-dependencies] pretty_assertions = "1.4.1" rmp-serde = "1" -serde_json = "1" tempfile = "3" diff --git a/packages/cultmesh-rs/src/authority.rs b/packages/cultmesh-rs/src/authority.rs deleted file mode 100644 index f4368884..00000000 --- a/packages/cultmesh-rs/src/authority.rs +++ /dev/null @@ -1,424 +0,0 @@ -//! CultMesh authority route verification. -//! -//! Port of `src/GameCult.Mesh/CultMeshAuthorityProof.cs` (the reference) and of -//! the route constructors in `CultMeshVerse.cs`. Rule for rule, in the same -//! order, the same input yields the same refusal text as -//! `CultMeshAuthorityTrustPolicy.Verify`: unsigned, channel protection, root -//! lookup, validity window, signature. -//! -//! Shared vectors: `contracts/cultmesh/authority-route-vectors.json` (C# written). - -use base64::alphabet::STANDARD as STANDARD_ALPHABET; -use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; -use base64::Engine; -use p256::ecdsa::signature::Verifier; -use p256::ecdsa::{Signature, VerifyingKey}; -use std::collections::BTreeMap; -use std::fmt; - -const ROUTE_DOMAIN: &str = "gamecult.cultmesh.route-certificate.v1"; -const SESSION_DOMAIN: &str = "gamecult.cultmesh.session-proof.v1"; - -// `Convert.FromBase64String`: standard alphabet, padded, tolerant of trailing bits. -const DOTNET_BASE64: GeneralPurpose = GeneralPurpose::new( - &STANDARD_ALPHABET, - GeneralPurposeConfig::new() - .with_decode_padding_mode(DecodePaddingMode::RequireCanonical) - .with_decode_allow_trailing_bits(true), -); - -fn base64_decode(value: &str) -> Option> { - let stripped: String = value.chars().filter(|c| !matches!(c, ' ' | '\t' | '\r' | '\n')).collect(); - DOTNET_BASE64.decode(stripped).ok() -} - -/// .NET `char.IsWhiteSpace` is the Unicode White_Space property, as is `char::is_whitespace`. -fn trim(value: &str) -> &str { - value.trim_matches(char::is_whitespace) -} - -fn is_blank(value: &str) -> bool { - trim(value).is_empty() -} - -fn require(value: &str, name: &str) -> Result { - if is_blank(value) { - Err(format!("Value must be non-empty. (Parameter '{name}')")) - } else { - Ok(trim(value).to_owned()) - } -} - -/// A route or proof the consumer's trust policy refuses. Mirrors -/// `CultMeshSessionFailure` with reason `Authentication`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthorityRouteRefusal { - pub message: String, - pub endpoint: String, -} - -impl AuthorityRouteRefusal { - pub const REASON: &'static str = "authentication"; -} - -impl fmt::Display for AuthorityRouteRefusal { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.message) - } -} - -impl std::error::Error for AuthorityRouteRefusal {} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct P256PublicKey { - pub key_id: String, - pub x: String, - pub y: String, -} - -impl P256PublicKey { - pub fn new(key_id: &str, x: &str, y: &str) -> Result { - let key = Self { - key_id: require(key_id, "keyId")?, - x: require(x, "x")?, - y: require(y, "y")?, - }; - for (name, value) in [("x", &key.x), ("y", &key.y)] { - if base64_decode(value).is_none() { - return Err(format!("Coordinate must be base64. (Parameter '{name}')")); - } - } - Ok(key) - } - - /// IEEE P1363 (r || s) ECDSA-SHA256 verification; false on any malformation. - pub fn verify(&self, payload: &[u8], signature_base64: &str) -> bool { - let (Some(x), Some(y), Some(signature)) = ( - base64_decode(&self.x), - base64_decode(&self.y), - base64_decode(signature_base64), - ) else { - return false; - }; - if x.len() != 32 || y.len() != 32 || signature.len() != 64 { - return false; - } - let mut sec1 = Vec::with_capacity(65); - sec1.push(4); - sec1.extend_from_slice(&x); - sec1.extend_from_slice(&y); - let (Ok(key), Ok(signature)) = ( - VerifyingKey::from_sec1_bytes(&sec1), - Signature::from_slice(&signature), - ) else { - return false; - }; - key.verify(payload, &signature).is_ok() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RouteCertificate { - pub provider_key: P256PublicKey, - pub odin_key_id: String, - pub issued_at_unix_milliseconds: i64, - pub expires_at_unix_milliseconds: i64, - pub signature: String, -} - -impl RouteCertificate { - pub fn new( - provider_key: P256PublicKey, - odin_key_id: &str, - issued_at_unix_milliseconds: i64, - expires_at_unix_milliseconds: i64, - signature: &str, - ) -> Result { - if is_blank(odin_key_id) { - return Err("Odin key identity is required. (Parameter 'odinKeyId')".into()); - } - if issued_at_unix_milliseconds < 0 { - return Err("issuedAtUnixMilliseconds must not be negative.".into()); - } - if expires_at_unix_milliseconds <= issued_at_unix_milliseconds { - return Err("expiresAtUnixMilliseconds must be after issuedAtUnixMilliseconds.".into()); - } - Ok(Self { - provider_key, - odin_key_id: trim(odin_key_id).to_owned(), - issued_at_unix_milliseconds, - expires_at_unix_milliseconds, - signature: trim(signature).to_owned(), - }) - } -} - -/// `CultMeshAuthorityRoute`: construction cleans every field as the C# does. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AuthorityRoute { - pub authority_runtime_id: String, - pub endpoint: String, - pub protocol_ids: Vec, - pub priority: i32, - pub generation: String, - pub certificate: Option, -} - -impl AuthorityRoute { - pub fn new( - authority_runtime_id: &str, - endpoint: &str, - protocol_ids: &[&str], - priority: i32, - generation: Option<&str>, - certificate: Option, - ) -> Result { - let authority_runtime_id = require(authority_runtime_id, "authorityRuntimeId")?; - let endpoint = require(endpoint, "endpoint")?; - if priority < 0 { - return Err("priority must not be negative.".into()); - } - let mut protocols: Vec = protocol_ids - .iter() - .filter(|value| !is_blank(value)) - .map(|value| trim(value).to_owned()) - .collect(); - // StringComparer.Ordinal is UTF-16 code-unit order. - protocols.sort_by_key(|value| value.encode_utf16().collect::>()); - protocols.dedup(); - let generation = match generation { - Some(value) if !is_blank(value) => trim(value).to_owned(), - _ => format!("{authority_runtime_id}@{endpoint}"), - }; - Ok(Self { - authority_runtime_id, - endpoint, - protocol_ids: protocols, - priority, - generation, - certificate, - }) - } -} - -/// The fields of `CultMeshSessionOpenMessage` the session proof covers. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SessionOpen { - pub client_nonce: String, - pub message_id: String, - pub source_runtime_id: String, - pub verse_id: String, - pub authority_runtime_id: String, - pub protocol_id: String, - pub route_generation: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SessionAccepted { - pub client_nonce: String, - pub provider_key_id: String, - pub provider_signature: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TrustMode { - AuthenticatedRemote, - LocalDevelopment, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct VerifiedRoute { - pub route: AuthorityRoute, - pub provider_key: Option, - pub is_local_development: bool, -} - -pub fn canonical_fields(values: &[&str]) -> Vec { - let mut out = Vec::new(); - for value in values { - out.extend_from_slice(&(value.len() as u32).to_be_bytes()); - out.extend_from_slice(value.as_bytes()); - } - out -} - -pub fn canonical_route(verse_id: &str, route: &AuthorityRoute) -> Result, String> { - let certificate = route.certificate.as_ref().ok_or("Route certificate is required.")?; - let protocols = route.protocol_ids.join("\u{1f}"); - let priority = route.priority.to_string(); - let issued = certificate.issued_at_unix_milliseconds.to_string(); - let expires = certificate.expires_at_unix_milliseconds.to_string(); - Ok(canonical_fields(&[ - ROUTE_DOMAIN, - verse_id, - &route.authority_runtime_id, - &route.endpoint, - &protocols, - &priority, - &route.generation, - &certificate.provider_key.key_id, - &certificate.provider_key.x, - &certificate.provider_key.y, - &certificate.odin_key_id, - &issued, - &expires, - ])) -} - -pub fn canonical_session(request: &SessionOpen, endpoint: &str) -> Vec { - canonical_fields(&[ - SESSION_DOMAIN, - &request.client_nonce, - &request.message_id, - &request.source_runtime_id, - &request.verse_id, - &request.authority_runtime_id, - &request.protocol_id, - endpoint, - &request.route_generation, - ]) -} - -/// `(scheme, lowercase host)`, or `None` where `System.Uri` refuses the spelling. -/// A lenient parse could call a refused endpoint loopback or protected, so the -/// shape rule is the same strict one the TypeScript port applies. -fn split_endpoint(value: &str) -> Option<(String, String)> { - let raw = value.trim_matches(|c: char| c <= ' '); - if raw.contains('\\') { - return None; - } - let (scheme, rest) = raw.split_once("://")?; - let mut scheme_chars = scheme.chars(); - if !scheme_chars.next()?.is_ascii_alphabetic() - || !scheme_chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) - { - return None; - } - let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())]; - if authority.is_empty() { - return None; - } - let after_user = &authority[authority.rfind('@').map_or(0, |i| i + 1)..]; - let host = if after_user.starts_with('[') { - &after_user[..after_user.find(']').map_or(after_user.len(), |i| i + 1)] - } else { - after_user.split(':').next().unwrap_or("") - }; - if host.is_empty() || host.chars().any(|c| !('\u{21}'..='\u{7e}').contains(&c)) || host.ends_with('.') { - return None; - } - Some(( - scheme.to_ascii_lowercase(), - host.trim_matches(['[', ']']).to_ascii_lowercase(), - )) -} - -pub fn is_loopback_endpoint(endpoint: &str) -> bool { - let Some((_, host)) = split_endpoint(endpoint) else { - return false; - }; - let octets: Vec<&str> = host.split('.').collect(); - matches!(host.as_str(), "localhost" | "loopback" | "::1" | "::ffff:127.0.0.1") - || (octets.len() == 4 - && octets[0] == "127" - && octets[1..].iter().all(|o| (1..=3).contains(&o.len()) && o.bytes().all(|b| b.is_ascii_digit()))) -} - -pub fn is_protected_endpoint(endpoint: &str) -> bool { - split_endpoint(endpoint) - .is_some_and(|(scheme, _)| scheme == "wss" || scheme == "https" || scheme.contains("quic")) -} - -/// Consumer-owned policy. Fails closed unless a route is valid under it. -pub struct AuthorityTrustPolicy { - mode: TrustMode, - roots: BTreeMap, -} - -impl AuthorityTrustPolicy { - pub fn new(mode: TrustMode, odin_roots: Vec) -> Result { - let mut roots = BTreeMap::new(); - for root in odin_roots { - let key_id = root.key_id.clone(); - if roots.insert(key_id.clone(), root).is_some() { - return Err(format!( - "Odin root key id '{key_id}' is listed more than once in the trust policy." - )); - } - } - Ok(Self { mode, roots }) - } - - pub fn validate(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result<(), AuthorityRouteRefusal> { - self.verify(verse_id, route, now_unix_milliseconds).map(|_| ()) - } - - pub fn verify(&self, verse_id: &str, route: &AuthorityRoute, now_unix_milliseconds: i64) -> Result { - let refuse = |message: &str| AuthorityRouteRefusal { - message: message.to_owned(), - endpoint: route.endpoint.clone(), - }; - let local = self.mode == TrustMode::LocalDevelopment && is_loopback_endpoint(&route.endpoint); - let certificate = match &route.certificate { - Some(certificate) if !is_blank(&certificate.signature) => certificate, - _ if local => { - return Ok(VerifiedRoute { route: route.clone(), provider_key: None, is_local_development: true }); - } - _ => return Err(refuse("Remote CultMesh routes require an Odin-signed authority certificate.")), - }; - if !is_protected_endpoint(&route.endpoint) && !local { - return Err(refuse("Authenticated remote CultMesh routes require TLS or QUIC channel protection.")); - } - let Some(root) = self.roots.get(&certificate.odin_key_id) else { - return Err(refuse(&format!( - "Odin key '{}' is not trusted by this consumer.", - certificate.odin_key_id - ))); - }; - if now_unix_milliseconds < certificate.issued_at_unix_milliseconds - || now_unix_milliseconds >= certificate.expires_at_unix_milliseconds - { - return Err(refuse("The Odin route certificate is not currently valid.")); - } - let Some(signature) = base64_decode(&certificate.signature) else { - return Err(refuse("The Odin route signature is not base64.")); - }; - if signature.len() != 64 { - return Err(refuse("The Odin route signature is not IEEE P1363 P-256.")); - } - let canonical = canonical_route(verse_id, route).map_err(|message| refuse(&message))?; - if !root.verify(&canonical, &certificate.signature) { - return Err(refuse("The Odin route certificate signature is invalid.")); - } - Ok(VerifiedRoute { - route: route.clone(), - provider_key: Some(certificate.provider_key.clone()), - is_local_development: false, - }) - } -} - -/// Verifies the Odin route certificate (refusing) and the provider nonce proof. -pub fn verify_session_proof( - request: &SessionOpen, - response: &SessionAccepted, - verse_id: &str, - route: &AuthorityRoute, - trust: &AuthorityTrustPolicy, - now_unix_milliseconds: i64, -) -> Result { - let verified = trust.verify(verse_id, route, now_unix_milliseconds)?; - if verified.is_local_development { - return Ok(true); - } - let Some(key) = verified.provider_key else { - return Ok(false); - }; - if response.provider_key_id != key.key_id - || response.client_nonce != request.client_nonce - || is_blank(&response.provider_signature) - { - return Ok(false); - } - Ok(key.verify(&canonical_session(request, &route.endpoint), &response.provider_signature)) -} diff --git a/packages/cultmesh-rs/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 33cb4858..e50719cd 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -25,7 +25,6 @@ use std::time::Duration; use std::time::Instant; mod rudp_document_server; -pub mod authority; pub use rudp_document_server::*; @@ -82,10 +81,7 @@ pub struct CultMeshRudpDocumentPublishOptions { pub target: SocketAddr, pub runtime_id: String, pub connect_timeout: Duration, - /// How long the acknowledgement of every put may take. 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 resend_delay_ms: u64, pub source_agent_id: Option, @@ -152,7 +148,6 @@ impl Default for CultMeshRudpDocumentPublishOptions { runtime_id: "cultmesh-rudp-document-publisher".to_string(), connect_timeout: Duration::from_secs(3), flush_timeout: Duration::from_millis(300), - result_window: Duration::from_millis(500), poll_interval: Duration::from_millis(10), resend_delay_ms: 50, source_agent_id: None, @@ -869,7 +864,6 @@ pub fn request_raw_snapshot_from_rudp_catalog( /// fixed refusals a CultMesh catalog sends; any other text could carry /// anything, so it is left out. fn recognised_refusal(text: &str) -> String { - let text = text.strip_prefix("write_not_authorized: ").unwrap_or(text); if REFUSAL_TEXTS.contains(&text) { format!(": {text}") } else { @@ -886,30 +880,77 @@ fn unix_millis() -> u128 { /// Publish one raw CultNet schema message to a RUDP catalog. /// -/// A put completes on the catalog's result frame, never on the transport -/// acknowledgement: an ack proves delivery, not admission. A refusal (a -/// `cultnet.error.v0` frame) returns an error in either order against the ack, -/// naming the refusal when it is one of the fixed texts a catalog sends. A -/// catalog that admits a put sends no success frame, so after the ack the call -/// waits `result_window` for a refusal and reports the put as admitted when -/// none comes (interim until the catalog sends a success frame; a success -/// frame ends the wait early). Each call owns a fresh connection epoch. +/// Each call owns a fresh connection epoch and returns only after the catalog +/// acknowledges reliable application admission. Against a Rust CultMesh +/// catalog, which never acknowledges a message it refuses, a refusal is +/// returned as an error, naming the refusal when it is one of the fixed texts +/// such a catalog sends. A catalog that acknowledges a message on receipt and +/// refuses it afterwards (the TypeScript document server does) is reported as +/// having admitted it: the acknowledgement arrives first. pub fn publish_cultnet_message_to_rudp_catalog( message: &cultnet_rs::CultNetMessage, options: CultMeshRudpDocumentPublishOptions, ) -> 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 -/// connection. +/// Publish a batch of raw CultNet schema messages to a RUDP catalog. /// -/// Completes under the same rule as [`publish_cultnet_message_to_rudp_catalog`]: -/// every message acknowledged, then no refusal frame within `result_window` -/// (or one success frame per message). A refusal of any message in the batch -/// fails the whole call; the batch cannot hide it. The catalog's answers do not -/// name the message they answer, so a failed batch does not say which message -/// was refused or whether the others were admitted. +/// Unlike [`publish_cultnet_message_to_rudp_catalog`], which polls for +/// per-message application-level acknowledgement, this sends every message on +/// one connection and waits only for a transport-level `flush_reliable`. It +/// trades per-receipt confirmation for batch throughput; callers that need a +/// stronger delivery guarantee should use the singular publish path instead. pub fn publish_cultnet_messages_to_rudp_catalog( messages: &[cultnet_rs::CultNetMessage], options: CultMeshRudpDocumentPublishOptions, @@ -926,62 +967,11 @@ pub fn publish_cultnet_messages_to_rudp_catalog( "cultmesh-rudp-document-publisher", )?; - let mut receipts = Vec::with_capacity(messages.len()); for message in messages { let payload = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0)?; - receipts.push(client.send_reliable("schema", payload)?); - } - let ack_deadline = Instant::now() + options.flush_timeout; - let mut acknowledged_at: Option = 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.send("schema", payload)?; } + client.flush_reliable(options.flush_timeout) } fn connect_rudp_client( @@ -1459,141 +1449,6 @@ mod tests { 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, - thread::JoinHandle>, - )> { - 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 = 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 /// refusals a CultMesh catalog sends. Any other text, which could carry /// anything, stays out of the publisher's error. @@ -2044,63 +1899,4 @@ mod tests { assert_eq!(lease.bytes(), &[1, 2, 3]); 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(()) - } } diff --git a/packages/cultmesh-rs/src/rudp_document_server.rs b/packages/cultmesh-rs/src/rudp_document_server.rs index 647589e0..09c31adf 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -1,6 +1,6 @@ use anyhow::{Result, anyhow}; use cultnet_rs::{ - CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, + CultNetMessage, CultNetRawDocumentRecord, CultNetRudpPacket, CultNetRudpPacketType, CultNetRudpSendOptions, CultNetRudpSession, CultNetRudpSessionOptions, CultNetWireContract, decode_cultnet_message_from_slice, decode_rudp_packet, encode_cultnet_message_to_vec, 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. 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. /// /// Connection identifiers are scoped to a remote socket address. Two clients @@ -206,10 +196,7 @@ pub struct CultMeshRudpMaintenance { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CultMeshRudpApplicationOperation { DocumentPutRaw, - DocumentDelete, 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. @@ -254,18 +241,12 @@ pub enum CultMeshRudpRejectionReason { /// (`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. 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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { 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 { 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: /// 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. -pub(crate) const REFUSAL_TEXTS: [&str; 11] = [ +pub(crate) const REFUSAL_TEXTS: [&str; 9] = [ "the catalog refused the document", "the document can never be served", "the snapshot source failed", @@ -316,8 +297,6 @@ pub(crate) const REFUSAL_TEXTS: [&str; 11] = [ "the retained payload budget is full", "the snapshot response could not be queued", "the snapshot response could not be sent", - "the server does not take deletes", - "the message could not be read", ]; impl CultMeshRudpRejectionReason { @@ -333,77 +312,18 @@ impl CultMeshRudpRejectionReason { Self::PayloadBudgetFull => 6, Self::ResponseQueueFailed(_) => 7, 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)] pub struct CultMeshRudpApplicationRejection { pub session: CultMeshRudpSessionKey, pub operation: CultMeshRudpApplicationOperation, 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, 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)] pub enum CultMeshRudpPollOutcome { Idle, @@ -415,8 +335,8 @@ pub enum CultMeshRudpPollOutcome { enum SessionEnd { /// The session refused a packet. Refused, - /// The server rejected a peer's message; the peer is sent this refusal. - Rejected(CultNetMessage), + /// The server rejected a peer's message; the peer is sent this fixed text. + Rejected(&'static str), /// The session owes its peer a packet that can never be sent as built. Unsendable(std::io::Error), } @@ -426,11 +346,6 @@ struct SessionEntry { created_at_monotonic_millis: u64, last_activity_monotonic_millis: u64, 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, } /// A synchronous, multi-session CultNet RUDP raw-document server. @@ -605,18 +520,7 @@ where }; let mut unsendable = match result.reply { - Some(mut 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)? - } + Some(reply) => self.send_packet(key.remote_addr, &reply)?, None => None, }; @@ -625,125 +529,24 @@ where return Ok(CultMeshRudpPollOutcome::Handled); } - let mut kept_rejection = None; for frame in result.delivered { if frame.channel_id != "schema" { continue; } - // The capped frame was answered: nothing after it is served while the answer is delivered. - if self - .sessions - .get(&key) - .is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some()) - { - break; - } - let decoded = decode_cultnet_message_from_slice( + let message = match decode_cultnet_message_from_slice( &frame.payload, CultNetWireContract::CultNetSchemaV0, - ); - // Runs of refusals this frame extends or breaks. A session is kept through a short run and - // ended by `MAX_CONSECUTIVE_REFUSED_FRAMES` in a row. - 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:#}")), - }), + ) { + Ok(message) => message, + Err(_) => continue, }; - if let Some(rejection) = rejection { - // A refused delete or unreadable frame below the cap is answered on the live session, - // which goes on: the peer keeps the connection a stray frame did not break. - if (is_unreadable || is_delete) - && run.is_some_and(|run| run < MAX_CONSECUTIVE_REFUSED_FRAMES) - { - 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; - } + if let Some(rejection) = self.deliver_application_message( + key, + frame.sequence, + message, + now_unix, + now_monotonic, + )? { // The only reply `receive` returns is a Pong, which delivers no // frame, so no earlier send of this poll can precede a rejection. // A response that could not be sent ended its session where the @@ -752,17 +555,13 @@ where rejection.reason, 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)); } } - let answering_capped_frame = self - .sessions - .get(&key) - .is_some_and(|entry| entry.ending_deadline_monotonic_millis.is_some()); - if packet.reliable && unsendable.is_none() && !answering_capped_frame { + if packet.reliable && unsendable.is_none() { let ack = self .sessions .get_mut(&key) @@ -774,11 +573,7 @@ where if let Some(error) = unsendable { self.end_session(key, SessionEnd::Unsendable(error))?; } - self.end_answered_sessions()?; - Ok(match kept_rejection { - Some(rejection) => CultMeshRudpPollOutcome::ApplicationRejected(rejection), - None => CultMeshRudpPollOutcome::Handled, - }) + Ok(CultMeshRudpPollOutcome::Handled) } /// 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 }); let expired = before - self.sessions.len(); - self.end_answered_sessions()?; let mut resends = Vec::new(); for (key, entry) in &mut self.sessions { - for mut 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; - } + for packet in entry.session.due_resends(now) { 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 = 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; /// only a socket failure is an `Err`. fn accept_connection( @@ -889,9 +658,6 @@ where created_at_monotonic_millis: now, last_activity_monotonic_millis: now, admitted_payload_bytes: 0, - undecodable_frames_in_a_row: 0, - refused_deletes_in_a_row: 0, - ending_deadline_monotonic_millis: None, }, ); accept @@ -917,13 +683,11 @@ where message_id, document, } => { - let record_key = document.record_key.clone(); let reject = |reason| { Ok(Some(CultMeshRudpApplicationRejection { session: key, operation: CultMeshRudpApplicationOperation::DocumentPutRaw, message_id: message_id.clone(), - record_key: Some(record_key.clone()), reason, })) }; @@ -994,7 +758,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::SnapshotSourceFailed(format!( "{error:#}" )), @@ -1006,7 +769,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::SnapshotTooManyDocuments { documents: documents.len(), max_snapshot_documents: self.options.max_snapshot_documents, @@ -1024,7 +786,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::ResponseEncodingFailed(error), })); } @@ -1034,7 +795,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::SnapshotResponseTooLarge { response_bytes: payload.len(), max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, @@ -1046,7 +806,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::PayloadBudgetFull, })); } @@ -1073,7 +832,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::ResponseQueueFailed(format!( "{error:#}" )), @@ -1090,25 +848,11 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, 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) @@ -1131,7 +875,7 @@ where }; let (refusal, mut unsendable) = match end { 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)), }; entry.session.reset_peer_state(); @@ -1154,21 +898,25 @@ where 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( &self, session: &mut CultNetRudpSession, - refusal: &CultNetMessage, - reliable: bool, + text: &str, ) -> Vec { - 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| { session.send_many( "schema", payload, CultNetRudpSendOptions { - reliable, - ordered: reliable, + reliable: false, + ordered: false, sequenced: false, now_ms: self.clock.now_monotonic_millis(), reliable_expire_after_ms: None, @@ -1332,39 +1080,3 @@ fn encode_snapshot_response(response: &CultNetMessage) -> std::result::Result u64 { 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); - } -} diff --git a/packages/cultmesh-rs/tests/authority_route_vectors.rs b/packages/cultmesh-rs/tests/authority_route_vectors.rs deleted file mode 100644 index 40cc5bba..00000000 --- a/packages/cultmesh-rs/tests/authority_route_vectors.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! Judges `contracts/cultmesh/authority-route-vectors*.json` in Rust. -//! -//! `authority-route-vectors.json` is written by the C# reference (its signatures -//! are captured C# bytes); the `.ts-signed.json` file is signed by TypeScript -//! over awkward inputs. Each negative case fails when the matching check is -//! removed from `cultmesh_rs::authority`. - -use base64::Engine; -use base64::engine::general_purpose::STANDARD; -use cultmesh_rs::authority::*; -use p256::ecdsa::Signature; -use serde_json::Value; -use std::path::PathBuf; - -struct Vector { - now: i64, - root: P256PublicKey, - verse_id: String, - route: AuthorityRoute, - request: SessionOpen, - accepted: SessionAccepted, - trust: AuthorityTrustPolicy, -} - -fn s<'a>(value: &'a Value, key: &str) -> &'a str { - value[key].as_str().unwrap_or_else(|| panic!("missing string {key}")) -} - -fn key(value: &Value) -> P256PublicKey { - P256PublicKey::new(s(value, "keyId"), s(value, "x"), s(value, "y")).unwrap() -} - -fn load(name: &str) -> Vector { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../contracts/cultmesh") - .join(name); - let doc: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); - let r = &doc["route"]; - let c = &r["certificate"]; - let certificate = RouteCertificate::new( - key(&c["providerKey"]), - s(c, "odinKeyId"), - c["issuedAtUnixMilliseconds"].as_i64().unwrap(), - c["expiresAtUnixMilliseconds"].as_i64().unwrap(), - s(c, "signature"), - ) - .unwrap(); - let protocols: Vec<&str> = r["protocolIds"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); - let route = AuthorityRoute::new( - s(r, "authorityRuntimeId"), - s(r, "endpoint"), - &protocols, - r["priority"].as_i64().unwrap() as i32, - Some(s(r, "generation")), - Some(certificate), - ) - .unwrap(); - let p = &doc["sessionProof"]; - let q = &p["request"]; - let root = key(&doc["odinRoot"]); - Vector { - now: doc["nowUnixMilliseconds"].as_i64().unwrap(), - verse_id: s(r, "verseId").to_owned(), - route, - request: SessionOpen { - client_nonce: s(q, "clientNonce").into(), - message_id: s(q, "messageId").into(), - source_runtime_id: s(q, "sourceRuntimeId").into(), - verse_id: s(q, "verseId").into(), - authority_runtime_id: s(q, "authorityRuntimeId").into(), - protocol_id: s(q, "protocolId").into(), - route_generation: s(q, "routeGeneration").into(), - }, - accepted: SessionAccepted { - client_nonce: s(q, "clientNonce").into(), - provider_key_id: s(p, "providerKeyId").into(), - provider_signature: s(p, "providerSignature").into(), - }, - trust: AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![root.clone()]).unwrap(), - root, - } -} - -impl Vector { - fn certificate(&self) -> &RouteCertificate { - self.route.certificate.as_ref().unwrap() - } - fn with_certificate(&self, edit: impl FnOnce(&mut RouteCertificate)) -> AuthorityRoute { - let mut route = self.route.clone(); - edit(route.certificate.as_mut().unwrap()); - route - } - fn refusal(&self, route: &AuthorityRoute, now: i64) -> String { - let error = self.trust.validate(&self.verse_id, route, now).expect_err("route must be refused"); - assert_eq!(AuthorityRouteRefusal::REASON, "authentication"); - assert_eq!(route.endpoint, error.endpoint); - error.message - } - fn proof(&self, request: &SessionOpen, accepted: &SessionAccepted) -> bool { - verify_session_proof(request, accepted, &self.verse_id, &self.route, &self.trust, self.now).unwrap() - } -} - -const INVALID: &str = "The Odin route certificate signature is invalid."; - -#[test] -fn cs_written_vector_verifies_route_and_session_proof() { - let v = load("authority-route-vectors.json"); - v.trust.validate(&v.verse_id, &v.route, v.now).unwrap(); - assert!(v.proof(&v.request, &v.accepted)); -} - -#[test] -fn ts_signed_vector_verifies_and_tamper_is_refused() { - let v = load("authority-route-vectors.ts-signed.json"); - v.trust.validate(&v.verse_id, &v.route, v.now).unwrap(); - assert!(v.proof(&v.request, &v.accepted)); - let mut tampered = v.route.clone(); - tampered.priority += 1; - assert_eq!(INVALID, v.refusal(&tampered, v.now)); -} - -#[test] -fn canonical_route_is_length_prefixed_and_sorted() { - let v = load("authority-route-vectors.json"); - let bytes = canonical_route(&v.verse_id, &v.route).unwrap(); - assert!(bytes.starts_with(b"\x00\x00\x00\x26gamecult.cultmesh.route-certificate.v1\x00\x00\x00\x08aetheria")); - let needle = b"cultmesh.content.v1\x1fcultmesh.documents.v1"; - assert!(bytes.windows(needle.len()).any(|w| w == needle)); -} - -#[test] -fn tampered_payload_fields_are_refused() { - let v = load("authority-route-vectors.json"); - let edits: Vec> = vec![ - Box::new(|r| r.priority = 8), - Box::new(|r| r.endpoint = "wss://evil.example/mesh".into()), - Box::new(|r| r.protocol_ids = vec!["cultmesh.documents.v1".into()]), - Box::new(|r| r.generation = "other".into()), - Box::new(|r| r.certificate.as_mut().unwrap().expires_at_unix_milliseconds += 1), - ]; - for edit in edits { - let mut route = v.route.clone(); - edit(&mut route); - assert_eq!(INVALID, v.refusal(&route, v.now)); - } - let error = v.trust.validate("other-verse", &v.route, v.now).unwrap_err(); - assert_eq!(INVALID, error.message); -} - -#[test] -fn wrong_root_key_is_refused() { - let v = load("authority-route-vectors.json"); - // The provider's key is a real P-256 key that did not sign this route. - let provider = &v.certificate().provider_key; - let impostor = P256PublicKey::new(&v.root.key_id, &provider.x, &provider.y).unwrap(); - let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![impostor]).unwrap(); - assert_eq!(INVALID, trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message); -} - -#[test] -fn untrusted_odin_key_id_is_refused() { - let v = load("authority-route-vectors.json"); - let other = P256PublicKey::new("someone-else", &v.root.x, &v.root.y).unwrap(); - let trust = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![other]).unwrap(); - assert_eq!( - "Odin key 'odin-vector' is not trusted by this consumer.", - trust.validate(&v.verse_id, &v.route, v.now).unwrap_err().message - ); -} - -#[test] -fn expired_and_not_yet_valid_are_refused() { - let v = load("authority-route-vectors.json"); - let window = "The Odin route certificate is not currently valid."; - let (issued, expires) = (v.certificate().issued_at_unix_milliseconds, v.certificate().expires_at_unix_milliseconds); - v.trust.validate(&v.verse_id, &v.route, expires - 1).unwrap(); - v.trust.validate(&v.verse_id, &v.route, issued).unwrap(); - assert_eq!(window, v.refusal(&v.route, expires)); - assert_eq!(window, v.refusal(&v.route, issued - 1)); -} - -#[test] -fn unsigned_and_unprotected_routes_are_refused() { - let v = load("authority-route-vectors.json"); - let unsigned_message = "Remote CultMesh routes require an Odin-signed authority certificate."; - let mut unsigned = v.route.clone(); - unsigned.certificate = None; - assert_eq!(unsigned_message, v.refusal(&unsigned, v.now)); - let blank = v.with_certificate(|c| c.signature = " ".into()); - assert_eq!(unsigned_message, v.refusal(&blank, v.now)); - let mut plain = v.route.clone(); - plain.endpoint = "ws://provider.example/mesh".into(); - assert_eq!( - "Authenticated remote CultMesh routes require TLS or QUIC channel protection.", - v.refusal(&plain, v.now) - ); -} - -#[test] -fn local_development_admits_only_unsigned_loopback() { - let v = load("authority-route-vectors.json"); - let dev = AuthorityTrustPolicy::new(TrustMode::LocalDevelopment, vec![]).unwrap(); - let route = |endpoint: &str| AuthorityRoute::new("a", endpoint, &[], 0, None, None).unwrap(); - let loopback = route("ws://127.0.0.1:9/mesh"); - dev.validate(&v.verse_id, &loopback, v.now).unwrap(); - assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &loopback, &dev, v.now).unwrap()); - for endpoint in ["ws://provider.example/mesh", "ws://127.0.0.1./mesh", "ws:127.0.0.1"] { - assert!(dev.validate(&v.verse_id, &route(endpoint), v.now).is_err(), "{endpoint}"); - } - let remote = AuthorityTrustPolicy::new(TrustMode::AuthenticatedRemote, vec![]).unwrap(); - assert!(remote.validate(&v.verse_id, &loopback, v.now).is_err()); -} - -#[test] -fn malformed_signatures_get_their_own_refusals() { - let v = load("authority-route-vectors.json"); - let not_base64 = v.with_certificate(|c| c.signature = "!!not base64!!".into()); - assert_eq!("The Odin route signature is not base64.", v.refusal(¬_base64, v.now)); - let short = v.with_certificate(|c| c.signature = STANDARD.encode([b'x'; 63])); - assert_eq!("The Odin route signature is not IEEE P1363 P-256.", v.refusal(&short, v.now)); -} - -#[test] -fn high_s_signature_verifies_as_it_does_in_dotnet() { - let v = load("authority-route-vectors.json"); - let raw = STANDARD.decode(&v.certificate().signature).unwrap(); - let signature = Signature::from_slice(&raw).unwrap(); - let flipped = Signature::from_scalars(*signature.r().as_ref(), -*signature.s().as_ref()).unwrap(); - let high = v.with_certificate(|c| c.signature = STANDARD.encode(flipped.to_bytes())); - v.trust.validate(&v.verse_id, &high, v.now).unwrap(); -} - -#[test] -fn session_proof_rejects_replay_wrong_key_and_forgery() { - let v = load("authority-route-vectors.json"); - let mut replayed = v.request.clone(); - replayed.client_nonce = "replayed".into(); - let mut accepted = v.accepted.clone(); - accepted.client_nonce = "replayed".into(); - assert!(!v.proof(&replayed, &accepted)); - let mut wrong_id = v.accepted.clone(); - wrong_id.provider_key_id = "other".into(); - assert!(!v.proof(&v.request, &wrong_id)); - let mut wrong_nonce = v.accepted.clone(); - wrong_nonce.client_nonce = "other".into(); - assert!(!v.proof(&v.request, &wrong_nonce)); - let mut empty = v.accepted.clone(); - empty.provider_signature = String::new(); - assert!(!v.proof(&v.request, &empty)); - let mut raw = STANDARD.decode(&v.accepted.provider_signature).unwrap(); - raw[0] ^= 1; - let mut forged = v.accepted.clone(); - forged.provider_signature = STANDARD.encode(raw); - assert!(!v.proof(&v.request, &forged)); - let mut moved = v.request.clone(); - moved.protocol_id = "cultmesh.content.v1".into(); - assert!(!v.proof(&moved, &v.accepted)); - let expires = v.certificate().expires_at_unix_milliseconds; - assert!(verify_session_proof(&v.request, &v.accepted, &v.verse_id, &v.route, &v.trust, expires).is_err()); -} diff --git a/packages/cultmesh-rs/tests/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index 0e3cba66..30fe4678 100644 --- a/packages/cultmesh-rs/tests/rudp_document_server.rs +++ b/packages/cultmesh-rs/tests/rudp_document_server.rs @@ -6,7 +6,7 @@ use cultmesh_rs::{ CultMeshRudpSnapshotQuery, CultMeshRudpSnapshotSource, }; use cultnet_rs::{ - CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, + CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpReliableSendReceipt, CultNetRudpReliableSendStatus, CultNetRudpSocketMode, CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, @@ -343,18 +343,6 @@ fn refusal_seen_by( client: &mut CultNetRudpSocketTransportConnection, receipt: &CultNetRudpReliableSendReceipt, ) -> Result { - 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, Option)> { let mut refusal = None; for _ in 0..2_000 { if let Some(frame) = client.receive_once()? { @@ -374,6 +362,7 @@ fn typed_refusal_seen_by( else { panic!("the refused peer was sent no refusal: {refusal:?}"); }; + assert_eq!((code, details), (None, None)); assert!( !error.contains("CANARY"), "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" ); for _ in 0..2_000 { - if let Some(server) = server.as_deref_mut() { - server.poll_once()?; - } client.receive_once()?; if !client.connected() { break; @@ -398,7 +384,7 @@ fn typed_refusal_seen_by( client.reliable_send_status(receipt), CultNetRudpReliableSendStatus::Invalidated ); - Ok((error, code, details)) + Ok(error) } /// 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); // 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!( - typed_refusal_seen_by(&mut publisher, &publish_receipt, None)?, - ( - "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, - }) - ) + refusal_seen_by(&mut publisher, &publish_receipt)?, + "the catalog refused the document" ); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "a refused put changes nothing"); drain_until_idle(&mut server)?; let snapshot_receipt = send_reliable( @@ -757,173 +732,6 @@ fn application_rejection_is_nonfatal_peer_scoped_refused_to_the_peer_and_unackno 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, - client: &mut CultNetRudpSocketTransportConnection, - payload: Vec, -) -> Result<(Option, Option, 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 { - let mut payload = vec![0xAE]; - payload.extend_from_slice(b"CANARY-garbage"); - payload -} - -fn delete_of(key: &str) -> Vec { - 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 { - 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, - 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] fn snapshot_response_resends_until_acknowledged() -> Result<()> { let clock = Clock::new(51_000); @@ -1629,11 +1437,6 @@ impl RawPeer { /// Sends `message` reliably and in order; returns its sequences. fn send(&mut self, message: &CultNetMessage) -> Result> { 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) -> Result> { let options = cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered: true, @@ -1722,14 +1525,9 @@ fn a_refusal_reaches_a_peer_missing_an_earlier_packet() -> Result<()> { assert_eq!( refusals, vec![CultNetMessage::Error { - error: "write_not_authorized: the catalog refused the document".into(), - code: Some(CultNetErrorCode::WriteNotAuthorized), - details: Some(CultNetErrorDetails { - field: Some("recordKey".into()), - value: Some(document("refused", vec![4, 5, 6]).record_key), - as_of: None, - current: None, - }), + error: "the catalog refused the document".into(), + code: None, + details: None, }] ); 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(()) } - -/// 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(()) -} diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index d06e753b..8b4e0ab5 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -1,22 +1,18 @@ # 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/). ## Unreleased ### 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. - `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `` and yield `CultMeshCatalogValue`: 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`. ### 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`, exported. - `documentById` and `tryDocumentById` on `CultMeshDocumentCatalog`, which address one document by its document id when a catalog holds several documents of one type. diff --git a/packages/cultmesh-ts/README.md b/packages/cultmesh-ts/README.md index 32ec64ed..2f9eda51 100644 --- a/packages/cultmesh-ts/README.md +++ b/packages/cultmesh-ts/README.md @@ -1,6 +1,6 @@ # 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 native CultMesh stream implementation used by Mimir and Fensalir; this package 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: ```ts -import { CultMesh } from "@gamecult/cultmesh-ts"; +import { CultMesh } from "cultmesh-ts"; const viewport = CultMesh.rectFromBounds(-500, -300, 500, 300); const request = CultMesh.viewportRequest(viewport, [3, 8]); @@ -215,7 +215,7 @@ const stopFeed = viewportFeed.watch(request, "browser-starfire", render); ``` ```ts -import { CultMesh } from "@gamecult/cultmesh-ts"; +import { CultMesh } from "cultmesh-ts"; const verses = CultMesh.createVerseCatalog<{ verseId: string; label: string }>(); 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 topology truth. @@ -290,7 +290,7 @@ command transactions; the injected transport owns CultNet wire details. import { CultMeshProviderRudpTransport, CultMeshProviderSession, -} from "@gamecult/cultmesh-ts"; +} from "cultmesh-ts"; const odinTransport = new CultMeshProviderRudpTransport({ endpoint: "rudp://127.0.0.1:17871", @@ -345,11 +345,9 @@ evidence for the requested provider identity. ## 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 branded CultMesh entrypoint while the transport semantics stay owned by -`@gamecult/cultnet-ts`: +`cultnet-ts`: ```ts const server = await CultMesh.createRudpServer("ts-server", 0x1020_3040); @@ -474,7 +472,7 @@ The split is deliberate: The mirrored API shape is `CultMeshStreamCatalog`: ```ts -import { CultMesh } from "@gamecult/cultmesh-ts"; +import { CultMesh } from "cultmesh-ts"; const streams = CultMesh.createStreamCatalog(); const stopWatchingStreams = streams.watch((stream) => { @@ -535,7 +533,7 @@ nonlocal fallback. ## 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` `?chunkHash=&recordKey=`. The mount path is the host's; the 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 @@ -552,7 +550,7 @@ host store may reuse its buffer). `onServerFault` reports every 500 (`hash-misma ```ts import { readFileSync } from "node:fs"; 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"), { onServerFault: fault => console.error("chunk fault", fault.kind, fault.chunkHash), diff --git a/packages/cultmesh-ts/package.json b/packages/cultmesh-ts/package.json index 92962cbc..f6559fba 100644 --- a/packages/cultmesh-ts/package.json +++ b/packages/cultmesh-ts/package.json @@ -1,5 +1,5 @@ { - "name": "@gamecult/cultmesh-ts", + "name": "cultmesh-ts", "version": "0.2.0", "description": "CultMesh local typed state and mesh catalog primitives for TypeScript runtimes.", "main": "dist/index.js", @@ -48,8 +48,8 @@ "node": ">=20" }, "dependencies": { - "@gamecult/cultcache-ts": "^0.15.0", - "@gamecult/cultnet-ts": "^0.3.0", + "@gamecult/cultcache-ts": "^0.14.0", + "cultnet-ts": "^0.3.0", "koffi": "3.3.0" }, "devDependencies": { diff --git a/packages/cultmesh-ts/src/content.ts b/packages/cultmesh-ts/src/content.ts index b463f5b6..a0f3f06d 100644 --- a/packages/cultmesh-ts/src/content.ts +++ b/packages/cultmesh-ts/src/content.ts @@ -2,7 +2,7 @@ import { constants } from "node:fs"; import { open } from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; 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. */ export interface ContentChunkRequest { method: string; url: string; } diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index f065dd83..90331655 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -1,5 +1,5 @@ import { createSocket, type Socket } from "node:dgram"; -import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; import { CultCache, @@ -20,9 +20,6 @@ import { CultNetRudpSocketTransportConnection, rudpClientBindHost, CultNetSchemaCatalog, - cultNetMessageInvalidError, - cultNetTextError, - cultNetWriteNotAuthorizedError, CultNetShardCatalog, cultNetBuiltinSchemaRegistry, decodeRudpPacket, @@ -34,7 +31,6 @@ import { sendRudpDatagram, type CultNetErrorMessage, type CultNetDocumentBinding, - type CultNetDocumentDeleteMessage, type CultNetMessage, type CultNetOperationRequestMessage, type CultNetOperationResponseMessage, @@ -44,7 +40,7 @@ import { type CultNetSchemaCatalogOptions, type CultNetSnapshotResponseRawMessage, type CultNetWireContract, -} from "@gamecult/cultnet-ts"; +} from "cultnet-ts"; import { CultMeshQuicRealtimeConnector, @@ -3995,63 +3991,6 @@ export type CultMeshRudpDocumentPut = { | { 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; -} - -/** - * 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 { binding: CultNetDocumentBinding; recordKey: string; @@ -4091,24 +4030,10 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt documents: CultNetDocumentRegistry; getCache?: () => Promise | CultCache; 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) => | CultMeshRudpDocumentReceipt | void | Promise; - /** Applies a delete the authority granted. Without it a granted delete is answered with a text refusal. */ - onDocumentDeleteRaw?: (request: CultMeshRudpDocumentDelete) => void | Promise; onOperationRequest?: ( request: CultNetOperationRequestMessage, session: CultMeshRudpServerSession, @@ -4118,19 +4043,6 @@ export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOpt 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. */ export const CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES = 1200; @@ -4183,29 +4095,7 @@ export interface CultMeshRudpDocumentServer { 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; - constructor(response: Extract) { - super(response.error); - this.name = "CultMeshRudpWriteRefusedError"; - this.code = response.code ?? undefined; - this.response = response; - } -} - 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; sourceRuntimeId?: string; sourceAgentId?: string; @@ -5569,11 +5459,6 @@ export class CultMesh { closed: boolean; /** A packet it owed could never be sent, so the session ended. */ 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; }; const sessions = new Map(); @@ -5581,11 +5466,6 @@ export class CultMesh { let sendFailures = 0; const resendPollMs = Math.max(10, options.resendPollMs ?? 25); 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 maxFragmentBytes = options.maxFragmentBytes ?? CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES; if (!(maxFragmentBytes > 0)) { @@ -5628,8 +5508,6 @@ export class CultMesh { connectPayload, closed: false, unsendable: false, - unreadableInARow: 0, - refusedDeletesInARow: 0, session: new CultNetRudpSession({ connectionId, initialSequence: options.initialSequence, @@ -5694,36 +5572,15 @@ export class CultMesh { record: SessionRecord, payload: Uint8Array, ): Promise { - // Nothing after the capped frame is served while its answer is delivered. - if (record.closed || record.endingDeadlineMs !== undefined) return; - 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; + if (record.closed) return; + const message = parseCultNetMessage(decodeWireValue(payload), wireContract); switch (message.schemaVersion) { case "cultnet.snapshot_request.v0": { 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; } let reply: CultNetMessage; @@ -5752,32 +5609,11 @@ export class CultMesh { sendSchemaMessage(record, cultNetBuiltinSchemaRegistry.createCatalogResponse(message)); return; 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) { const unservable = unservablePut(message); if (unservable) { reportError(unservable); - sendSchemaMessage(record, cultNetTextError(unservable.message)); + sendSchemaMessage(record, { schemaVersion: "cultnet.error.v0", error: unservable.message }); return; } const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote, options.documents)); @@ -5787,119 +5623,32 @@ export class CultMesh { } catch (error) { // The put is stored: the peer must hear that its receipt is not coming. 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; - 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": 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; } sendSchemaMessage(record, await options.onOperationRequest(message, publicSession(record))); return; 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 { - let reason: string | undefined; - const authority = options.writeAuthority; - if (!authority) { - reason = "no remote write authority is configured"; - } else { - let timer: ReturnType | 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((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 { return { sessionId: record.sessionId, @@ -5949,17 +5698,6 @@ export class CultMesh { 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. */ function endUnsendableSession(record: SessionRecord, error: Error): void { if (record.unsendable) return; @@ -6003,7 +5741,7 @@ export class CultMesh { */ function refuse(record: SessionRecord, text: string): void { 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, { reliable: false, ordered: false, @@ -6093,11 +5831,6 @@ export class CultMesh { closeSession(record); continue; } - if (record.endingDeadlineMs !== undefined - && (record.session.outstandingReliablePacketCount === 0 || nowMs >= record.endingDeadlineMs)) { - endRefusedSession(key, record); - continue; - } for (const packet of record.session.dueResends(nowMs)) { sendPacket(record, packet); } @@ -6179,23 +5912,11 @@ export class CultMesh { requireNonEmpty(runtimeId, "runtimeId"); requireNonEmpty(recordKey, "recordKey"); const registry = new CultNetDocumentRegistry([binding]); - const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); 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( binding, - messageId, + options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`, recordKey, value, { @@ -6206,13 +5927,6 @@ export class CultMesh { }, )); await peer.flush(options.flushTimeoutMs ?? 30_000); - if (!refusal && !succeeded) { - await new Promise((resolve) => { - const timer = setTimeout(resolve, options.resultWindowMs ?? 500); - wake = () => { clearTimeout(timer); resolve(); }; - }); - } - if (refusal) throw refusal; } finally { peer.close(); } @@ -6231,7 +5945,6 @@ export class CultMesh { const receipt = new Promise>((resolve, reject) => { 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 => { - 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; try { const decoded = decodeDocumentPayload(receiptBinding.definition, message.document.payload); diff --git a/packages/cultmesh-ts/src/provider-rudp-transport.ts b/packages/cultmesh-ts/src/provider-rudp-transport.ts index b193825f..fc0d75c0 100644 --- a/packages/cultmesh-ts/src/provider-rudp-transport.ts +++ b/packages/cultmesh-ts/src/provider-rudp-transport.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { createSocket, type Socket } from "node:dgram"; import { documentPayloadFromBytes } from "@gamecult/cultcache-ts"; -import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { encodeWireValue } from "cultnet-ts/wire"; import { CultNetPeer, CultNetRudpSocketTransportConnection, @@ -9,7 +9,7 @@ import { type CultNetOperationRequestMessage, type CultNetOperationResponseMessage, type CultNetRawDocumentRecord, -} from "@gamecult/cultnet-ts"; +} from "cultnet-ts"; import type { CultMeshProviderCommand, diff --git a/packages/cultmesh-ts/src/provider-session-broker.ts b/packages/cultmesh-ts/src/provider-session-broker.ts index c427b83a..75240a14 100644 --- a/packages/cultmesh-ts/src/provider-session-broker.ts +++ b/packages/cultmesh-ts/src/provider-session-broker.ts @@ -1,11 +1,11 @@ import { encodeDocumentPayload } from "@gamecult/cultcache-ts"; -import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { encodeWireValue } from "cultnet-ts/wire"; import type { CultNetDocumentPutRawMessage, CultNetOperationRequestMessage, CultNetOperationResponseMessage, CultNetRawDocumentRecord, -} from "@gamecult/cultnet-ts"; +} from "cultnet-ts"; import type { CultMeshRudpServerSession } from "./index"; import { diff --git a/packages/cultmesh-ts/src/provider-session-wire.ts b/packages/cultmesh-ts/src/provider-session-wire.ts index 3cffb13e..d4884694 100644 --- a/packages/cultmesh-ts/src/provider-session-wire.ts +++ b/packages/cultmesh-ts/src/provider-session-wire.ts @@ -1,6 +1,6 @@ import { decodeDocumentPayload, defineDocumentType } from "@gamecult/cultcache-ts"; -import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire"; -import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts"; +import { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import type { CultNetRawDocumentRecord } from "cultnet-ts"; export const CULTMESH_PROVIDER_SESSION_SERVICE_ID = "gamecult.mesh.provider_session"; diff --git a/packages/cultmesh-ts/src/realtime-quic.ts b/packages/cultmesh-ts/src/realtime-quic.ts index 32b500d2..c368c96f 100644 --- a/packages/cultmesh-ts/src/realtime-quic.ts +++ b/packages/cultmesh-ts/src/realtime-quic.ts @@ -21,8 +21,8 @@ import { trimCSharp, verifyAuthorityRoute, type CultMeshAuthorityTrustPolicy, -} from "@gamecult/cultnet-ts"; -import type { CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts"; +} from "cultnet-ts"; +import type { CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; import { decodeRealtimeFrame, diff --git a/packages/cultmesh-ts/src/realtime-wire.ts b/packages/cultmesh-ts/src/realtime-wire.ts index e87df641..9fc99789 100644 --- a/packages/cultmesh-ts/src/realtime-wire.ts +++ b/packages/cultmesh-ts/src/realtime-wire.ts @@ -16,7 +16,7 @@ // agree with itself. 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. */ 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 * `string.IsNullOrWhiteSpace`, whose whitespace set is not * `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 * `bigint` does not, and a `DataView` would otherwise throw a bare `RangeError` diff --git a/packages/cultmesh-ts/test/client-write-completion.test.ts b/packages/cultmesh-ts/test/client-write-completion.test.ts deleted file mode 100644 index 8dd122ed..00000000 --- a/packages/cultmesh-ts/test/client-write-completion.test.ts +++ /dev/null @@ -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((resolve) => socket.bind(0, "127.0.0.1", resolve)); - const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 }); - const timers = new Set(); - let closed = false; - const send = (packet: Parameters[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(); } -}); diff --git a/packages/cultmesh-ts/test/codec-documents.test.ts b/packages/cultmesh-ts/test/codec-documents.test.ts index d9c8d2e1..58c05568 100644 --- a/packages/cultmesh-ts/test/codec-documents.test.ts +++ b/packages/cultmesh-ts/test/codec-documents.test.ts @@ -19,8 +19,8 @@ import { CultNetDocumentRegistry, defineCultNetDocumentBinding, type CultNetPeer, -} from "@gamecult/cultnet-ts"; -import { CultMesh, CultMeshGrantAllWrites, cultMeshDocumentFromSingleFile, type CultMeshRudpDocumentPut } from "../src/index"; +} from "cultnet-ts"; +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 // 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 puts: unknown[] = []; const server = CultMesh.createRudpDocumentServer("cultmesh-ts-codec-server", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([commandBinding, plainBinding, receiptBinding]), getCache: () => source.cache, 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 puts: CultMeshRudpDocumentPut[] = []; const server = CultMesh.createRudpDocumentServer("cultmesh-ts-unregistered-server", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([known]), bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, 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 puts: CultMeshRudpDocumentPut[] = []; const server = CultMesh.createRudpDocumentServer("cultmesh-ts-depth-server", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: registry, bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, onError: (error) => errors.push(error), diff --git a/packages/cultmesh-ts/test/content.test.ts b/packages/cultmesh-ts/test/content.test.ts index 7e3a357a..9662bb86 100644 --- a/packages/cultmesh-ts/test/content.test.ts +++ b/packages/cultmesh-ts/test/content.test.ts @@ -8,7 +8,7 @@ import { join } from "node:path"; import test, { mock } from "node:test"; 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 { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 90ff38f9..eccb4545 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; 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 { SchemaConflictError, defineDocumentType, documentPayloadBytes, documentPayloadFromBytes } from "@gamecult/cultcache-ts"; import { @@ -12,7 +12,6 @@ import { CultNetPeer, CultNetRudpSession, cultNetBuiltinSchemaRegistry, - cultNetTextError, decodeRudpPacket, defineCultNetDocumentBinding, encodeRudpPacket, @@ -20,8 +19,8 @@ import { type CultNetRudpPacket, type CultNetMessage, type CultNetSnapshotResponseRawMessage, -} from "@gamecult/cultnet-ts"; -import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError } from "../src/index"; +} from "cultnet-ts"; +import { CultMesh, CultMeshRudpUnservableDocumentError } from "../src/index"; // A record's payload as the bytes a peer would put on the wire. 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", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry(), bindHost: "127.0.0.1", bindPort: 0, @@ -2946,7 +2944,6 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () "cultmesh-ts-odin-catalog", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry(), bindHost: "127.0.0.1", 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 receiptBinding = defineCultNetDocumentBinding({ definition: noteReceiptDocument }); const server = CultMesh.createRudpDocumentServer("cultmesh-ts-receipt-server", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([commandBinding, receiptBinding]), bindHost: "127.0.0.1", bindPort: 0, resendDelayMs: 25, resendPollMs: 5, onDocumentPutRaw: async (document) => ({ @@ -3058,7 +3054,6 @@ test("CultMesh TS reads remote RUDP snapshots through document handles", async ( "cultmesh-ts-rudp-snapshot-server", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([ defineCultNetDocumentBinding({ definition: noteDocument }), ]), @@ -3156,7 +3151,6 @@ test("CultMesh TS syncs remote RUDP snapshots into a local node", async () => { "cultmesh-ts-rudp-sync-server", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([ 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", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: new CultNetDocumentRegistry([ defineCultNetDocumentBinding({ definition: incompatibleNoteDocument }), defineCultNetDocumentBinding({ definition: foreignNoteDocument }), @@ -3480,7 +3473,6 @@ async function withServedBoundRig( const messages: CultNetMessage[] = []; const unparsed: Error[] = []; const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: registry, getCache: () => node.cache, bindHost: "127.0.0.1", @@ -3642,7 +3634,6 @@ async function withRawPeerRig( for (const put of stored) await registry.applyRawDocumentPutMessage(node.cache, put); const errors: Error[] = []; const server = CultMesh.createRudpDocumentServer(SERVED_BOUND_SERVER, connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), documents: registry, getCache: () => (getCache ? getCache(node.cache) : node.cache) as typeof node.cache, 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); const refusal = refusalIn(rig.inbox); 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) { 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); const refusal = refusalIn(rig.inbox); 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/); }); }); diff --git a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts index f22d5af3..e7e04c1a 100644 --- a/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts +++ b/packages/cultmesh-ts/test/interop/cultmesh-quic-peer.ts @@ -18,8 +18,8 @@ // harness can dial a peer late and exercise Cut C's retained-state seed // instead of the provider always tearing down the moment it is done sending. // -// Neither mode imports `@gamecult/cultmesh-ts` itself (the harness drives processes, -// and the dependency direction is `@gamecult/cultmesh-ts` -> `@gamecult/cultnet-ts`, never the +// Neither mode imports `cultmesh-ts` itself (the harness drives processes, +// and the dependency direction is `cultmesh-ts` -> `cultnet-ts`, never the // reverse); this script reaches the provider/connector through its own // package's `src/`. diff --git a/packages/cultmesh-ts/test/payload-type.test.ts b/packages/cultmesh-ts/test/payload-type.test.ts index f04541da..cdba3505 100644 --- a/packages/cultmesh-ts/test/payload-type.test.ts +++ b/packages/cultmesh-ts/test/payload-type.test.ts @@ -8,8 +8,8 @@ import { documentPayloadFromBytes, type DocumentPayload, } from "@gamecult/cultcache-ts"; -import { decodeWireValue } from "@gamecult/cultnet-ts/wire"; -import type { CultNetRawDocumentRecord } from "@gamecult/cultnet-ts"; +import { decodeWireValue } from "cultnet-ts/wire"; +import type { CultNetRawDocumentRecord } from "cultnet-ts"; import type { CultMeshRudpDocumentPut } from "../src/index"; import { decodeRealtimeFrame, encodeRealtimeFrame, type CultMeshRealtimeFrame } from "../src/realtime-wire"; diff --git a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts index aa4db341..e9a9f1c5 100644 --- a/packages/cultmesh-ts/test/provider-rudp-transport.test.ts +++ b/packages/cultmesh-ts/test/provider-rudp-transport.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { createSocket } from "node:dgram"; import { documentPayloadBytes } from "@gamecult/cultcache-ts"; -import { decodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { decodeWireValue } from "cultnet-ts/wire"; import { CultNetDocumentRegistry, CultNetRudpSession, @@ -10,11 +10,10 @@ import { encodeRudpPacket, type CultNetOperationRequestMessage, type CultNetRudpPacket, -} from "@gamecult/cultnet-ts"; +} from "cultnet-ts"; import { CultMesh, - CultMeshGrantAllWrites, CultMeshProviderRudpTransport, CultMeshProviderSessionBroker, 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, { - writeAuthority: new CultMeshGrantAllWrites(), bindPort: 0, documents: new CultNetDocumentRegistry(), 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 closedSessions = 0; const server = CultMesh.createRudpDocumentServer("odin-queue-test", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), bindPort: 0, documents: new CultNetDocumentRegistry(), onOperationRequest: async request => { @@ -329,7 +326,6 @@ test("a client restarted on the same address with the same Connect payload repla const connectionId = 0x43554c54; let closedSessions = 0; const server = CultMesh.createRudpDocumentServer("odin-restart-test", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), bindPort: 0, documents: new CultNetDocumentRegistry(), 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 () => { const connectionId = 0x43554c55; const server = CultMesh.createRudpDocumentServer("odin-ack-test", connectionId, { - writeAuthority: new CultMeshGrantAllWrites(), bindPort: 0, documents: new CultNetDocumentRegistry(), onError: () => undefined, diff --git a/packages/cultmesh-ts/test/provider-session-wire.test.ts b/packages/cultmesh-ts/test/provider-session-wire.test.ts index 4905f161..ee196f41 100644 --- a/packages/cultmesh-ts/test/provider-session-wire.test.ts +++ b/packages/cultmesh-ts/test/provider-session-wire.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { documentPayloadBytes, documentPayloadFromBytes, encodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts"; -import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { encodeWireValue } from "cultnet-ts/wire"; import { cultMeshProviderSessionSchemas, diff --git a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts index f48dec74..acf06a41 100644 --- a/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts +++ b/packages/cultmesh-ts/test/realtime-quic-consumer.test.ts @@ -21,8 +21,8 @@ import { type CultMeshAuthorityRouteView, type CultMeshAuthorityTrustPolicy, type CultMeshP256PublicKey, -} from "@gamecult/cultnet-ts"; -import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts"; +} from "cultnet-ts"; +import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; import { CultMeshQuicNativeRuntime, diff --git a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts b/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts index f636fc95..e1843466 100644 --- a/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts +++ b/packages/cultmesh-ts/test/realtime-quic-session-manager.test.ts @@ -14,8 +14,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { CultMeshAuthorityTrustPolicy } from "@gamecult/cultnet-ts"; -import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "@gamecult/cultnet-ts/contracts"; +import type { CultMeshAuthorityTrustPolicy } from "cultnet-ts"; +import type { CultMeshAuthorityRouteMessage, CultMeshVerseDescriptorMessage } from "cultnet-ts/contracts"; import { CultMeshQuicRealtimeSessionManager, diff --git a/packages/cultmesh-ts/test/remote-write-authority.test.ts b/packages/cultmesh-ts/test/remote-write-authority.test.ts deleted file mode 100644 index 3c35171e..00000000 --- a/packages/cultmesh-ts/test/remote-write-authority.test.ts +++ /dev/null @@ -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 = {}, 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 { - this.requests.push(request); - return this.answer(request) as CultMeshRemoteWriteDecision | Promise; - } -} - -const grant = () => ({ granted: true }); - -interface Rig { - puts: CultMeshRudpDocumentPut[]; - deletes: CultMeshRudpDocumentDelete[]; - errors: Error[]; - replies: CultNetMessage[]; - peer: CultNetPeer; - settle(): Promise; - close(): void; -} - -let nextConnection = 0x10204000; - -async function rig(writeAuthority?: CultMeshRemoteWriteAuthority, extra: { maxPayloadBytes?: number; writeAuthorityTimeoutMs?: number } = {}): Promise { - 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): Promise { - 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[] = [ - { 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: }} - 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((resolve) => toClient.bind(0, "127.0.0.1", resolve)); - await new Promise((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>; client: { send(channel: string, payload: Uint8Array): void }; replies: CultNetMessage[] }) => Promise) { - 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(); } -}); diff --git a/packages/cultmesh-ts/test/rudp-document-server-send-failure.test.ts b/packages/cultmesh-ts/test/rudp-document-server-send-failure.test.ts index 21864a97..713a3bee 100644 --- a/packages/cultmesh-ts/test/rudp-document-server-send-failure.test.ts +++ b/packages/cultmesh-ts/test/rudp-document-server-send-failure.test.ts @@ -6,15 +6,15 @@ import assert from "node:assert/strict"; import { createSocket, type RemoteInfo, type Socket } from "node:dgram"; import test from "node:test"; import { documentPayloadFromBytes } from "@gamecult/cultcache-ts"; -import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; +import { encodeWireValue } from "cultnet-ts/wire"; import { CultNetDocumentRegistry, CultNetRudpSession, decodeRudpPacket, encodeRudpPacket, type CultNetPeer, -} from "@gamecult/cultnet-ts"; -import { CultMesh, CultMeshGrantAllWrites } from "../src/index"; +} from "cultnet-ts"; +import { CultMesh } from "../src/index"; async function waitFor(predicate: () => boolean, description: string): Promise { const startedAt = Date.now(); @@ -64,7 +64,6 @@ async function startServer(connectionId: number, socket: Socket): Promise 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 with self.open_transport() as transport: transport.send("schema", msgpack.packb(wire, use_bin_type=True)) @@ -320,7 +319,6 @@ class CultNetDatabaseSubscription: self._transport = 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: raise RuntimeError("CultNet database subscription is not open") wire = message.to_wire() if isinstance(message, CultNetMessage) else message diff --git a/packages/cultnet-py/src/cultnet_py/interop_peer.py b/packages/cultnet-py/src/cultnet_py/interop_peer.py index 56dddec8..e6cd4601 100644 --- a/packages/cultnet-py/src/cultnet_py/interop_peer.py +++ b/packages/cultnet-py/src/cultnet_py/interop_peer.py @@ -38,7 +38,6 @@ from .cultmesh_contracts import ( simulation_fact_document, ) -from .messages import unsupported_schema_version_error as _unsupported_schema_version_error from .schema_catalog import INTEROP_WIRE_CONTRACT, wire_message_schema_descriptors from .client import create_rudp_schema_transport from .transport import ( @@ -408,7 +407,14 @@ def is_unsupported_cultnet_version(schema_version: Any) -> bool: def unsupported_schema_version_error(message: dict[str, Any]) -> dict[str, Any]: - return _unsupported_schema_version_error(message.get("schemaVersion")).to_wire() + schema_version = message.get("schemaVersion") + return { + "schemaVersion": "cultnet.error.v0", + "messageId": str(message.get("messageId") or ""), + "error": f"Unsupported CultNet message schema: {schema_version!r}.", + "code": "unsupported_schema_version", + "details": {"schemaVersion": schema_version}, + } def handle_database_subscribe(state: PeerState, message: dict[str, Any], subscriptions: dict[str, DatabaseSubscription]) -> list[dict[str, Any]]: diff --git a/packages/cultnet-py/src/cultnet_py/messages.py b/packages/cultnet-py/src/cultnet_py/messages.py index 1a1f8e88..4fa8d336 100644 --- a/packages/cultnet-py/src/cultnet_py/messages.py +++ b/packages/cultnet-py/src/cultnet_py/messages.py @@ -28,63 +28,9 @@ def parse_message(payload: bytes) -> CultNetMessage: raise ValueError("CultNet schema-v0 messages must declare schemaVersion") body = dict(decoded) del body["schemaVersion"] - if schema_version == "cultnet.error.v0": - _validate_error_body(body) return CultNetMessage(schema_version=schema_version, body=body) -# Closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). Adding a -# code is one coordinated change across every runtime; a peer refuses a code it does not know. -CULTNET_ERROR_CODES: tuple[str, ...] = ( - "selection_invalid", - "cursor_stale", - "cursor_invalid", - "reference_outside_target", - "variant_unsupported", - "unowned_schema", - "write_not_authorized", - "unsupported_schema_version", - "message_invalid", -) - -_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current") - - -def _is_uint(value: Any) -> bool: - return isinstance(value, int) and not isinstance(value, bool) and value >= 0 - - -def _validate_error_body(body: dict[str, Any]) -> None: - """Refuse a cultnet.error.v0 body that is not the full-key shape the schema requires.""" - expected = ("error", "routingHint", "code", "details") - if set(body) != set(expected): - raise ValueError(f"cultnet.error.v0 must carry exactly {list(expected)}, got {sorted(body)}") - if not isinstance(body["error"], str) or not body["error"]: - raise ValueError("cultnet.error.v0 error must be a non-empty string") - code = body["code"] - if code is not None and (not isinstance(code, str) or code not in CULTNET_ERROR_CODES): - raise ValueError(f"cultnet.error.v0 code {code!r} is not a recognized refusal code") - hint = body["routingHint"] - if hint is not None: - if ( - not isinstance(hint, dict) - or set(hint) != {"shard", "reason"} - or not isinstance(hint["reason"], str) - or not (hint["shard"] is None or isinstance(hint["shard"], dict)) - ): - raise ValueError("cultnet.error.v0 routingHint must be null or {shard, reason}") - details = body["details"] - if details is not None: - if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS): - raise ValueError(f"cultnet.error.v0 details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}") - for key in ("field", "value"): - if details[key] is not None and not isinstance(details[key], str): - raise ValueError(f"cultnet.error.v0 details.{key} must be a string or null") - for key in ("asOf", "current"): - if details[key] is not None and not _is_uint(details[key]): - raise ValueError(f"cultnet.error.v0 details.{key} must be a non-negative integer or null") - - def hello( *, runtime_id: str, @@ -153,44 +99,6 @@ def verify_session( ) -def error_message( - error: str, - *, - code: str | None = None, - details: dict[str, Any] | None = None, - routing_hint: dict[str, Any] | None = None, -) -> CultNetMessage: - """Build cultnet.error.v0 with every key written (nil included), as the C# map formatter does. - - ``details`` may name any of field/value/asOf/current; the rest are written as null. - """ - body = { - "error": error, - "routingHint": routing_hint, - "code": code, - "details": None if details is None else {key: details.get(key) for key in _ERROR_DETAILS_KEYS}, - } - if details is not None and set(details) - set(_ERROR_DETAILS_KEYS): - raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}") - _validate_error_body(body) - return CultNetMessage("cultnet.error.v0", body) - - -def unsupported_schema_version_error(schema_version: Any) -> CultNetMessage: - """The refusal for a message whose schemaVersion this peer does not speak (C# ForUnsupportedSchemaVersion).""" - value = None if schema_version is None else str(schema_version) - return error_message( - f"unsupported_schema_version: {value or ''}", - code="unsupported_schema_version", - details={"field": "schemaVersion", "value": value}, - ) - - -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( *, nonce: str, diff --git a/packages/cultnet-py/src/cultnet_py/rudp_server.py b/packages/cultnet-py/src/cultnet_py/rudp_server.py deleted file mode 100644 index 2b162043..00000000 --- a/packages/cultnet-py/src/cultnet_py/rudp_server.py +++ /dev/null @@ -1,345 +0,0 @@ -"""Multi-peer RUDP listener: one UDP socket, one reliable session per remote endpoint. - -Matches the C# `CultNetRudpSocketTransportServer` (the reference): a Connect opens a peer's -session, a Connect from the same endpoint that is not a repeat replaces only that peer, and -a peer that ends, is refused or cannot be answered never disturbs the others. The caller -drives it: `try_receive_once` consumes one datagram, `poll_resends` sends what is due. -Everything runs on the caller's thread, so no session needs a lock. -""" -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass -import socket - -from .transport import ( - REFUSED_PACKET_REASON, - CultNetRudpPacket, - CultNetRudpPacketType, - CultNetRudpSession, - CultNetRudpSessionOptions, - CultNetReconnectPolicy, - CultNetTransportFrame, - CultNetTransportStats, - _channel_send_options, - _now_ms, - create_rudp_transport_profile, - decode_rudp_packet, - encode_rudp_packet, - is_permanent_send_error, -) - -REPLACED_PEER_REASON = b"replaced by a new Connect generation" - - -@dataclass -class CultNetRudpSocketServerOptions: - runtime_id: str - socket: socket.socket - connection_id: int - initial_sequence: int | None = None - resend_delay_ms: int = 250 - transport_id: str = "rudp" - max_payload_bytes: int | None = None - max_fragment_bytes: int | None = None - max_pending_reliable_packets: int | None = None - reconnect_policy: CultNetReconnectPolicy | None = None - accept_payload: bytes = b"" - - -class CultNetRudpSocketServerPeer: - """One remote endpoint's session. A peer that reconnects from the same endpoint is a new object.""" - - def __init__(self, remote_addr: tuple, connect_payload: bytes, session: CultNetRudpSession) -> None: - self.remote_addr = remote_addr - self.connect_payload = connect_payload - self.session = session - # The last transport-level reason the session ended with, once it has. - self.disconnect_reason: bytes | None = None - - @property - def connected(self) -> bool: - return self.session.connected - - @property - def pending_reliable_packet_count(self) -> int: - return self.session.outstanding_reliable_packet_count - - -@dataclass(frozen=True) -class CultNetRudpSocketServerFrame: - peer: CultNetRudpSocketServerPeer - frame: CultNetTransportFrame - - -def _remote_key(addr: tuple) -> tuple: - return tuple(addr[:2]) - - -class CultNetRudpSocketTransportServer: - def __init__(self, options: CultNetRudpSocketServerOptions) -> None: - if not options.runtime_id.strip(): - raise ValueError("Runtime id is required.") - self.socket = options.socket - self._options = options - host, port = self.socket.getsockname()[:2] - self.profile = create_rudp_transport_profile( - options.runtime_id, - transport_id=options.transport_id, - host=host, - port=port, - max_payload_bytes=options.max_payload_bytes, - max_fragment_bytes=options.max_fragment_bytes, - max_pending_reliable_packets=options.max_pending_reliable_packets, - reconnect_policy=options.reconnect_policy, - ) - self._peers: dict[tuple, CultNetRudpSocketServerPeer] = {} - self._delivered: deque[CultNetRudpSocketServerFrame] = deque() - self._bytes_received = 0 - self._bytes_sent = 0 - self._frames_received = 0 - self._frames_sent = 0 - self._packets_dropped = 0 - self._send_failures = 0 - self._closed = False - self.peer_disconnected: list = [] # callbacks taking the peer - - @property - def peers(self) -> tuple[CultNetRudpSocketServerPeer, ...]: - return tuple(self._peers.values()) - - @property - def stats(self) -> CultNetTransportStats: - return CultNetTransportStats( - bytes_received=self._bytes_received, - bytes_sent=self._bytes_sent, - frames_received=self._frames_received, - frames_sent=self._frames_sent, - packets_dropped=self._packets_dropped, - send_failures=self._send_failures, - ) - - def send(self, peer: CultNetRudpSocketServerPeer, channel_id: str, payload: bytes) -> None: - """Sends to one peer. Raises when that peer's session has ended or was replaced, or when a - datagram can never be sent as built: the first fragment failing queues nothing, a later one - ends the session, as the peer has seen part of the frame.""" - if self._peers.get(_remote_key(peer.remote_addr)) is not peer: - raise ConnectionError("RUDP peer is not connected.") - first_sequence = peer.session.next_sequence - packets = peer.session.send_many( - channel_id, - payload, - _channel_send_options(channel_id, _now_ms()), - max_fragment_bytes=self._options.max_fragment_bytes, - ) - for index, packet in enumerate(packets): - error = self._send_packet(peer.remote_addr, packet) - if error is None: - continue - if index == 0: - peer.session.withdraw_send(first_sequence) - else: - self._end_unsendable_peer(peer, error) - raise error - self._frames_sent += 1 - - def send_schema(self, peer: CultNetRudpSocketServerPeer, payload: bytes) -> None: - self.send(peer, "schema", payload) - - def broadcast(self, channel_id: str, payload: bytes) -> list[CultNetRudpSocketServerPeer]: - """Sends to every connected peer and returns the peers that could not be sent to; one - peer's failure never stops the others.""" - failed = [] - for peer in self.peers: - try: - self.send(peer, channel_id, payload) - except (OSError, ConnectionError, ValueError): - failed.append(peer) - return failed - - def disconnect(self, peer: CultNetRudpSocketServerPeer, reason: bytes = b"") -> bool: - """Ends one peer's session, telling it why. False when the session had already ended.""" - return self.end_peer_session(peer, reason) - - def end_peer_session(self, peer: CultNetRudpSocketServerPeer, reason: bytes) -> bool: - """Ends the session of a peer whose delivered frame the application could not decode or - handle, with a goodbye carrying the application's reason. The frame is already - acknowledged, so the session cannot be kept; the peer's frames still queued are not - delivered. A session that already ended owes no goodbye, since the endpoint may belong - to a new session; the call then returns False.""" - self._packets_dropped += 1 - if peer.session.ended: - return False - self._send_packet(peer.remote_addr, peer.session.end_with(reason)) - self._delivered = deque(item for item in self._delivered if item.peer is not peer) - self._retire(peer, reason) - return True - - def receive_once(self) -> CultNetRudpSocketServerFrame | None: - return self.try_receive_once()[1] - - def try_receive_once(self) -> tuple[bool, CultNetRudpSocketServerFrame | None]: - """Consumes one transport work item. Returns whether progress was made, independently of - whether it completed an application frame.""" - if self._delivered: - return True, self._delivered.popleft() - try: - wire, remote = self.socket.recvfrom(65535) - except (TimeoutError, BlockingIOError, ConnectionResetError): - return False, None - self._bytes_received += len(wire) - - try: - packet = decode_rudp_packet(wire) - except ValueError: - self._packets_dropped += 1 - return True, None - if packet.connection_id != self._options.connection_id: - self._packets_dropped += 1 - return True, None - - key = _remote_key(remote) - if packet.packet_type == CultNetRudpPacketType.CONNECT: - self._receive_connect(packet, remote, key) - return True, None - peer = self._peers.get(key) - if peer is None: - self._packets_dropped += 1 - return True, None - self._receive_from_peer(peer, key, packet) - return True, (self._delivered.popleft() if self._delivered else None) - - def poll_resends(self) -> None: - """Sends the reliable packets whose resend timers are due, for every peer.""" - for peer in self.peers: - if self._peers.get(_remote_key(peer.remote_addr)) is not peer: - continue - for packet in peer.session.due_resends(_now_ms()): - error = self._send_packet(peer.remote_addr, packet) - if error is not None: - self._end_unsendable_peer(peer, error) - break - - def close(self) -> None: - if self._closed: - return - self._closed = True - self.socket.close() - - def __enter__(self) -> "CultNetRudpSocketTransportServer": - return self - - def __exit__(self, *_: object) -> None: - self.close() - - def _receive_connect(self, packet: CultNetRudpPacket, remote: tuple, key: tuple) -> None: - admitted = self._peers.get(key) - accept_payload = self._options.accept_payload - # The admitted session decides whether this Connect repeats the one it accepted: a repeat - # gets the Accept already owed and queues nothing. Any other Connect is a new generation - # and ends the admitted session here. - if admitted is not None: - if admitted.session.connect_repeats(packet): - try: - reply = admitted.session.accept_connect(packet, _now_ms(), accept_payload) - except ValueError: - self._packets_dropped += 1 - return - error = self._send_packet(admitted.remote_addr, reply) - if error is not None: - self._end_unsendable_peer(admitted, error) - return - admitted.session.reset_peer_state() - - peer = CultNetRudpSocketServerPeer( - tuple(remote), - packet.payload, - CultNetRudpSession( - CultNetRudpSessionOptions( - connection_id=self._options.connection_id, - initial_sequence=self._options.initial_sequence, - resend_delay_ms=self._options.resend_delay_ms, - max_pending_reliable_packets=self._options.max_pending_reliable_packets, - ) - ), - ) - try: - accept = peer.session.accept_connect(packet, _now_ms(), accept_payload) - except ValueError: - self._packets_dropped += 1 - accept = None - # A peer that cannot be answered gets no session, and the one it replaced has already ended. - if accept is None or self._send_packet(peer.remote_addr, accept) is not None: - self._peers.pop(key, None) - if accept is not None: - self._packets_dropped += 1 - else: - self._peers[key] = peer - if admitted is not None: - admitted.disconnect_reason = REPLACED_PEER_REASON - self._notify_disconnected(admitted) - - def _receive_from_peer(self, peer: CultNetRudpSocketServerPeer, key: tuple, packet: CultNetRudpPacket) -> None: - session = peer.session - try: - result = session.receive(packet, _now_ms()) - except ValueError: - # receive() has already recorded the packet's reliable sequence, so the session cannot - # be kept: a retransmit would be acknowledged and the frame silently lost. - self._packets_dropped += 1 - self._send_packet(peer.remote_addr, session.end_with(REFUSED_PACKET_REASON)) - self._retire(peer, REFUSED_PACKET_REASON) - return - error = None - if result.reply is not None: - error = self._send_packet(peer.remote_addr, result.reply) - for ready in result.ready_to_send: - if error is None: - error = self._send_packet(peer.remote_addr, ready) - if result.disconnected: - self._retire(peer, result.disconnect_reason) - return - for frame in result.delivered: - self._delivered.append( - CultNetRudpSocketServerFrame(peer, CultNetTransportFrame(frame.channel_id, frame.payload)) - ) - self._frames_received += 1 - if error is None and packet.packet_type == CultNetRudpPacketType.DATA: - error = self._send_packet(peer.remote_addr, session.create_ack_for_received(packet.sequence)) - if error is not None: - self._end_unsendable_peer(peer, error) - - def _end_unsendable_peer(self, peer: CultNetRudpSocketServerPeer, error: OSError) -> None: - """An admitted peer owes a packet that can never be sent as built: its session ends, never - the poll, the peer is told, and the disconnect reason names the error. A session that has - already ended owes no goodbye, since the endpoint may belong to a new session.""" - if peer.session.ended: - return - goodbye = peer.session.end_unsendable_session(error) - self._send_packet(peer.remote_addr, goodbye) - self._retire(peer, goodbye.payload) - - def _retire(self, peer: CultNetRudpSocketServerPeer, reason: bytes | None) -> None: - # A peer a new Connect already replaced was reported then; its key now names the successor. - key = _remote_key(peer.remote_addr) - if self._peers.get(key) is not peer: - return - del self._peers[key] - peer.disconnect_reason = reason - self._notify_disconnected(peer) - - def _notify_disconnected(self, peer: CultNetRudpSocketServerPeer) -> None: - for callback in list(self.peer_disconnected): - callback(peer) - - def _send_packet(self, remote_addr: tuple, packet: CultNetRudpPacket) -> OSError | None: - """A transient failure is that peer's lost datagram: counted, never raised. A permanent - one (`is_permanent_send_error`) is returned for the caller to refuse or end the session.""" - wire = encode_rudp_packet(packet) - try: - self._bytes_sent += self.socket.sendto(wire, remote_addr) - except OSError as error: - if is_permanent_send_error(error): - return error - self._send_failures += 1 - return None diff --git a/packages/cultnet-py/src/cultnet_py/schema_catalog.py b/packages/cultnet-py/src/cultnet_py/schema_catalog.py index ee6fe430..6d71b3cd 100644 --- a/packages/cultnet-py/src/cultnet_py/schema_catalog.py +++ b/packages/cultnet-py/src/cultnet_py/schema_catalog.py @@ -12,19 +12,6 @@ PEER_EXCHANGE_REQUEST = "cultmesh.peer_exchange_request.v0" TRANSPORT_PROFILE_SCHEMA_VERSION = "cultnet.transport_profile.v0" TRANSPORT_PROFILE_SCHEMA_ID = f"{CULTNET_SCHEMA_BASE}/cultnet.transport-profile.schema.json" -# The closed code set of contracts/cultnet/cultnet.error.schema.json; a code outside it is one coordinated change across every runtime. -CULTNET_ERROR_CODES = ( - "selection_invalid", - "cursor_stale", - "cursor_invalid", - "reference_outside_target", - "variant_unsupported", - "unowned_schema", - "write_not_authorized", - "unsupported_schema_version", - "message_invalid", -) - WIRE_MESSAGE_SCHEMA_VERSIONS = ( ("cultnet.hello.v0", "CultNet Hello Message", f"{CULTNET_SCHEMA_BASE}/cultnet.hello.schema.json"), ("cultnet.error.v0", "CultNet Error Message", f"{CULTNET_SCHEMA_BASE}/cultnet.error.schema.json"), @@ -282,7 +269,7 @@ def shared_contract_schema_json(schema_id: str, title: str, schema_version: str) def wire_message_required_fields(schema_version: str) -> list[str]: required = { "cultnet.hello.v0": ["schemaVersion", "runtimeId"], - "cultnet.error.v0": ["schemaVersion", "error", "routingHint", "code", "details"], + "cultnet.error.v0": ["schemaVersion", "error"], "cultnet.login.v0": ["schemaVersion", "nonce", "auth", "password"], "cultnet.register.v0": ["schemaVersion", "nonce", "email", "password", "name"], "cultnet.verify.v0": ["schemaVersion", "nonce", "session"], @@ -368,10 +355,10 @@ def wire_message_schema_properties(schema_version: str) -> dict[str, Any]: "supportsSchemaCatalog": {"type": "boolean"}, }, "cultnet.error.v0": { - "error": {"type": "string", "minLength": 1}, - "routingHint": {"type": ["object", "null"]}, - "code": {"type": ["string", "null"], "enum": [None, *CULTNET_ERROR_CODES]}, - "details": {"type": ["object", "null"]}, + "messageId": common["messageId"], + "error": {"type": "string"}, + "code": {"type": "string"}, + "details": {"type": "object", "additionalProperties": True}, }, "cultnet.login.v0": { "nonce": {"type": "string", "minLength": 1}, diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py index 801d1313..76b38bf5 100644 --- a/packages/cultnet-py/src/cultnet_py/transport.py +++ b/packages/cultnet-py/src/cultnet_py/transport.py @@ -29,9 +29,6 @@ class CultNetTransportStats: # transport: malformed frames, another session's connection id, a sender # that is not the peer, or a packet the session refuses. packets_dropped: int = 0 - # Datagrams a multi-peer server could not send to one peer and lost: each is - # a transient failure, never a reason to end a session. - send_failures: int = 0 @dataclass(frozen=True) @@ -440,7 +437,6 @@ def create_rudp_transport_profile( UNSENDABLE_PACKET_REASON = "packet could not be sent" -REFUSED_PACKET_REASON = b"session refused a packet" # The fixed name of each permanent send failure, keyed by errno, Winsock equivalents included. _PERMANENT_SEND_ERROR_CODES = { @@ -604,28 +600,6 @@ class CultNetRudpSession: self._fragment_buffers.clear() self._fragment_sets_evicted = 0 - @property - def next_sequence(self) -> int: - return self._next_sequence - - def withdraw_send(self, first_sequence: int) -> None: - """Undoes a send that never reached the wire: every packet it created at or after - `first_sequence`, pending or queued, leaves no trace and its sequences are reissued, - so the peer never sees a gap. Only valid while nothing else has been sent since.""" - for sequence in [s for s in self._pending_reliable if s >= first_sequence]: - del self._pending_reliable[sequence] - kept = [p for p in self._queued_reliable if p.sequence < first_sequence] - self._queued_reliable.clear() - self._queued_reliable.extend(kept) - self._next_sequence = first_sequence - - def end_with(self, reason: bytes) -> CultNetRudpPacket: - """Ends a session whose peer's frame was refused or failed after the session acknowledged - it. The peer's sequences are forgotten before the goodbye is built, or its ack field would - acknowledge the very frame that was refused.""" - self.reset_peer_state() - return self.create_disconnect(reason) - def create_connect(self, now_ms: int = 0, payload: bytes = b"") -> CultNetRudpPacket: # A session that has had a peer starts a new generation: nothing it # learned from that peer describes the one this Connect reaches, and @@ -1256,7 +1230,6 @@ class CultNetRudpSocketTransportConnection: self._send_packet(self.session.create_connect(_now_ms(), payload)) 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( channel_id, payload, @@ -1424,8 +1397,11 @@ class CultNetRudpSocketTransportConnection: self.socket.close() def _end_refused_session(self) -> None: - reason = REFUSED_PACKET_REASON - goodbye = self.session.end_with(reason) + reason = b"session refused a packet" + # The goodbye is built after the reset, or its ack field would acknowledge + # the very frame the session refused. + self.session.reset_peer_state() + goodbye = self.session.create_disconnect(reason) self.disconnect_reason = reason try: self._send_packet(goodbye) @@ -1568,7 +1544,6 @@ class TcpFramedTransportConnection: ) 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": raise ValueError(f"tcp_framed transport only supports the schema channel, got {channel_id!r}") before = len(payload) + 4 diff --git a/packages/cultnet-py/tests/test_cultnet.py b/packages/cultnet-py/tests/test_cultnet.py index 6e8d2b71..19c88b6a 100644 --- a/packages/cultnet-py/tests/test_cultnet.py +++ b/packages/cultnet-py/tests/test_cultnet.py @@ -2235,11 +2235,9 @@ class CultNetTests(unittest.TestCase): self.assertEqual(delete_schema["properties"]["recordKey"]["type"], "string") error_schema = json.loads(by_version["cultnet.error.v0"]["schemaJson"]) - contract = json.loads((Path(__file__).resolve().parents[3] / "contracts" / "cultnet" / "cultnet.error.schema.json").read_text(encoding="utf-8")) - self.assertEqual(error_schema["required"], contract["required"]) - self.assertEqual(sorted(error_schema["properties"]), sorted(contract["properties"])) + self.assertEqual(error_schema["required"], ["schemaVersion", "error"]) self.assertEqual(error_schema["properties"]["error"]["type"], "string") - self.assertEqual(error_schema["properties"]["code"]["enum"], contract["properties"]["code"]["enum"]) + self.assertIn("details", error_schema["properties"]) login_schema = json.loads(by_version["cultnet.login.v0"]["schemaJson"]) self.assertEqual(login_schema["required"], ["schemaVersion", "nonce", "auth", "password"]) @@ -2580,8 +2578,7 @@ class CultNetTests(unittest.TestCase): self.assertEqual(len(responses), 1) self.assertEqual(responses[0]["schemaVersion"], "cultnet.error.v0") self.assertEqual(responses[0]["code"], "unsupported_schema_version") - self.assertEqual(responses[0]["details"]["value"], schema_version) - self.assertNotIn("messageId", responses[0]) + self.assertEqual(responses[0]["messageId"], "v1-refused") def test_cultnet_simulation_consensus_dedupes_witnesses_and_requires_quorum(self) -> None: from cultnet_py import CultNetSimulationConsensus diff --git a/packages/cultnet-py/tests/test_cultnet_error.py b/packages/cultnet-py/tests/test_cultnet_error.py deleted file mode 100644 index 6c27bda2..00000000 --- a/packages/cultnet-py/tests/test_cultnet_error.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import json -import unittest -from pathlib import Path - -import msgpack - -from cultnet_py import CULTNET_ERROR_CODES, error_message, message_invalid_error, parse_message, unsupported_schema_version_error - -# 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). -_REPO = Path(__file__).resolve().parents[3] -_VECTORS = json.loads((_REPO / "contracts/cultnet/interop/error-vectors.json").read_text(encoding="utf-8"))["vectors"] -_SCHEMA = json.loads((_REPO / "contracts/cultnet/cultnet.error.schema.json").read_text(encoding="utf-8")) - - -class CultNetErrorMessageTests(unittest.TestCase): - def test_closed_code_set_equals_the_schema_enum(self) -> None: - schema_codes = [c for c in _SCHEMA["properties"]["code"]["enum"] if c is not None] - self.assertEqual(sorted(CULTNET_ERROR_CODES), sorted(schema_codes)) - - def test_vectors_cover_every_code(self) -> None: - self.assertEqual({v["message"]["code"] for v in _VECTORS}, set(CULTNET_ERROR_CODES)) - - def test_decodes_csharp_and_rust_written_bytes_with_every_key(self) -> None: - for vector in _VECTORS: - message = parse_message(bytes.fromhex(vector["messagePackHex"])) - self.assertEqual(message.to_wire(), vector["message"], vector["name"]) - self.assertEqual( - list(message.to_wire()), ["schemaVersion", "error", "routingHint", "code", "details"], vector["name"] - ) - - def test_reencode_is_byte_identical(self) -> None: - for vector in _VECTORS: - expected = bytes.fromhex(vector["messagePackHex"]) - self.assertEqual(parse_message(expected).to_bytes(), expected, vector["name"]) - - def test_builder_produces_the_csharp_bytes(self) -> None: - by_name = {v["name"]: bytes.fromhex(v["messagePackHex"]) for v in _VECTORS} - stale = error_message( - "cursor_stale: cursor is stale", code="cursor_stale", details={"asOf": 1, "current": 2} - ) - self.assertEqual(stale.to_bytes(), by_name["cursor_stale"]) - invalid = error_message("cursor_invalid: cannot parse cursor", code="cursor_invalid") - self.assertEqual(invalid.to_bytes(), by_name["cursor_invalid"]) - selection = error_message( - "selection_invalid: blank key entry", - code="selection_invalid", - details={"field": "keys", "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( - 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: - wire = error_message("boom").to_wire() - self.assertEqual(wire, {"schemaVersion": "cultnet.error.v0", "error": "boom", "routingHint": None, "code": None, "details": None}) - - def test_unknown_code_is_refused_by_builder_and_decoder(self) -> None: - with self.assertRaises(ValueError): - error_message("x", code="not_a_code") - bad = msgpack.packb( - {"schemaVersion": "cultnet.error.v0", "error": "x", "routingHint": None, "code": "nope", "details": None}, - use_bin_type=True, - ) - with self.assertRaises(ValueError): - parse_message(bad) - - def test_dropping_code_or_details_is_refused(self) -> None: - vector = next(v for v in _VECTORS if v["name"] == "cursor_stale") - for dropped in ("code", "details", "routingHint"): - wire = dict(vector["message"]) - del wire[dropped] - with self.assertRaises(ValueError, msg=dropped): - parse_message(msgpack.packb(wire, use_bin_type=True)) - - def test_malformed_details_are_refused(self) -> None: - base = dict(next(v for v in _VECTORS if v["name"] == "cursor_stale")["message"]) - for details in ( - {"field": None, "value": None, "asOf": 1}, - {"field": None, "value": None, "asOf": -1, "current": 2}, - {"field": None, "value": None, "asOf": 1, "current": 2, "extra": 1}, - ): - with self.assertRaises(ValueError, msg=str(details)): - parse_message(msgpack.packb({**base, "details": details}, use_bin_type=True)) - with self.assertRaises(ValueError): - error_message("x", code="cursor_stale", details={"asOf": 1, "bogus": 2}) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultnet-py/tests/test_first_call.py b/packages/cultnet-py/tests/test_first_call.py deleted file mode 100644 index 9815043c..00000000 --- a/packages/cultnet-py/tests/test_first_call.py +++ /dev/null @@ -1,72 +0,0 @@ -"""The Python CultNet first call from docs/start/README.md. The docs show the region between the -`snippet` markers verbatim, and test_docs_show_the_tested_snippet fails when they differ.""" - -import unittest -from pathlib import Path - -# snippet:begin -import socket -from dataclasses import asdict, dataclass - -from cultcache_py import CultCache, define_document_type -from cultnet_py import ( - apply_raw_document_record, - document_put_raw, - encode_frame, - parse_message, - read_frame, - schema_document_map, -) - - -@dataclass -class Note: - title: str - body: str - - -note_doc = define_document_type( - "note", - encode=lambda note: asdict(note), - decode=lambda raw: Note(**raw), - name="title", -) - - -def first_call() -> Note: - sender, receiver = socket.socketpair() # an in-process pipe; use socket.create_connection for TCP - - # Send: a typed document as a cultnet.document_put_raw.v0 message in one length-prefixed frame. - put = document_put_raw( - message_id="put-1", - key="note:hello", - schema_id=note_doc.catalog_entry().schema_id, - stored_at="2026-01-01T00:00:00Z", - payload=note_doc.encode_payload(Note("Hello", "sent over CultNet")), - ) - sender.sendall(encode_frame(put.to_bytes())) - - # Receive: read the frame, parse the message, apply its record to a second cache. - message = parse_message(read_frame(receiver.makefile("rb"))) - assert message.schema_version == "cultnet.document_put_raw.v0" - inbox = CultCache.builder().register_document_type(note_doc).build() - apply_raw_document_record(inbox, schema_document_map([note_doc]), message.body["document"]) - sender.close() - receiver.close() - return inbox.get_required(note_doc, "note:hello") -# snippet:end - - -class FirstCallTests(unittest.TestCase): - def test_a_typed_document_crosses_a_pipe_into_a_second_cache(self) -> None: - self.assertEqual(first_call(), Note("Hello", "sent over CultNet")) - - def test_docs_show_the_tested_snippet(self) -> None: - source = Path(__file__).read_text(encoding="utf-8") - snippet = source.split("# snippet:begin\n", 1)[1].split("# snippet:end\n", 1)[0] - docs = (Path(__file__).parents[3] / "docs" / "start" / "README.md").read_text(encoding="utf-8") - self.assertIn("```python\n" + snippet + "```", docs) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultnet-py/tests/test_rudp_multipeer_server.py b/packages/cultnet-py/tests/test_rudp_multipeer_server.py deleted file mode 100644 index 3bb14640..00000000 --- a/packages/cultnet-py/tests/test_rudp_multipeer_server.py +++ /dev/null @@ -1,350 +0,0 @@ -"""One UDP socket serves many peers, each in its own session, as the C# reference does. - -Sockets bind port 0, so no test races another for a port. -""" -from __future__ import annotations - -import errno -import socket -import time -import unittest -from pathlib import Path - -from cultnet_py.rudp_server import ( - CultNetRudpSocketServerOptions, - CultNetRudpSocketTransportServer, -) -from cultnet_py.transport import ( - CultNetRudpPacketType, - CultNetRudpSendOptions, - CultNetRudpSession, - CultNetRudpSessionOptions, - decode_rudp_packet, - encode_rudp_packet, -) - -CONNECTION_ID = 0x43554C54 -VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "rudp-multipeer-server" / "transcript.txt" - - -def bind() -> socket.socket: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.bind(("127.0.0.1", 0)) - sock.settimeout(0.03) - return sock - - -def make_server(**kwargs) -> CultNetRudpSocketTransportServer: - options = dict(runtime_id="py-test", socket=bind(), connection_id=CONNECTION_ID, resend_delay_ms=5) - options.update(kwargs) - return CultNetRudpSocketTransportServer(CultNetRudpSocketServerOptions(**options)) - - -def pump(server: CultNetRudpSocketTransportServer) -> list: - """Consumes datagrams until the socket is idle, returning the frames delivered.""" - frames = [] - while True: - progressed, frame = server.try_receive_once() - if frame is not None: - frames.append(frame) - if not progressed: - return frames - - -def drain(sock: socket.socket) -> list[bytes]: - wires = [] - while True: - try: - wires.append(sock.recvfrom(65535)[0]) - except TimeoutError: - return wires - - -class Client: - def __init__(self, server: CultNetRudpSocketTransportServer, initial_sequence: int) -> None: - self.server = server - self.socket = bind() - self.session = CultNetRudpSession( - CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=initial_sequence) - ) - - @property - def addr(self) -> tuple: - return self.socket.getsockname() - - def transmit(self, packet) -> None: - self.socket.sendto(encode_rudp_packet(packet), self.server.socket.getsockname()) - - def connect(self, payload: bytes = b"") -> None: - self.transmit(self.session.create_connect(0, payload)) - pump(self.server) - self.receive() - - def receive(self) -> list: - packets = [decode_rudp_packet(wire) for wire in drain(self.socket)] - for packet in packets: - if self.session.connected or packet.packet_type == CultNetRudpPacketType.ACCEPT: - self.session.receive(packet, 0) - return packets - - def write(self, text: str) -> None: - self.transmit( - self.session.send("schema", text.encode(), CultNetRudpSendOptions(reliable=True, ordered=True)) - ) - - def peer(self): - return next(p for p in self.server.peers if p.remote_addr == self.addr) - - def close(self) -> None: - self.socket.close() - - -class MultiPeerServerTests(unittest.TestCase): - def setUp(self) -> None: - self.server = make_server(initial_sequence=500, accept_payload=b"acc") - self.addCleanup(self.server.close) - self.clients: list[Client] = [] - - def client(self, initial_sequence: int) -> Client: - client = Client(self.server, initial_sequence) - self.clients.append(client) - self.addCleanup(client.close) - return client - - def test_interleaved_peers_each_get_their_own_session_and_frames(self) -> None: - x, y = self.client(10), self.client(20) - x.connect(b"hx") - y.connect(b"hy") - self.assertEqual(len(self.server.peers), 2) - self.assertEqual(x.peer().connect_payload, b"hx") - self.assertEqual(y.peer().connect_payload, b"hy") - - x.write("x1") - y.write("y1") - x.write("x2") - y.write("y2") - frames = pump(self.server) - by_peer = {} - for item in frames: - by_peer.setdefault(item.peer.remote_addr, []).append(item.frame.payload) - self.assertEqual(by_peer, {x.addr: [b"x1", b"x2"], y.addr: [b"y1", b"y2"]}) - - self.server.send(x.peer(), "schema", b"to-x") - self.server.send(y.peer(), "schema", b"to-y") - for client, expected in ((x, b"to-x"), (y, b"to-y")): - data = [p for p in client.receive() if p.packet_type == CultNetRudpPacketType.DATA] - self.assertEqual([p.payload for p in data], [expected]) - - def test_one_peer_ending_does_not_disturb_the_other(self) -> None: - x, y = self.client(10), self.client(20) - x.connect() - y.connect() - y_peer = y.peer() - ended = [] - self.server.peer_disconnected.append(ended.append) - - x.transmit(x.session.create_disconnect(b"bye")) - pump(self.server) - self.assertEqual(len(self.server.peers), 1) - self.assertEqual([p.disconnect_reason for p in ended], [b"bye"]) - self.assertIs(y.peer(), y_peer) - - # Datagrams from the ended endpoint belong to no session. - before = self.server.stats.packets_dropped - x_late = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=99)) - x_late.accept_connect(x.session.create_connect(0), 0) - x.transmit(x_late.send("schema", b"late", CultNetRudpSendOptions(reliable=True, ordered=True))) - pump(self.server) - self.assertEqual(self.server.stats.packets_dropped, before + 1) - - y.write("still here") - self.assertEqual([f.frame.payload for f in pump(self.server)], [b"still here"]) - self.server.send(y_peer, "schema", b"reply") - self.assertEqual( - [p.payload for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"reply"] - ) - - def test_a_new_connect_from_the_same_endpoint_replaces_only_that_peer(self) -> None: - x, y = self.client(10), self.client(20) - x.connect() - y.connect() - old_y = y.peer() - ended = [] - self.server.peer_disconnected.append(ended.append) - - y.session = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=30)) - y.connect(b"again") - self.assertEqual(ended, [old_y]) - self.assertEqual(old_y.disconnect_reason, b"replaced by a new Connect generation") - self.assertIsNot(y.peer(), old_y) - self.assertEqual(y.peer().connect_payload, b"again") - with self.assertRaises(ConnectionError): - self.server.send(old_y, "schema", b"to the old session") - self.assertEqual(len(self.server.peers), 2) - - def test_a_repeated_connect_is_answered_with_the_owed_accept_and_starts_nothing(self) -> None: - x = self.client(10) - connect = x.session.create_connect(0, b"h") - x.transmit(connect) - pump(self.server) - first = x.peer() - x.transmit(connect) - pump(self.server) - self.assertIs(x.peer(), first) - accepts = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.ACCEPT] - self.assertEqual(len(accepts), 2) - self.assertEqual(accepts[0].sequence, accepts[1].sequence) - - def test_strays_are_dropped_and_counted(self) -> None: - x = self.client(10) - x.socket.sendto(b"not rudp", self.server.socket.getsockname()) - other = CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID + 1, initial_sequence=1)) - x.transmit(other.create_connect(0)) - x.transmit( - CultNetRudpSession(CultNetRudpSessionOptions(connection_id=CONNECTION_ID, initial_sequence=1)).create_ping() - ) - pump(self.server) - self.assertEqual(self.server.stats.packets_dropped, 3) - self.assertEqual(self.server.peers, ()) - - def test_resends_go_only_to_peers_with_unacknowledged_writes(self) -> None: - x, y = self.client(10), self.client(20) - x.connect() - y.connect() - self.server.send(x.peer(), "schema", b"owed") - x.receive() # the first transmission is lost to the test: x never acks - y.receive() - time.sleep(0.02) - self.server.poll_resends() - x_data = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DATA] - self.assertEqual([p.payload for p in x_data], [b"owed"]) - self.assertEqual([p for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], []) - - def test_broadcast_reaches_every_peer(self) -> None: - x, y = self.client(10), self.client(20) - x.connect() - y.connect() - self.assertEqual(self.server.broadcast("schema", b"all"), []) - for client in (x, y): - self.assertEqual( - [p.payload for p in client.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"all"] - ) - - def test_an_application_ending_a_session_tells_the_peer_and_drops_its_queued_frames(self) -> None: - x, y = self.client(10), self.client(20) - x.connect() - y.connect() - x.write("x1") - y.write("y1") - # Both frames are decoded into the server's queue; ending x discards x's. - progressed, first = self.server.try_receive_once() - self.assertTrue(progressed) - pump_rest = pump(self.server) - frames = [first] + pump_rest if first else pump_rest - peer_x = x.peer() - self.assertTrue(self.server.end_peer_session(peer_x, b"server could not handle a message")) - self.assertFalse(self.server.end_peer_session(peer_x, b"again"), "an ended session owes no second goodbye") - goodbye = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DISCONNECT] - self.assertEqual([p.payload for p in goodbye], [b"server could not handle a message"]) - self.assertEqual(len(self.server.peers), 1) - self.assertEqual({f.peer.remote_addr for f in frames}, {x.addr, y.addr}) - - def test_a_datagram_too_large_to_send_is_that_callers_error_and_queues_nothing(self) -> None: - x = self.client(10) - x.connect() - peer = x.peer() - owed = peer.pending_reliable_packet_count # the Accept, not yet acknowledged - with self.assertRaises(OSError) as raised: - self.server.send(peer, "schema", bytes(70_000)) - self.assertEqual(raised.exception.errno, errno.EMSGSIZE) - self.assertEqual(peer.pending_reliable_packet_count, owed) - self.assertEqual(len(self.server.peers), 1) - # The refused write left no gap in the sequence space the peer is waiting on. - self.server.send(peer, "schema", b"fine") - data = [p for p in x.receive() if p.packet_type == CultNetRudpPacketType.DATA] - self.assertEqual([(p.sequence, p.payload) for p in data], [(501, b"fine")]) - - -class UnsendableAfterFirstFragmentSocket: - """Delegates to a real socket; fails permanently once its budget of sends to one address is spent.""" - - def __init__(self, real: socket.socket, addr: tuple, budget: int) -> None: - self._real, self._addr, self._budget = real, addr, budget - - def sendto(self, wire: bytes, addr: tuple) -> int: - if tuple(addr) == self._addr: - if self._budget == 0: - raise OSError(errno.EMSGSIZE, "Message too long") - self._budget -= 1 - return self._real.sendto(wire, addr) - - def __getattr__(self, name: str): - return getattr(self._real, name) - - -class UnsendableSessionTests(unittest.TestCase): - def test_a_later_fragment_that_cannot_be_sent_ends_that_session_only(self) -> None: - server = make_server(initial_sequence=500, max_fragment_bytes=8) - self.addCleanup(server.close) - x, y = Client(server, 10), Client(server, 20) - self.addCleanup(x.close) - self.addCleanup(y.close) - x.connect() - y.connect() - x_peer = x.peer() - server.socket = UnsendableAfterFirstFragmentSocket(server.socket, x.addr, budget=1) - ended = [] - server.peer_disconnected.append(ended.append) - - with self.assertRaises(OSError): - server.send(x_peer, "schema", bytes(24)) - self.assertEqual(ended, [x_peer]) - self.assertEqual(x_peer.disconnect_reason, b"packet could not be sent: EMSGSIZE") - self.assertEqual(len(server.peers), 1) - # The goodbye could not be sent either (same address), so the peer is only retired; y is unharmed. - server.send(y.peer(), "schema", b"ok") - self.assertEqual( - [p.payload for p in y.receive() if p.packet_type == CultNetRudpPacketType.DATA], [b"ok"] - ) - - -class ReferenceTranscriptTests(unittest.TestCase): - """Replays the datagrams the C# reference listener was captured answering, byte for byte.""" - - def test_the_listener_answers_with_the_bytes_the_reference_does(self) -> None: - server = make_server( - connection_id=0x43554C54, initial_sequence=500, max_fragment_bytes=8, accept_payload=b"acc", resend_delay_ms=250 - ) - self.addCleanup(server.close) - sockets = {label: bind() for label in ("X", "Y")} - for sock in sockets.values(): - self.addCleanup(sock.close) - target = server.socket.getsockname() - - steps: list[tuple[list[str], list[tuple[str, str]]]] = [] - for line in VECTORS.read_text().splitlines(): - if line.startswith("#") or not line.strip(): - continue - fields = line.split(" ") - if fields[0] == "out": - steps[-1][1].append((fields[1], fields[2])) - else: - steps.append((fields, [])) - self.assertGreater(len(steps), 10) - - for action, expected in steps: - if action[0] == "in": - sockets[action[1]].sendto(bytes.fromhex(action[2]), target) - while not server.try_receive_once()[0]: - time.sleep(0.001) - else: - peer = next(p for p in server.peers if p.remote_addr == sockets[action[1]].getsockname()) - server.send(peer, action[2], bytes.fromhex(action[3])) - actual = [(label, wire.hex()) for label in ("X", "Y") for wire in drain(sockets[label])] - self.assertEqual(actual, expected, f"after {' '.join(action[:2])}") - self.assertEqual(len(server.peers), 1) - self.assertEqual(server.stats.packets_dropped, 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/packages/cultnet-rs/CHANGELOG.md b/packages/cultnet-rs/CHANGELOG.md index 384395ec..c2600756 100644 --- a/packages/cultnet-rs/CHANGELOG.md +++ b/packages/cultnet-rs/CHANGELOG.md @@ -8,7 +8,6 @@ only in git history. ### 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 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. diff --git a/packages/cultnet-rs/README.md b/packages/cultnet-rs/README.md index c356b97b..48ac2dd3 100644 --- a/packages/cultnet-rs/README.md +++ b/packages/cultnet-rs/README.md @@ -1,6 +1,6 @@ # 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 CultCache replication without making callers paw through raw envelopes like we lost a bet. @@ -100,7 +100,7 @@ game-session authority remain separate responsibilities. ## 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.snapshot_response_raw.v0` diff --git a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json index 07befacc..e3b94c2b 100644 --- a/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json +++ b/packages/cultnet-rs/contracts/cultnet/cultnet.error.schema.json @@ -28,7 +28,7 @@ "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).", "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"] }, "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.", @@ -36,8 +36,8 @@ "additionalProperties": false, "required": ["field", "value", "asOf", "current"], "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"] }, - "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"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\".", "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.", "type": ["string", "null"] }, "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 } } diff --git a/packages/cultnet-rs/src/contracts.rs b/packages/cultnet-rs/src/contracts.rs index ebc32afa..56afaa7c 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -247,8 +247,6 @@ pub enum CultNetErrorCode { VariantUnsupported, UnownedSchema, WriteNotAuthorized, - UnsupportedSchemaVersion, - MessageInvalid, } impl CultNetErrorCode { @@ -261,8 +259,6 @@ impl CultNetErrorCode { Self::VariantUnsupported => "variant_unsupported", Self::UnownedSchema => "unowned_schema", Self::WriteNotAuthorized => "write_not_authorized", - Self::UnsupportedSchemaVersion => "unsupported_schema_version", - Self::MessageInvalid => "message_invalid", } } @@ -275,8 +271,6 @@ impl CultNetErrorCode { "variant_unsupported" => Ok(Self::VariantUnsupported), "unowned_schema" => Ok(Self::UnownedSchema), "write_not_authorized" => Ok(Self::WriteNotAuthorized), - "unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion), - "message_invalid" => Ok(Self::MessageInvalid), _ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")), } } diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index 6a2aa20b..37bf73d8 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -2198,7 +2198,6 @@ impl CultNetRudpSocketTransportConnection { 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) -> Result<()> { let options = channel_send_options( @@ -2217,8 +2216,6 @@ impl CultNetRudpSocketTransportConnection { /// Sends one frame on a reliable channel whose packets cannot expire and /// 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( &mut self, channel_id: &str, @@ -2312,9 +2309,6 @@ impl CultNetRudpSocketTransportConnection { 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<()> { let original_timeout = self.socket.read_timeout()?; let poll_timeout = original_timeout diff --git a/packages/cultnet-rs/src/transport.rs b/packages/cultnet-rs/src/transport.rs index 57070dc3..f2c196d4 100644 --- a/packages/cultnet-rs/src/transport.rs +++ b/packages/cultnet-rs/src/transport.rs @@ -252,7 +252,6 @@ impl TcpFramedTransportConnection where 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<()> { if channel_id != "schema" { return Err(anyhow!( diff --git a/packages/cultnet-rs/tests/error_message.rs b/packages/cultnet-rs/tests/error_message.rs index daca2efc..58732295 100644 --- a/packages/cultnet-rs/tests/error_message.rs +++ b/packages/cultnet-rs/tests/error_message.rs @@ -173,36 +173,6 @@ fn cases() -> Vec { }), }, }, - Case { - name: "unsupported_schema_version", - // C#: ForUnsupportedSchemaVersion("cultnet.hello.v1") - csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0", - message: || CultNetMessage::Error { - error: "unsupported_schema_version: cultnet.hello.v1".to_string(), - code: Some(CultNetErrorCode::UnsupportedSchemaVersion), - details: Some(CultNetErrorDetails { - field: Some("schemaVersion".to_string()), - value: Some("cultnet.hello.v1".to_string()), - as_of: None, - current: None, - }), - }, - }, - 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, - }), - }, - }, ] } @@ -241,22 +211,3 @@ fn round_trip_is_stable() { assert_eq!(decoded, message, "{}: round trip mismatch", case.name); } } - -/// `contracts/cultnet/interop/error-vectors.json` is the file the TypeScript and Python runtimes -/// decode; it must stay byte-identical to the inline C# captures above, or those runtimes would be -/// pinned to bytes this crate no longer pins. -#[test] -fn error_vectors_file_matches_the_inline_csharp_captures() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../contracts/cultnet/interop/error-vectors.json"); - let file: serde_json::Value = - serde_json::from_str(&std::fs::read_to_string(&path).expect("read error-vectors.json")) - .expect("parse error-vectors.json"); - let vectors = file["vectors"].as_array().expect("vectors array"); - let all = cases(); - assert_eq!(vectors.len(), all.len(), "vector count differs from the inline cases"); - for (vector, case) in vectors.iter().zip(all.iter()) { - assert_eq!(vector["name"].as_str(), Some(case.name)); - assert_eq!(vector["messagePackHex"].as_str(), Some(case.csharp_hex), "{}", case.name); - } -} diff --git a/packages/cultnet-rs/tests/first_call.rs b/packages/cultnet-rs/tests/first_call.rs deleted file mode 100644 index e2ef1e6f..00000000 --- a/packages/cultnet-rs/tests/first_call.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! The Rust CultNet first call from docs/start/README.md. The docs show the region between the -//! `snippet` markers verbatim, and `docs_show_the_tested_snippet` fails when they differ. - -use cultcache_rs::{CultCache, DatabaseEntry}; - -// snippet:begin -use std::net::{TcpListener, TcpStream}; -use std::thread; - -use cultnet_rs::{ - CultNetDocumentBinding, CultNetDocumentPutOptions, CultNetDocumentRegistry, CultNetWireContract, - TcpFramedTransportConnection, create_tcp_framed_transport_profile, - decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, -}; - -#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] -#[cultcache(type = "note")] -struct Note { - #[cultcache(key = 0)] - title: String, - #[cultcache(key = 1)] - body: String, -} - -fn first_call() -> anyhow::Result { - let mut documents = CultNetDocumentRegistry::new(); - documents.register(CultNetDocumentBinding::for_entry_with_schema_id::( - "gamecult.first_call_note.v1".to_string(), - "gamecult.first_call_note.v1".to_string(), - ))?; - let profile = |runtime_id| create_tcp_framed_transport_profile(runtime_id, Default::default()); - - // Receiver: a loopback listener that applies the document put it receives to its own cache. - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - let receiver = thread::spawn({ - let (documents, profile) = (documents.clone(), profile("receiver")); - move || -> anyhow::Result { - let (stream, _) = listener.accept()?; - let frame = TcpFramedTransportConnection::new(stream, profile).receive()?; - let message = decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?; - let mut inbox = CultCache::new(); - inbox.register_entry_type::()?; - documents.apply_raw_document_put_message::(&mut inbox, &message) - } - }); - - // Sender: build a typed put message, encode it for the wire, send one frame. - let put = documents.create_raw_document_put_message( - "put-1", - "note:hello", - &Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }, - CultNetDocumentPutOptions::default(), - )?; - let bytes = encode_cultnet_message_to_vec(&put, CultNetWireContract::CultNetSchemaV0)?; - TcpFramedTransportConnection::new(TcpStream::connect(("127.0.0.1", port))?, profile("sender")) - .send("schema", &bytes)?; - - receiver.join().expect("receiver thread") -} -// snippet:end - -#[test] -fn a_typed_document_crosses_loopback_into_a_second_cache() { - let note = first_call().unwrap(); - assert_eq!(note, Note { title: "Hello".to_string(), body: "sent over CultNet".to_string() }); -} - -#[test] -fn docs_show_the_tested_snippet() { - let source = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/first_call.rs")).unwrap(); - let begin = source.find("// snippet:begin\n").unwrap() + "// snippet:begin\n".len(); - let end = source.find("// snippet:end\n").unwrap(); - let docs = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../docs/start/README.md")).unwrap(); - let block = format!("```rust\n{}```", &source[begin..end]); - assert!(docs.contains(&block), "docs/start/README.md no longer shows tests/first_call.rs verbatim"); -} diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 739396cd..eae823c4 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -1,24 +1,16 @@ # 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/). ## Unreleased ### 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. -- 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 -- 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. - - `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. - Needs the cultcache-ts release that ships with this change (its manifest range moves with that release): the Idunn runtime authority reader calls `readSingleFileStore`, which 0.14.x does not export. An absent authority file is now a plain `Error` without a code. diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 1fb37a3f..c766793b 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -1,6 +1,6 @@ # 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 security/session helpers, and state replication that hands typed values into a `CultCache` instead of making consumer code paw raw database envelopes. @@ -42,7 +42,7 @@ We want: So the split is: - `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 transport layer. @@ -65,7 +65,6 @@ Current message families: - `cultnet.snapshot_request.v0` - `cultnet.snapshot_response.v0` - `cultnet.snapshot_response_raw.v0` -- `cultnet.snapshot_request.v1`, `cultnet.database_subscribe.v1`, `cultnet.snapshot_response_raw.v1` (typed-selection carriers; no selection engine yet) - `cultnet.sample.change_name.v0` - `cultnet.sample.chat.v0` @@ -80,7 +79,7 @@ CultNet without inventing a second ontology first. ## 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 - runtime schema verifiers @@ -111,7 +110,7 @@ which exports: ## 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. Current wire-contract options: @@ -129,7 +128,7 @@ away from the C# lineage. ## 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 reference, their MessagePack form, hash normalization, both record-key grammars, SHA-256 hashing through WebCrypto, and `packContent`. It is browser-safe: it @@ -160,7 +159,7 @@ imports no `node:` module, so a browser bundle can take it. ## Local Fast Lane 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.snapshot_response_raw.v0` @@ -185,7 +184,7 @@ cuts out the dumbest part of the trip. Apps sharing a CultNet space should be able to ask each other what they can exchange without resorting to folklore. -`@gamecult/cultnet-ts` now includes: +`cultnet-ts` now includes: - `cultnet.schema_catalog_request.v0` - `cultnet.schema_catalog_response.v0` @@ -232,7 +231,7 @@ import { createTcpFramedCultNetPeer, defineCultNetDocumentBinding, ghostlightAgentStateGeneratedContract, -} from "@gamecult/cultnet-ts"; +} from "cultnet-ts"; const ghostlightStateDefinition = defineDocumentType({ type: "ghostlight.agent-state", @@ -270,7 +269,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 "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 maps them back to the exact C# union payload shape at the wire boundary. diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index 60c9e13f..1fc6abed 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -1,5 +1,5 @@ { - "name": "@gamecult/cultnet-ts", + "name": "cultnet-ts", "version": "0.3.0", "description": "CultNet-style typed direct-pipe messaging and state replication for TypeScript runtimes.", "main": "dist/index.js", @@ -73,7 +73,7 @@ "dependencies": { "@msgpack/msgpack": "^3.1.3", "ajv": "^8.17.1", - "@gamecult/cultcache-ts": "^0.15.0" + "@gamecult/cultcache-ts": "^0.14.1" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index d4bc1b19..31188790 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -1,4 +1,3 @@ -import { isNullOrWhiteSpaceCSharp } from "./cultmesh-authority"; import type { DocumentPayload } from "@gamecult/cultcache-ts/document"; import Ajv2020, { type ValidateFunction } from "ajv/dist/2020.js"; @@ -34,25 +33,8 @@ import verseCatalogRequestSchema from "../contracts/cultnet/cultmesh.verse-catal import verseCatalogResponseSchema from "../contracts/cultnet/cultmesh.verse-catalog-response.schema.json"; import sessionOpenSchema from "../contracts/cultnet/cultmesh.session-open.schema.json"; import sessionAcceptedSchema from "../contracts/cultnet/cultmesh.session-accepted.schema.json"; -import selectionSchema from "../contracts/cultnet/cultnet.selection.schema.json"; -import rawDocumentHeaderSchema from "../contracts/cultnet/cultnet.raw-document-header.schema.json"; -import snapshotRequestV1Schema from "../contracts/cultnet/cultnet.snapshot-request.v1.schema.json"; -import snapshotResponseRawV1Schema from "../contracts/cultnet/cultnet.snapshot-response-raw.v1.schema.json"; -import databaseSubscribeV1Schema from "../contracts/cultnet/cultnet.database-subscribe.v1.schema.json"; import ghostlightAgentStateSchema from "../contracts/cultnet/ghostlight.agent-state.schema.json"; -import { - encodeSelectionCarrierForWire, - normalizeSelectionCarrierNulls, - type CultNetSelectionCarrier, -} from "./selection-carriers"; -import { - validateSelectionShape, - type CultNetEdge, - type CultNetRawDocumentHeader, - type CultNetSelection, -} from "./selection"; - export type CultNetWireContract = "cultnet.schema.v0" | "gamecult.networking.v0"; export type CultNetSchemaVersion = @@ -71,9 +53,6 @@ export type CultNetSchemaVersion = | "cultnet.snapshot_response.v0" | "cultnet.snapshot_response_raw.v0" | "cultnet.database_subscribe.v0" - | "cultnet.snapshot_request.v1" - | "cultnet.snapshot_response_raw.v1" - | "cultnet.database_subscribe.v1" | "cultnet.database_unsubscribe.v0" | "cultnet.database_change_raw.v0" | "cultnet.schema_catalog_request.v0" @@ -186,73 +165,9 @@ export interface CultNetLoginSuccessMessage { session: string; } -/** The closed refusal-code set of cultnet.error.v0 (contracts/cultnet/cultnet.error.schema.json). */ -export const CULTNET_ERROR_CODES = [ - "selection_invalid", - "cursor_stale", - "cursor_invalid", - "reference_outside_target", - "variant_unsupported", - "unowned_schema", - "write_not_authorized", - "unsupported_schema_version", - "message_invalid", -] as const; - -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 { - shard: Record | null; - reason: string; -} - -export interface CultNetErrorDetails { - field: string | null; - value: string | null; - asOf: number | null; - current: number | null; -} - -/** Every key is written on the wire, null included (the C# map formatter never omits a key). */ export interface CultNetErrorMessage { schemaVersion: "cultnet.error.v0"; error: string; - routingHint: CultNetErrorRoutingHint | null; - code: CultNetErrorCode | null; - details: CultNetErrorDetails | null; } export interface CultNetSampleChangeNameMessage { @@ -332,46 +247,6 @@ export interface CultNetDatabaseSubscribeMessage { supportedBodyTransports?: string[]; } -/** A document snapshot through a typed selection (docs/cultnet-selection-cut.md). */ -export interface CultNetSnapshotRequestV1Message { - schemaVersion: "cultnet.snapshot_request.v1"; - messageId: string; - selection: CultNetSelection; - shardId?: string; - shardEpoch?: number; -} - -/** A live subscription through a typed selection. */ -export interface CultNetDatabaseSubscribeV1Message { - schemaVersion: "cultnet.database_subscribe.v1"; - messageId: string; - subscriptionId: string; - selection: CultNetSelection; - /** Default true. */ - includeSnapshot?: boolean; - consumerRuntimeId?: string; - bodyIds?: string[]; - supportedBodyTransports?: string[]; -} - -/** One selection page: exactly one of `headers` / `documents` is present, by projection. */ -export interface CultNetSnapshotResponseRawV1Message { - schemaVersion: "cultnet.snapshot_response_raw.v1"; - messageId: string; - /** Rows on this page. */ - matched: number; - /** The snapshot this page is exact as of. */ - asOf: number; - /** Cursor for the next page, absent on the last page. */ - next?: string; - headers?: CultNetRawDocumentHeader[]; - documents?: CultNetRawDocumentRecord[]; - edges?: CultNetEdge[]; - shardId?: string; - shardEpoch?: number; - shardLogSequence?: number; -} - export interface CultNetDatabaseUnsubscribeMessage { schemaVersion: "cultnet.database_unsubscribe.v0"; messageId: string; @@ -784,9 +659,6 @@ export type CultNetMessage = | CultNetSnapshotResponseMessage | CultNetSnapshotResponseRawMessage | CultNetDatabaseSubscribeMessage - | CultNetSnapshotRequestV1Message - | CultNetSnapshotResponseRawV1Message - | CultNetDatabaseSubscribeV1Message | CultNetDatabaseUnsubscribeMessage | CultNetDatabaseChangeRawMessage | CultNetSchemaCatalogRequestMessage @@ -821,9 +693,6 @@ const CULTNET_MESSAGE_SCHEMAS = [ snapshotResponseSchema, snapshotResponseRawSchema, databaseSubscribeSchema, - snapshotRequestV1Schema, - snapshotResponseRawV1Schema, - databaseSubscribeV1Schema, databaseUnsubscribeSchema, databaseChangeRawSchema, schemaCatalogRequestSchema, @@ -842,8 +711,6 @@ const cultNetValidators = new Map(); for (const schema of [ documentRecordSchema, rawDocumentRecordSchema, - rawDocumentHeaderSchema, - selectionSchema, documentMutationContractSchema, transportProfileSchema, schemaDescriptorSchema, @@ -890,20 +757,6 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon return input as CultNetSnapshotResponseRawMessage; } - if (schemaVersion === "cultnet.snapshot_response_raw.v1") { - validateSnapshotResponseRawV1Message(input); - return input as CultNetSnapshotResponseRawV1Message; - } - - if (schemaVersion === "cultnet.snapshot_request.v1" || schemaVersion === "cultnet.database_subscribe.v1") { - // The door first, so an empty or blank schemas/keys entry is a typed refusal naming its field - // rather than a generic schema-validation failure. - const selection = (input as { selection?: unknown }).selection; - if (selection && typeof selection === "object") { - validateSelectionShape(selection as CultNetSelection); - } - } - if (schemaVersion === "cultnet.database_change_raw.v0") { validateDatabaseChangeRawMessage(input); return input as CultNetDatabaseChangeRawMessage; @@ -925,14 +778,7 @@ export function encodeCultNetMessageForWire( return encodeGameCultNetworkingMessage(message); } - switch (message.schemaVersion) { - case "cultnet.snapshot_request.v1": - case "cultnet.database_subscribe.v1": - case "cultnet.snapshot_response_raw.v1": - return encodeSelectionCarrierForWire(message as CultNetSelectionCarrier); - default: - return message; - } + return message; } export function validateGhostlightAgentState(input: unknown): GhostlightAgentStateDocument { @@ -993,8 +839,10 @@ function parseGameCultNetworkingMessage(input: unknown): CultNetMessage { session: requireLegacyBytes(payload[1], "LoginSuccessMessage.Session"), }; case 4: - // The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone. - return cultNetTextError(requireLegacyString(payload[0], "ErrorMessage.Error")); + return { + schemaVersion: "cultnet.error.v0", + error: requireLegacyString(payload[0], "ErrorMessage.Error"), + }; case 5: return { schemaVersion: "cultnet.sample.change_name.v0", @@ -1050,7 +898,6 @@ function encodeGameCultNetworkingMessage(message: CultNetMessage): [number, unkn decodeBase64Url(message.session, "LoginSuccessMessage.Session"), ]]; case "cultnet.error.v0": - // The gamecult.networking.v0 union's ErrorMessage carries only the text. return [4, [message.error]]; case "cultnet.sample.change_name.v0": return [5, [message.name]]; @@ -1255,89 +1102,6 @@ function validateSnapshotResponseRawMessage( }); } -function validateSnapshotResponseRawV1Message( - input: unknown, -): asserts input is CultNetSnapshotResponseRawV1Message { - const candidate = validateBinaryMessageRoot(input, "cultnet.snapshot_response_raw.v1"); - const fail = (path: string, problem: string): never => { - throw new Error(`Validation failed for cultnet.snapshot_response_raw.v1: ${path}: ${problem}`); - }; - for (const counter of ["matched", "asOf"] as const) { - const value = candidate[counter]; - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { - fail(`/${counter}`, "must be a non-negative integer"); - } - } - if (typeof candidate.next !== "undefined" && typeof candidate.next !== "string") { - fail("/next", "must be a string"); - } - const hasHeaders = typeof candidate.headers !== "undefined"; - const hasDocuments = typeof candidate.documents !== "undefined"; - if (hasHeaders === hasDocuments) { - fail("/", "exactly one of headers and documents must be present"); - } - if (hasHeaders) { - if (!Array.isArray(candidate.headers)) fail("/headers", "must be an array"); - (candidate.headers as unknown[]).forEach((header, index) => validateRawDocumentHeader(header, `/headers/${index}`)); - } else { - if (!Array.isArray(candidate.documents)) fail("/documents", "must be an array"); - (candidate.documents as unknown[]).forEach((document, index) => validateRawDocumentRecord(document, `/documents/${index}`)); - } - if (typeof candidate.edges !== "undefined") { - if (!Array.isArray(candidate.edges)) fail("/edges", "must be an array"); - (candidate.edges as unknown[]).forEach((edge, index) => validateEdge(edge, `/edges/${index}`)); - } - requireOptionalNonEmptyString(candidate.shardId, "/shardId"); - for (const counter of ["shardEpoch", "shardLogSequence"] as const) { - const value = candidate[counter]; - if (typeof value !== "undefined" && (typeof value !== "number" || !Number.isSafeInteger(value))) { - fail(`/${counter}`, "must be an integer"); - } - } -} - -function validateRawDocumentHeader(input: unknown, instancePath: string): void { - if (!input || typeof input !== "object") { - throw new Error(`Validation failed for raw CultNet document header: ${instancePath}: must be an object`); - } - const candidate = input as Record; - requireNonEmptyString(candidate.schemaId, `${instancePath}/schemaId`); - requireOptionalNonEmptyString(candidate.schemaName, `${instancePath}/schemaName`); - requireOptionalNonEmptyString(candidate.schemaVersion, `${instancePath}/schemaVersion`); - requireOptionalNonEmptyString(candidate.schemaContentHash, `${instancePath}/schemaContentHash`); - requireNonEmptyString(candidate.recordKey, `${instancePath}/recordKey`); - requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`); - requireOptionalNonEmptyString(candidate.sourceRuntimeId, `${instancePath}/sourceRuntimeId`); - requireOptionalNonEmptyString(candidate.sourceAgentId, `${instancePath}/sourceAgentId`); - requireOptionalNonEmptyString(candidate.sourceRole, `${instancePath}/sourceRole`); - requireOptionalStringArray(candidate.tags, `${instancePath}/tags`); -} - -function validateEdge(input: unknown, instancePath: string): void { - const fail = (path: string, problem: string): never => { - throw new Error(`Validation failed for cultnet.snapshot_response_raw.v1: ${path}: ${problem}`); - }; - if (!input || typeof input !== "object") { - fail(instancePath, "must be an object"); - } - const candidate = input as Record; - for (const end of ["from", "to"] as const) { - const ref = candidate[end] as Record | undefined; - if (!ref || typeof ref !== "object") { - fail(`${instancePath}/${end}`, "must be an object"); - } - requireNonEmptyString(ref!.schemaId, `${instancePath}/${end}/schemaId`); - requireNonEmptyString(ref!.recordKey, `${instancePath}/${end}/recordKey`); - } - requireNonEmptyString(candidate.role, `${instancePath}/role`); - if (typeof candidate.payloadEncoding !== "undefined" && candidate.payloadEncoding !== "messagepack") { - fail(`${instancePath}/payloadEncoding`, "must equal messagepack"); - } - if (typeof candidate.payload !== "undefined" && !(candidate.payload instanceof Uint8Array)) { - fail(`${instancePath}/payload`, "must be a Uint8Array"); - } -} - function validateDatabaseChangeRawMessage( input: unknown, ): asserts input is CultNetDatabaseChangeRawMessage { @@ -1370,7 +1134,6 @@ function validateBinaryMessageRoot( schemaVersion: | "cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0" - | "cultnet.snapshot_response_raw.v1" | "cultnet.database_change_raw.v0", ): Record { if (!input || typeof input !== "object") { @@ -1473,11 +1236,6 @@ function normalizeCultNetOptionalNulls( "supportedBodyTransports", ]); return; - case "cultnet.snapshot_request.v1": - case "cultnet.database_subscribe.v1": - case "cultnet.snapshot_response_raw.v1": - normalizeSelectionCarrierNulls(schemaVersion, candidate); - return; case "cultnet.operation_request.v0": stripNullProperties(candidate, ["payloadEncoding", "sourceRuntimeId", "targetRuntimeId"]); return; @@ -1547,11 +1305,6 @@ export const cultNetSchemas = { snapshotResponseSchema, snapshotResponseRawSchema, databaseSubscribeSchema, - selectionSchema, - rawDocumentHeaderSchema, - snapshotRequestV1Schema, - snapshotResponseRawV1Schema, - databaseSubscribeV1Schema, databaseUnsubscribeSchema, databaseChangeRawSchema, schemaDescriptorSchema, diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts index 9d1c7438..c260cf70 100644 --- a/packages/cultnet-ts/src/cultmesh-authority.ts +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -39,8 +39,8 @@ export interface CultMeshAuthorityRouteCertificate { /** * Maps an advertised route's wire-shaped certificate to the view * `verifyAuthorityRoute` and the transports consume. Shared by the QUIC - * realtime session manager (`@gamecult/cultmesh-ts`) and the browser WebSocket client - * (`@gamecult/cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage` + * realtime session manager (`cultmesh-ts`) and the browser WebSocket client + * (`cultmesh-browser`), which advertise the same `CultMeshAuthorityRouteMessage` * certificate shape and were computing this mapping independently. */ export function routeCertificateView( diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts index 209a2881..3713363d 100644 --- a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -107,36 +107,26 @@ async function publishDocument( const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); 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 - // it arrives before the ack, after it, or inside the result window; a success reply ends the wait early. - let succeeded = false; - const onFrame = (frame: { channelId: string; payload: Uint8Array }) => { - if (frame.channelId !== "schema") return; - const response = parseCultNetMessage(decode(frame.payload)); - if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response); - succeeded = true; - }; - await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame); - if (!succeeded) await receiveUntil(receiver, session, endpoint, () => succeeded, RESULT_WINDOW_MS, onFrame, true); + await receiveUntil( + receiver, + session, + endpoint, + () => session.outstandingReliablePacketCount === 0, + 2000, + (frame) => { + if (frame.channelId !== "schema") return; + const response = parseCultNetMessage(decode(frame.payload)); + if (response.schemaVersion === "cultnet.error.v0") { + throw new Error(`Odin rejected runtime presence: ${response.error}`); + } + }, + ); } finally { receiver.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 = { socket: dgram.Socket; next(timeoutMs: number): Promise; @@ -200,7 +190,6 @@ async function receiveUntil( predicate: (packet: CultNetRudpPacket) => boolean, timeoutMs: number, onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, - returnOnTimeout = false, ): Promise { const deadline = Date.now() + timeoutMs; 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); } - if (returnOnTimeout) return; throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); } diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 4fd0014c..c321bd07 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -3,9 +3,6 @@ export { encodeCultNetMessageForWire, parseCultNetMessage, validateGhostlightAgentState, - type CultNetDatabaseSubscribeV1Message, - type CultNetSnapshotRequestV1Message, - type CultNetSnapshotResponseRawV1Message, type CultNetDocumentDeleteMessage, type CultNetDocumentMutationContract, type CultNetDocumentPutMessage, @@ -16,14 +13,7 @@ export { type CultNetDatabaseChangeRawMessage, type CultNetRawDocumentRecord, type CultNetRawPayloadEncoding, - CULTNET_ERROR_CODES, - cultNetTextError, - cultNetMessageInvalidError, - cultNetWriteNotAuthorizedError, - type CultNetErrorCode, - type CultNetErrorDetails, type CultNetErrorMessage, - type CultNetErrorRoutingHint, type CultNetHelloMessage, type CultNetLoginMessage, type CultNetLoginSuccessMessage, @@ -72,19 +62,6 @@ export { type GhostlightVariableMap, type GhostlightWorldState, } from "./contracts"; -export { - CultNetSelectionInvalidError, - validateSelectionShape, - type CultNetCitation, - type CultNetEdge, - type CultNetFieldPredicate, - type CultNetIncoming, - type CultNetRawDocumentHeader, - type CultNetRecordRef, - type CultNetSelection, - type CultNetSelectionOperator, - type CultNetSelectionProjection, -} from "./selection"; export { base64ToBytes, bytesToBase64, diff --git a/packages/cultnet-ts/src/peer.ts b/packages/cultnet-ts/src/peer.ts index 504973af..0e54ee58 100644 --- a/packages/cultnet-ts/src/peer.ts +++ b/packages/cultnet-ts/src/peer.ts @@ -95,10 +95,6 @@ export class CultNetPeer extends EventEmitter { 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 { const wireValue = encodeCultNetMessageForWire(message, this.#wireContract); const payload = encode(wireValue); @@ -110,7 +106,6 @@ export class CultNetPeer extends EventEmitter { 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 { await this.#transport?.flush?.(timeoutMs); } diff --git a/packages/cultnet-ts/src/replication.ts b/packages/cultnet-ts/src/replication.ts index 32ee4163..9676d0f0 100644 --- a/packages/cultnet-ts/src/replication.ts +++ b/packages/cultnet-ts/src/replication.ts @@ -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( cache: CultCache, message: CultNetDocumentDeleteMessage, diff --git a/packages/cultnet-ts/src/selection-carriers.ts b/packages/cultnet-ts/src/selection-carriers.ts deleted file mode 100644 index 21090f03..00000000 --- a/packages/cultnet-ts/src/selection-carriers.ts +++ /dev/null @@ -1,178 +0,0 @@ -// The two shape conversions the selection/v1 carriers need between the reference's wire and TypeScript: -// the reference writes every declared key, nil included, in declaration order (MessagePack-CSharp map -// mode; schema notes R-S/R-X), so a received message arrives with explicit nulls that are normalised -// to absent keys, and a sent message is rebuilt with every key in the reference's order. - -import type { - CultNetDatabaseSubscribeV1Message, - CultNetRawDocumentRecord, - CultNetSnapshotRequestV1Message, - CultNetSnapshotResponseRawV1Message, -} from "./contracts"; -import type { CultNetEdge, CultNetRawDocumentHeader, CultNetRecordRef, CultNetSelection } from "./selection"; - -export type CultNetSelectionCarrier = - | CultNetSnapshotRequestV1Message - | CultNetDatabaseSubscribeV1Message - | CultNetSnapshotResponseRawV1Message; - -type Bag = Record; - -const isBag = (value: unknown): value is Bag => !!value && typeof value === "object" && !Array.isArray(value); - -function stripNulls(candidate: unknown, keys: readonly string[]): void { - if (!isBag(candidate)) return; - for (const key of keys) { - if (candidate[key] === null) delete candidate[key]; - } -} - -function eachBag(list: unknown, visit: (item: Bag) => void): void { - if (!Array.isArray(list)) return; - for (const item of list) { - if (isBag(item)) visit(item); - } -} - -/** Drops the nil an absent selection member travels as, at every depth the selection carries one. */ -export function normalizeSelectionNulls(selection: unknown): void { - stripNulls(selection, ["schemas", "keys", "fields", "cites", "cited", "limit", "cursor"]); - if (!isBag(selection)) return; - eachBag(selection.fields, field => stripNulls(field, ["values", "number"])); - stripNulls(selection.cites, ["role"]); -} - -/** Drops the nil an absent member travels as, for one selection/v1 carrier message. */ -export function normalizeSelectionCarrierNulls( - schemaVersion: CultNetSelectionCarrier["schemaVersion"], - candidate: Bag, -): void { - switch (schemaVersion) { - case "cultnet.snapshot_request.v1": - stripNulls(candidate, ["shardId", "shardEpoch"]); - normalizeSelectionNulls(candidate.selection); - return; - case "cultnet.database_subscribe.v1": - stripNulls(candidate, ["consumerRuntimeId", "bodyIds", "supportedBodyTransports"]); - normalizeSelectionNulls(candidate.selection); - return; - case "cultnet.snapshot_response_raw.v1": - stripNulls(candidate, ["next", "headers", "documents", "edges", "shardId", "shardEpoch", "shardLogSequence"]); - eachBag(candidate.headers, header => stripNulls(header, HEADER_OPTIONAL)); - eachBag(candidate.documents, document => stripNulls(document, HEADER_OPTIONAL)); - eachBag(candidate.edges, edge => stripNulls(edge, ["payloadEncoding", "payload"])); - return; - } -} - -const HEADER_OPTIONAL = ["schemaName", "schemaVersion", "schemaContentHash", "sourceRuntimeId", "sourceAgentId", "sourceRole", "tags"] as const; - -const orNull = (value: T | undefined | null): T | null => value ?? null; - -function recordRefWire(ref: CultNetRecordRef) { - return { schemaId: ref.schemaId, recordKey: ref.recordKey }; -} - -function selectionWire(selection: CultNetSelection) { - return { - schemas: orNull(selection.schemas), - keys: orNull(selection.keys), - fields: selection.fields - ? selection.fields.map(field => ({ - index: field.index, - op: field.op, - values: orNull(field.values), - number: orNull(field.number), - })) - : null, - cites: selection.cites - ? { target: recordRefWire(selection.cites.target), role: orNull(selection.cites.role) } - : null, - cited: selection.cited ? { role: selection.cited.role, exists: selection.cited.exists } : null, - projection: selection.projection ?? "header", - descending: selection.descending ?? false, - limit: orNull(selection.limit), - cursor: orNull(selection.cursor), - }; -} - -function headerWire(header: CultNetRawDocumentHeader) { - return { - schemaId: header.schemaId, - schemaName: orNull(header.schemaName), - schemaVersion: orNull(header.schemaVersion), - schemaContentHash: orNull(header.schemaContentHash), - recordKey: header.recordKey, - storedAt: header.storedAt, - sourceRuntimeId: orNull(header.sourceRuntimeId), - sourceAgentId: orNull(header.sourceAgentId), - sourceRole: orNull(header.sourceRole), - tags: orNull(header.tags), - }; -} - -function documentWire(document: CultNetRawDocumentRecord) { - return { - schemaId: document.schemaId, - schemaName: orNull(document.schemaName), - schemaVersion: orNull(document.schemaVersion), - schemaContentHash: orNull(document.schemaContentHash), - recordKey: document.recordKey, - storedAt: document.storedAt, - payloadEncoding: document.payloadEncoding, - payload: document.payload, - sourceRuntimeId: orNull(document.sourceRuntimeId), - sourceAgentId: orNull(document.sourceAgentId), - sourceRole: orNull(document.sourceRole), - tags: orNull(document.tags), - }; -} - -function edgeWire(edge: CultNetEdge) { - return { - from: recordRefWire(edge.from), - role: edge.role, - to: recordRefWire(edge.to), - payloadEncoding: orNull(edge.payloadEncoding), - payload: orNull(edge.payload), - }; -} - -/** The reference's nil-keyed, declaration-ordered shape for one selection/v1 carrier message. */ -export function encodeSelectionCarrierForWire(message: CultNetSelectionCarrier): unknown { - switch (message.schemaVersion) { - case "cultnet.snapshot_request.v1": - return { - schemaVersion: message.schemaVersion, - messageId: message.messageId, - selection: selectionWire(message.selection), - shardId: orNull(message.shardId), - shardEpoch: orNull(message.shardEpoch), - }; - case "cultnet.database_subscribe.v1": - return { - schemaVersion: message.schemaVersion, - messageId: message.messageId, - subscriptionId: message.subscriptionId, - selection: selectionWire(message.selection), - includeSnapshot: message.includeSnapshot ?? true, - consumerRuntimeId: orNull(message.consumerRuntimeId), - bodyIds: orNull(message.bodyIds), - supportedBodyTransports: orNull(message.supportedBodyTransports), - }; - case "cultnet.snapshot_response_raw.v1": - return { - schemaVersion: message.schemaVersion, - messageId: message.messageId, - matched: message.matched, - asOf: message.asOf, - next: orNull(message.next), - headers: message.headers ? message.headers.map(headerWire) : null, - documents: message.documents ? message.documents.map(documentWire) : null, - edges: message.edges ? message.edges.map(edgeWire) : null, - shardId: orNull(message.shardId), - shardEpoch: orNull(message.shardEpoch), - shardLogSequence: orNull(message.shardLogSequence), - }; - } -} diff --git a/packages/cultnet-ts/src/selection.ts b/packages/cultnet-ts/src/selection.ts deleted file mode 100644 index 2bba1ebe..00000000 --- a/packages/cultnet-ts/src/selection.ts +++ /dev/null @@ -1,115 +0,0 @@ -// cultnet.selection.v1 payload shapes (docs/cultnet-selection-cut.md, section 2), the carriers' common -// vocabulary: one typed selection, the header/edge rows a page answers with, and the door refusal. -// The messages that carry them live in contracts.ts; the nil-keyed wire encoding, null normalization and -// response validation live in selection-carriers.ts. - -export type CultNetSelectionOperator = "any_of" | "lt" | "le" | "ge" | "gt"; -export type CultNetSelectionProjection = "header" | "document"; - -/** One predicate over a single declared index alias. `values` iff any_of; `number` (canonical decimal string) iff a comparison. */ -export interface CultNetFieldPredicate { - index: string; - op: CultNetSelectionOperator; - values?: string[]; - number?: string; -} - -/** Identifies one row on the wire by schema and record key. */ -export interface CultNetRecordRef { - schemaId: string; - recordKey: string; -} - -/** Selects rows whose declared reference names `target`; an absent `role` matches any declared reference. */ -export interface CultNetCitation { - target: CultNetRecordRef; - role?: string; -} - -/** Selects rows some other row does (`exists`) or does not name in `role`. */ -export interface CultNetIncoming { - role: string; - exists: boolean; -} - -/** Absent `schemas`/`keys` mean every schema/key; a present empty list is refused at the door. */ -export interface CultNetSelection { - schemas?: string[]; - keys?: string[]; - fields?: CultNetFieldPredicate[]; - cites?: CultNetCitation; - cited?: CultNetIncoming; - /** Default "header". */ - projection?: CultNetSelectionProjection; - descending?: boolean; - /** Clamped to 1..=200 by the answering server. */ - limit?: number; - /** Opaque; minted by the answering server. */ - cursor?: string; -} - -/** A raw document record minus its payload and payloadEncoding. */ -export interface CultNetRawDocumentHeader { - schemaId: string; - schemaName?: string; - schemaVersion?: string; - schemaContentHash?: string; - recordKey: string; - storedAt: string; - sourceRuntimeId?: string; - sourceAgentId?: string; - sourceRole?: string; - tags?: string[]; -} - -/** One edge a hop traversed. `payload` is present under document projection only. */ -export interface CultNetEdge { - from: CultNetRecordRef; - role: string; - to: CultNetRecordRef; - payloadEncoding?: "messagepack"; - payload?: Uint8Array; -} - -/** - * Refused typed at the door: a selection the declared shape cannot support. Carried back as - * `cultnet.error.v0 { code: "selection_invalid", details: { field, value } }`. - */ -export class CultNetSelectionInvalidError extends Error { - constructor( - readonly field: string, - readonly value: string | null, - message: string, - ) { - super(message); - this.name = "CultNetSelectionInvalidError"; - } -} - -/** - * The declaration-independent half of the door, matching the reference's `ValidateShape`: an empty or - * blank `schemas`/`keys` entry or list, and an unrecognised `projection`. Checks that need the - * declared schemas (index aliases, reference roles, cites target) belong to the selection engine. - */ -export function validateSelectionShape(selection: CultNetSelection): void { - const list = (field: "schemas" | "keys") => { - const entries = selection[field]; - if (entries === undefined || entries === null) return; - if (entries.length === 0) { - throw new CultNetSelectionInvalidError( - field, null, `selection.${field} is present and empty; omit it to reach every ${field === "schemas" ? "schema" : "key"}.`); - } - for (const entry of entries) { - if (typeof entry !== "string" || entry.trim().length === 0) { - throw new CultNetSelectionInvalidError(field, entry, `selection.${field} carries an empty or whitespace entry.`); - } - } - }; - list("schemas"); - list("keys"); - const projection = selection.projection ?? "header"; - if (projection !== "header" && projection !== "document") { - throw new CultNetSelectionInvalidError( - "projection", String(projection), `selection.projection "${String(projection)}" is neither "header" nor "document".`); - } -} diff --git a/packages/cultnet-ts/test/carrier-vectors.test.ts b/packages/cultnet-ts/test/carrier-vectors.test.ts deleted file mode 100644 index 63f5826f..00000000 --- a/packages/cultnet-ts/test/carrier-vectors.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -// The reference's own bytes for the cultnet.error.v0 code/details shape and the selection/v1 -// carriers (`contracts/cultnet/interop/carrier-vectors.cs-written.json`, written by the C# -// CarrierVectorTests). Each vector is decoded, parsed, and re-encoded: the re-encoding must be the -// reference's exact bytes, and the decoded message must equal the reference's own JSON of the object. - -import test from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { decode, encode } from "@msgpack/msgpack"; - -import { encodeCultNetMessageForWire, parseCultNetMessage } from "../src/contracts"; - -const FILE = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json"); -type Vector = { name: string; schemaVersion: string; messagePackBase64: string; expected: unknown }; -const vectors: Vector[] = JSON.parse(readFileSync(FILE, "utf8")).vectors; -const vectorsFor = (prefix: string) => vectors.filter(v => v.name.startsWith(prefix)); -const bytesOf = (v: Vector) => new Uint8Array(Buffer.from(v.messagePackBase64, "base64")); - -/** Uint8Array to base64, recursively, so a decoded message compares with the reference's JSON. */ -function jsonShape(value: unknown): unknown { - if (value instanceof Uint8Array) return Buffer.from(value).toString("base64"); - if (Array.isArray(value)) return value.map(jsonShape); - if (value && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, jsonShape(v)])); - } - return value; -} - -const reencode = (message: Parameters[0]) => - new Uint8Array(encode(encodeCultNetMessageForWire(message), { ignoreUndefined: true })); - -test("every error vector keeps the reference's code and details through parse and re-encode", () => { - const errors = vectorsFor("error_"); - assert.ok(errors.length >= 5); - for (const v of errors) { - const raw = decode(bytesOf(v)); - assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`); - const message = parseCultNetMessage(raw); - assert.equal(message.schemaVersion, "cultnet.error.v0"); - assert.deepEqual(jsonShape(message), v.expected, `${v.name}: parsed message`); - assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`); - } -}); - -test("an error whose code is outside the schema's closed enum is refused", () => { - const raw = decode(bytesOf(vectors.find(v => v.name === "error_cursor_stale")!)) as Record; - assert.throws(() => parseCultNetMessage({ ...raw, code: "made_up_code" }), /cultnet\.error\.v0/); -}); - -// ---- selection/v1 carriers ---- - -for (const prefix of ["snapshot_request_v1", "database_subscribe_v1", "snapshot_response_raw_v1"]) { - test(`${prefix} vectors: decode, parse, and re-encode to the reference's exact bytes`, () => { - const group = vectorsFor(prefix); - assert.ok(group.length >= 2, `${prefix}: vectors present`); - for (const v of group) { - const raw = decode(bytesOf(v)); - assert.deepEqual(jsonShape(raw), v.expected, `${v.name}: decoded bytes`); - const message = parseCultNetMessage(raw); - assert.equal(message.schemaVersion, v.schemaVersion); - assert.deepEqual(reencode(message), bytesOf(v), `${v.name}: re-encoded bytes`); - } - }); -} - -test("a parsed v1 message drops the nil an absent member travels as", () => { - const full = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_request_v1_defaults")!))) as unknown as - { selection: Record; shardId?: unknown }; - assert.deepEqual(Object.keys(full.selection).sort(), ["descending", "projection"]); - assert.equal("shardId" in full, false); - const page = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_headers")!))) as unknown as - { headers: Record[]; documents?: unknown }; - assert.equal("documents" in page, false); - assert.equal("tags" in page.headers[0], false); - assert.deepEqual(page.headers[1].tags, ["alpha", "beta"]); -}); - -test("edge payloads and document payloads arrive as bytes, not text", () => { - const page = parseCultNetMessage(decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_documents_and_edges")!))) as unknown as - { edges: { payload?: Uint8Array }[]; documents: { payload: Uint8Array }[] }; - assert.deepEqual(Array.from(page.edges[1].payload!), [1, 2, 3]); - assert.equal("payload" in page.edges[0], false); - assert.deepEqual(Array.from(page.documents[0].payload), [0x81, 0xa1, 0x6b, 0x01]); -}); - -const request = () => decode(bytesOf(vectors.find(v => v.name === "snapshot_request_v1_full_selection_and_shard")!)) as - { selection: Record } & Record; -const refusedField = (message: unknown): string | undefined => { - try { - parseCultNetMessage(message); - } catch (error) { - return (error as { field?: string }).field; - } - return "accepted"; -}; - -test("the door refuses an empty or blank schemas/keys entry with a typed field, never as every-schema", () => { - const withSelection = (patch: Record) => ({ ...request(), selection: { ...request().selection, ...patch } }); - assert.equal(refusedField(withSelection({ schemas: [] })), "schemas"); - assert.equal(refusedField(withSelection({ keys: ["a-eq", " "] })), "keys"); - assert.equal(refusedField(withSelection({ projection: "everything" })), "projection"); -}); - -test("the schema refuses a comparison whose number is not a canonical decimal, and a predicate that mixes values and number", () => { - const withField = (field: Record) => { - const message = request(); - return { ...message, selection: { ...message.selection, fields: [field] } }; - }; - for (const number of ["05", "1.50", "+1", "1e3", "-0", " 1"]) { - assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "ge", values: null, number })), /snapshot_request\.v1/, number); - } - assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "ge", values: ["1"], number: "1" })), /snapshot_request\.v1/); - assert.throws(() => parseCultNetMessage(withField({ index: "mass", op: "near", values: null, number: "1" })), /snapshot_request\.v1/); -}); - -test("a selection page with both or neither of headers and documents is refused", () => { - const page = decode(bytesOf(vectors.find(v => v.name === "snapshot_response_raw_v1_headers")!)) as Record; - assert.throws(() => parseCultNetMessage({ ...page, documents: [] }), /exactly one of headers and documents/); - assert.throws(() => parseCultNetMessage({ ...page, headers: null }), /exactly one of headers and documents/); -}); - -// ---- selection vectors: the reference's and Rust's selection bytes ---- - -const INTEROP = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop"); -type SelectionVector = { name: string; selectionMessagePackBase64: string; expectedRefusalField?: string | null }; -const selectionVectors = (file: string): SelectionVector[] => - JSON.parse(readFileSync(join(INTEROP, file), "utf8")).vectors; - -import { CultNetSelectionInvalidError, validateSelectionShape, type CultNetSelection } from "../src/selection"; - -for (const file of ["selection-vectors.cs-written.json", "selection-vectors.rs-written.json"]) { - test(`${file}: every selection decodes inside a v1 request and the schema door agrees with the vector`, () => { - const all = selectionVectors(file); - assert.ok(all.length >= 20); - for (const v of all) { - const selection = decode(new Uint8Array(Buffer.from(v.selectionMessagePackBase64, "base64"))); - const message = { schemaVersion: "cultnet.snapshot_request.v1", messageId: "m", selection, shardId: null, shardEpoch: null }; - if (v.expectedRefusalField === "schemas" || v.expectedRefusalField === "keys") { - assert.equal(refusedField(message), v.expectedRefusalField, `${v.name}: shape refusal`); - continue; - } - if (v.expectedRefusalField) { - // Declaration-dependent refusals (cites target, index aliases) need the selection engine's schemas. - const parsed = parseCultNetMessage(message) as unknown as { selection: CultNetSelection }; - validateSelectionShape(parsed.selection); - continue; - } - const parsed = parseCultNetMessage(message) as unknown as { selection: CultNetSelection }; - validateSelectionShape(parsed.selection); - const rewritten = new Uint8Array(encode( - (encodeCultNetMessageForWire(parsed as never) as { selection: unknown }).selection)); - if (file.includes("cs-written")) { - // The reference's bytes are the nil-keyed shape: our encoder must reproduce them exactly. - assert.deepEqual(rewritten, new Uint8Array(Buffer.from(v.selectionMessagePackBase64, "base64")), - `${v.name}: selection re-encodes to the reference's bytes`); - } else { - // Rust omits absent members; what it means must survive our nil-keyed rewrite unchanged. - const again = parseCultNetMessage({ ...message, selection: decode(rewritten) }) as unknown as { selection: CultNetSelection }; - assert.deepEqual(again.selection, parsed.selection, `${v.name}: selection meaning survives re-encode`); - } - } - }); -} - -test("CultNetSelectionInvalidError carries the field and offending value", () => { - assert.throws( - () => validateSelectionShape({ keys: ["ok", ""] }), - (error: unknown) => error instanceof CultNetSelectionInvalidError && error.field === "keys" && error.value === "", - ); -}); diff --git a/packages/cultnet-ts/test/cultnet-error.test.ts b/packages/cultnet-ts/test/cultnet-error.test.ts deleted file mode 100644 index 9526cc29..00000000 --- a/packages/cultnet-ts/test/cultnet-error.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; - -import { decode, encode } from "@msgpack/msgpack"; - -import { - CULTNET_ERROR_CODES, - cultNetSchemas, - cultNetMessageInvalidError, - cultNetWriteNotAuthorizedError, - encodeCultNetMessageForWire, - parseCultNetMessage, - type CultNetErrorMessage, -} from "../src"; - -// 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). -const VECTORS = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "error-vectors.json"); -const vectors = (JSON.parse(readFileSync(VECTORS, "utf8")) as { - vectors: { name: string; messagePackHex: string; message: unknown }[]; -}).vectors; - -const bytesOf = (hex: string): Uint8Array => Uint8Array.from(Buffer.from(hex, "hex")); -const hexOf = (bytes: Uint8Array): string => Buffer.from(bytes).toString("hex").toUpperCase(); -const wireOf = (message: unknown): Uint8Array => encode(encodeCultNetMessageForWire(message as CultNetErrorMessage)); - -test("the error vector file carries a case for every closed code", () => { - const codes = new Set(vectors.map((v) => (v.message as CultNetErrorMessage).code)); - for (const code of CULTNET_ERROR_CODES) { - assert.ok(codes.has(code), `no vector for ${code}`); - } -}); - -test("a null code with null routingHint and details is a valid error message (no captured bytes carry one)", () => { - const message = parseCultNetMessage({ - schemaVersion: "cultnet.error.v0", error: "boom", routingHint: null, code: null, details: null, - }); - assert.equal((message as CultNetErrorMessage).code, null); -}); - -test("the closed code union equals the schema's enum", () => { - const schemaCodes = ((cultNetSchemas.errorSchema.properties.code as { enum?: (string | null)[] }).enum ?? []).filter((c) => c !== null); - assert.deepEqual([...CULTNET_ERROR_CODES].sort(), [...schemaCodes].sort()); -}); - -test("C#/Rust-written error bytes decode into the typed message with every key", () => { - for (const vector of vectors) { - const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); - assert.deepEqual(parsed, vector.message, vector.name); - assert.deepEqual(Object.keys(parsed), ["schemaVersion", "error", "routingHint", "code", "details"], vector.name); - } -}); - -test("a parsed error message re-encodes to the same bytes", () => { - for (const vector of vectors) { - const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); - assert.equal(hexOf(wireOf(parsed)), vector.messagePackHex, vector.name); - } -}); - -test("dropping code or details breaks the round trip and the decode", () => { - const vector = vectors.find((v) => v.name === "cursor_stale")!; - for (const dropped of ["code", "details", "routingHint"]) { - const copy = { ...(decode(bytesOf(vector.messagePackHex)) as Record) }; - delete copy[dropped]; - assert.throws(() => parseCultNetMessage(copy), /cultnet\.error\.v0/, `missing ${dropped} must be refused`); - assert.notEqual(hexOf(encode(copy)), vector.messagePackHex); - } -}); - -test("a code outside the closed enum is refused, as is a malformed details object", () => { - const base = decode(bytesOf(vectors.find((v) => v.name === "cursor_stale")!.messagePackHex)) as Record; - assert.throws(() => parseCultNetMessage({ ...base, code: "not_a_code" }), /cultnet\.error\.v0/); - assert.throws(() => parseCultNetMessage({ ...base, code: 7 }), /cultnet\.error\.v0/); - assert.throws( - () => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: 1, current: 2, extra: 1 } }), - /cultnet\.error\.v0/, - ); - assert.throws( - () => parseCultNetMessage({ ...base, details: { field: null, value: null, asOf: -1, current: 2 } }), - /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"); -}); diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index 37d963a8..1bcfca0d 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -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.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"); -}); diff --git a/packages/cultnet-ts/test/first-call.test.ts b/packages/cultnet-ts/test/first-call.test.ts deleted file mode 100644 index 977a6e5b..00000000 --- a/packages/cultnet-ts/test/first-call.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; -import test from "node:test"; - -// 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 -// 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. -// snippet:begin -import { createServer, connect } from "node:net"; -import { z } from "zod"; -import { CultCache, defineDocumentType } from "@gamecult/cultcache-ts"; -import { - CultNetDocumentRegistry, - createTcpFramedCultNetPeer, - defineCultNetDocumentBinding, -} from "../src"; - -const noteDocument = defineDocumentType({ - type: "note", - schemaId: "gamecult.first_call_note.v1", - schemaName: "gamecult.first_call_note", - schemaVersion: "gamecult.first_call_note.v1", - schema: z.object({ title: z.string(), body: z.string() }), - name: "title", -}); -const documents = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: noteDocument })]); - -async function firstCall() { - const options = { runtimeId: "first-call", wireContract: "cultnet.schema.v0" } as const; - const inbox = CultCache.builder().withDocumentType(noteDocument).build(); // the receiver's cache - - // Receiver: a loopback listener whose peer applies each document put to the inbox. - const received = new Promise((resolve, reject) => { - const server = createServer((socket) => { - const peer = createTcpFramedCultNetPeer(socket, options); - peer.on("invalidMessage", reject); - peer.on("message", async (message) => { - if (message.schemaVersion !== "cultnet.document_put_raw.v0") return; - await documents.applyRawDocumentPutMessage(inbox, message); - peer.close(); - server.close(); - resolve(); - }); - }).listen(0, "127.0.0.1", () => { - // Sender: connect, build a typed put message, send it. - const { port } = server.address() as { port: number }; - const sender = createTcpFramedCultNetPeer(connect(port, "127.0.0.1"), options); - sender.send( - documents.createRawDocumentPutMessage(documents.get("note")!, "put-1", "note:hello", { - title: "Hello", - body: "sent over CultNet", - }), - ); - received.finally(() => sender.close()); - }); - }); - await received; - return inbox.get(noteDocument, "note:hello"); -} -// snippet:end - -test("send a typed document over loopback and receive it into a second cache", async () => { - const note = await firstCall(); - assert.deepEqual(note, { title: "Hello", body: "sent over CultNet" }); -}); - -test("docs show the tested snippet", async () => { - // Compiled to dist-test/test/first-call.test.js, so the package root is two levels up. - const source = await readFile(join(__dirname, "..", "..", "test", "first-call.test.ts"), "utf8"); - const snippet = source - .split("// snippet:begin\n")[1] - .split("// snippet:end\n")[0] - .replace('"../src"', '"@gamecult/cultnet-ts"'); - 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"); -}); diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 2e05bbf4..cf1e4a26 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -276,14 +276,9 @@ async function startOdinPeer(context: TestContext, endpoint: string) { peer.once("error", reject); 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. - const timers = new Set(); - const later = (send: () => void) => { timers.add(setTimeout(send, 120)); }; - context.after(() => { for (const timer of timers) clearTimeout(timer); peer.close(); }); + context.after(() => peer.close()); const state = { 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 } }>, responseAck: undefined as number | undefined, failure: undefined as Error | undefined, @@ -310,15 +305,7 @@ async function startOdinPeer(context: TestContext, endpoint: string) { }); assert.ok(response); state.responseAck = response.ack; - if (state.reject && state.order === "ack-then-refusal") { - 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); - } + peer.send(encodeRudpPacket(response), remote.port, remote.address); } catch (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); }); -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) => { const endpoint = expectedFacts("service").odinEndpoint!; 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", () => { - 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(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; assert.equal(typeof exported.createIdunnRuntimeSigner, "function"); assert.equal(typeof exported.loadIdunnRuntimeAuthorityFromEnvironment, "function"); assert.equal(exported.createIdunnRuntimePresencePublisher, undefined); assert.equal(exported.signIdunnRuntimePresence, undefined); - assert.equal((require("@gamecult/cultnet-ts") as Record).signIdunnRuntimePresence, undefined); + assert.equal((require("cultnet-ts") as Record).signIdunnRuntimePresence, undefined); }); diff --git a/samples/eve-browser-network/README.md b/samples/eve-browser-network/README.md index 62a7980b..445bfb9d 100644 --- a/samples/eve-browser-network/README.md +++ b/samples/eve-browser-network/README.md @@ -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 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; - one retained C# headless client discovers the provider through the same Odin identity, observes the typed counter and canonical receipt ids, survives the diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts index 76f8817a..fc57e364 100644 --- a/samples/eve-browser-network/browser/main.ts +++ b/samples/eve-browser-network/browser/main.ts @@ -5,7 +5,7 @@ import { CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload, -} from "@gamecult/cultmesh-browser"; +} from "cultmesh-browser"; 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 diff --git a/samples/eve-two-runtime/sample.mjs b/samples/eve-two-runtime/sample.mjs index e7dc56c5..93ed1dc8 100644 --- a/samples/eve-two-runtime/sample.mjs +++ b/samples/eve-two-runtime/sample.mjs @@ -5,7 +5,7 @@ import { join } from "node:path"; import { JSDOM } from "jsdom"; import { z } from "zod"; import { defineDocumentType } from "@gamecult/cultcache-ts"; -import { CultMesh } from "@gamecult/cultmesh-ts"; +import { CultMesh } from "cultmesh-ts"; import { parseEveCommandReceipt, parseEveSurfaceDocument, diff --git a/scripts/run-typescript-workspaces.mjs b/scripts/run-typescript-workspaces.mjs index 3ed4514c..0ef34005 100644 --- a/scripts/run-typescript-workspaces.mjs +++ b/scripts/run-typescript-workspaces.mjs @@ -11,7 +11,7 @@ const npmCli = process.env.npm_execpath ?? // moves when a package is scoped: `8cb3b72` renamed `cultcache-ts` to // `@gamecult/cultcache-ts` and left this entry behind, which broke `build:ts` and // `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" ? { ...process.env, diff --git a/scripts/test-typescript-package-closure.mjs b/scripts/test-typescript-package-closure.mjs index 1fb6c140..6b0d7661 100644 --- a/scripts/test-typescript-package-closure.mjs +++ b/scripts/test-typescript-package-closure.mjs @@ -79,8 +79,8 @@ try { const assert = require("node:assert/strict"); const cache = require("@gamecult/cultcache-ts"); const documentEntry = require("@gamecult/cultcache-ts/document"); -const net = require("@gamecult/cultnet-ts"); -const mesh = require("@gamecult/cultmesh-ts"); +const net = require("cultnet-ts"); +const mesh = require("cultmesh-ts"); 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. 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"), ` import assert from "node:assert/strict"; -import { CultMeshBrowserClient, decodeCultNetPayload } from "@gamecult/cultmesh-browser"; -import { parseCultNetMessage } from "@gamecult/cultnet-ts/contracts"; +import { CultMeshBrowserClient, decodeCultNetPayload } from "cultmesh-browser"; +import { parseCultNetMessage } from "cultnet-ts/contracts"; assert.equal(typeof CultMeshBrowserClient.connect, "function"); assert.equal(typeof decodeCultNetPayload, "function"); assert.equal(typeof parseCultNetMessage, "function"); @@ -159,14 +159,14 @@ assert.equal("putDocument" in CultMeshBrowserClient.prototype, false); writeFileSync(join(consumerRoot, "types-smoke.ts"), ` import { defineDocumentType } from "@gamecult/cultcache-ts"; import { decodeDocumentPayload, type DocumentPayload } from "@gamecult/cultcache-ts/document"; -import { CultNetPeer } from "@gamecult/cultnet-ts"; +import { CultNetPeer } from "cultnet-ts"; import { CultMesh, CultMeshProviderSession, type CultMeshProviderIdentity, type CultMeshProviderTransport, type CultMeshVec2, -} from "@gamecult/cultmesh-ts"; +} from "cultmesh-ts"; const point: CultMeshVec2 = CultMesh.vec2(3, 4); const defineDocument: typeof defineDocumentType = defineDocumentType; @@ -209,7 +209,7 @@ import { CultMeshBrowserClient, type CultMeshBrowserIdentity, type CultMeshRawDocumentLeaseOptions, -} from "@gamecult/cultmesh-browser"; +} from "cultmesh-browser"; const identity: CultMeshBrowserIdentity = { verseId: "smoke", diff --git a/scripts/verify-eve-browser-network.mjs b/scripts/verify-eve-browser-network.mjs index 998ee52c..401e4afb 100644 --- a/scripts/verify-eve-browser-network.mjs +++ b/scripts/verify-eve-browser-network.mjs @@ -46,8 +46,8 @@ try { const moduleMap = { "@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/cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"), - "@gamecult/cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"), + "cultmesh-browser": join(repoRoot, "packages", "cultmesh-browser", "src", "index.ts"), + "cultnet-ts/contracts": join(repoRoot, "packages", "cultnet-ts", "src", "contracts.ts"), }; const sampleTsconfig = join(workRoot, "tsconfig.json"); await writeFile(sampleTsconfig, JSON.stringify({ diff --git a/src/GameCult.Mesh/CultMeshClient.cs b/src/GameCult.Mesh/CultMeshClient.cs index 5e8ef38d..5db03ca5 100644 --- a/src/GameCult.Mesh/CultMeshClient.cs +++ b/src/GameCult.Mesh/CultMeshClient.cs @@ -44,12 +44,6 @@ namespace GameCult.Mesh /// Gets or sets the deadline for one correlated typed operation response. public TimeSpan OperationResponseTimeout { get; set; } = TimeSpan.FromSeconds(10); - - /// - /// 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 SubmitDocumentAsync. - /// - public TimeSpan SubmitResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow; } /// @@ -155,7 +149,6 @@ namespace GameCult.Mesh private readonly CultMeshSessionManager _sessions; private readonly TimeSpan _subscriptionResponseTimeout; private readonly TimeSpan _operationResponseTimeout; - private readonly TimeSpan _submitResultWindow; private readonly ConcurrentDictionary _documents = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _collections = new(StringComparer.Ordinal); private bool _disposed; @@ -198,7 +191,6 @@ namespace GameCult.Mesh options.Sessions); _subscriptionResponseTimeout = options.SubscriptionResponseTimeout; _operationResponseTimeout = options.OperationResponseTimeout; - _submitResultWindow = options.SubmitResultWindow; } /// Connects to one stable Verse/provider target using one application protocol. @@ -327,10 +319,7 @@ namespace GameCult.Mesh /// /// 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 - /// the provider's result frame: a refusal (write_not_authorized, message_invalid, ...) throws - /// , and a submission not refused within - /// completes. + /// only a provider-authored receipt or resulting state can establish acceptance. /// public async Task SubmitDocumentAsync( CultMeshSessionTarget target, @@ -362,11 +351,7 @@ namespace GameCult.Mesh var session = await ConnectAsync(target, CultMeshProtocols.Documents, cancellationToken) .ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - await CultNetWriteCompletion.SendAndAwaitResultAsync( - refuse => session.OnCultNet(refuse), - () => session.SendCultNet(message), - _submitResultWindow, - cancellationToken).ConfigureAwait(false); + session.OpenSchemaClient().SendCultNet(message); } /// diff --git a/src/GameCult.Mesh/CultMeshSessions.cs b/src/GameCult.Mesh/CultMeshSessions.cs index 9e06c196..9768dfd5 100644 --- a/src/GameCult.Mesh/CultMeshSessions.cs +++ b/src/GameCult.Mesh/CultMeshSessions.cs @@ -352,7 +352,6 @@ namespace GameCult.Mesh internal long PhysicalGeneration => Interlocked.Read(ref _physicalGeneration); public Observable WatchState() => _states; public ICultNetSchemaClient OpenSchemaClient() => new SessionSchemaClient(this); - /// Fire-and-forget: an ack or a return is not admission; read the result frame. public void SendCultNet(T message) where T : ICultNetSchemaMessage => Channel.SendCultNet(message); public IDisposable OnCultNet(Action callback) where T : ICultNetSchemaMessage { diff --git a/src/GameCult.Mesh/docs/browser-verse-transport.md b/src/GameCult.Mesh/docs/browser-verse-transport.md index 663acc06..fcfde998 100644 --- a/src/GameCult.Mesh/docs/browser-verse-transport.md +++ b/src/GameCult.Mesh/docs/browser-verse-transport.md @@ -12,21 +12,19 @@ over binary loopback CultNet WebSocket routes under an explicit `local-development` trust policy. It proves one canonical command effect, idempotent retry, shared observation, provider restart on a new physical route, browser rediscovery, retained lease resubscription, and a second -canonical command after reconnection. Running the sample needs a sibling Eve -checkout (`--eve-root`); it is not part of `CultLib.sln` and does not build -standalone. +canonical command after reconnection. The current substrate has a browser client boundary and local live proof, but not a complete public Verse proof: -- `@gamecult/cultmesh-ts` is a Node package and imports `node:dgram` at its root; -- `@gamecult/cultnet-ts/contracts` is the browser-safe contract entrypoint; the package +- `cultmesh-ts` is a Node package and imports `node:dgram` at its root; +- `cultnet-ts/contracts` is the browser-safe contract entrypoint; the package root still includes Node transport, filesystem, crypto, stream, and UDP modules and must not enter browser bundles; - C# and TypeScript now share `cultnet.database_subscribe.v0`, `cultnet.database_unsubscribe.v0`, and `cultnet.database_change_raw.v0`; - `@gamecult/cultmesh-browser` consumes those messages as explicit document leases; -- `@gamecult/cultmesh-browser` owns stable identity, route refresh, resubscription, and + `cultmesh-browser` consumes those messages as explicit document leases; +- `cultmesh-browser` owns stable identity, route refresh, resubscription, and typed operation correlation without exposing direct document writes; - `CultMeshBrowserOdinRendezvous` resolves stable Verse/provider identity with `cultmesh.verse_catalog_request.v0` and @@ -112,8 +110,8 @@ Shared paths: Deletion line: - the jsdom checkpoint remains labeled as a Node-hosted DOM proof; -- browser applications import `@gamecult/cultmesh-browser` and - `@gamecult/cultnet-ts/contracts`, never the Node-only package roots; +- browser applications import `cultmesh-browser` and + `cultnet-ts/contracts`, never the Node-only package roots; - subscription messages remain one shared schema contract across the C# and TypeScript catalogs; - the real provider adapter must consume the canonical WebSocket message lane; diff --git a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md index 768a31a3..5cb5b742 100644 --- a/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md +++ b/src/GameCult.Mesh/docs/getting-started/03-publish-an-eve-surface.md @@ -101,7 +101,7 @@ The real-process C# sample is then rebuilt from that same temporary NuGet feed. The source project references in its `.csproj` are contributor convenience only and are disabled by the checkpoint; they are not the consumer contract. -The first layer builds and packs `cultcache-ts`, `@gamecult/cultnet-ts`, `@gamecult/cultmesh-ts`, +The first layer builds and packs `cultcache-ts`, `cultnet-ts`, `cultmesh-ts`, `@gamecult/eve-contracts`, and `@gamecult/eve-browser-lowering`, installs the tarballs into an empty temporary consumer, and runs `samples/eve-two-runtime/sample.mjs`. Eve's generated runtime validators reject @@ -135,9 +135,7 @@ does **not** prove the network layer; the second program owns that evidence. The network program is [`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md). -Running it needs a sibling Eve checkout (`--eve-root`); it is not part of -`CultLib.sln` and does not build standalone. Run it separately only while -diagnosing that layer. A smoke against the +Run it separately only while diagnosing that layer. A smoke against the deployed Odin daemon remains a separate infrastructure gate. Its owner map and full acceptance contract are recorded in [`browser-verse-transport.md`](../browser-verse-transport.md). diff --git a/src/GameCult.Mesh/docs/getting-started/04-run-the-multiruntime-game.md b/src/GameCult.Mesh/docs/getting-started/04-run-the-multiruntime-game.md index 581895a0..ce18fdb3 100644 --- a/src/GameCult.Mesh/docs/getting-started/04-run-the-multiruntime-game.md +++ b/src/GameCult.Mesh/docs/getting-started/04-run-the-multiruntime-game.md @@ -58,8 +58,7 @@ source-tree fallbacks into application code. The process harness lives in [`samples/eve-browser-network`](../../../../samples/eve-browser-network/README.md). -Running it needs a sibling Eve checkout (`--eve-root`); it does not build -standalone. Application code should copy the typed state, operation, and Eve composition +Application code should copy the typed state, operation, and Eve composition shape—not its benchmark flags or fixture orchestration. For a TypeScript provider daemon, continue with the diff --git a/src/GameCult.Mesh/docs/reliability-migration-plan.md b/src/GameCult.Mesh/docs/reliability-migration-plan.md index 6763080f..47e1b190 100644 --- a/src/GameCult.Mesh/docs/reliability-migration-plan.md +++ b/src/GameCult.Mesh/docs/reliability-migration-plan.md @@ -574,7 +574,7 @@ registration, renewal, stable publication order, reconnect and replay, duplicate receipt reuse, explicit withdrawal, and the negative stop-during- registration timeline, durable receipt replay, observer isolation, conflicting command IDs, and exception-safe shutdown. The empty-consumer tarball smoke proves the session is -present in the installable `@gamecult/cultmesh-ts` package. A live Odin transport adapter +present in the installable `cultmesh-ts` package. A live Odin transport adapter and the VoidBot migration remain required for the production proof. ## Phase 3: Authority as a Mandatory Boundary diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 6433c6c3..149b9619 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -401,7 +401,7 @@ namespace GameCult.Networking : message.SubscriptionId; if (string.IsNullOrWhiteSpace(subscriptionId)) { - peer.SendCultNet(CultNetErrorMessage.ForMessageInvalid("subscriptionId", "Database subscription requires a subscriptionId or messageId.")); + peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." }); return Task.CompletedTask; } @@ -443,7 +443,7 @@ namespace GameCult.Networking : message.SubscriptionId; if (string.IsNullOrWhiteSpace(subscriptionId)) { - peer.SendCultNet(CultNetErrorMessage.ForMessageInvalid("subscriptionId", "Database subscription requires a subscriptionId or messageId.")); + peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." }); return Task.CompletedTask; } diff --git a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs index 8398feba..7ae3c67e 100644 --- a/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseSubscriptionServer.cs @@ -145,7 +145,7 @@ namespace GameCult.Networking : request.SubscriptionId; if (string.IsNullOrWhiteSpace(subscriptionId)) { - peer.SendCultNet(CultNetErrorMessage.ForMessageInvalid("subscriptionId", "Database subscription requires a subscriptionId or messageId.")); + peer.SendCultNet(new CultNetErrorMessage { Error = "Database subscription requires a subscriptionId or messageId." }); return Task.CompletedTask; } diff --git a/src/GameCult.Networking/CultNetRudpSchemaServer.cs b/src/GameCult.Networking/CultNetRudpSchemaServer.cs index 3378036a..de8d0154 100644 --- a/src/GameCult.Networking/CultNetRudpSchemaServer.cs +++ b/src/GameCult.Networking/CultNetRudpSchemaServer.cs @@ -1,5 +1,4 @@ using System; -using System.Runtime.CompilerServices; using System.Collections.Concurrent; using System.Collections.Generic; using System.Net; @@ -114,24 +113,9 @@ namespace GameCult.Networking private readonly CultNetRudpSocketTransportServer _transport; private readonly ConcurrentDictionary _handlers = new(); private readonly ConcurrentDictionary _schemaPeerAdapters = new(); - private readonly ConditionalWeakTable> _undecodableFramesInARow = new(); private bool _disposed; private ILogger _logger = new NullLogger(); - /// - /// How many consecutive undecodable frames a peer's session may send before it is ended. Fewer are - /// answered with a typed message_invalid and the session is kept; the frame that reaches this - /// number is answered too, then the session ends. Any frame that decodes resets the run. The same - /// number is the cap in every runtime. - /// - public const int MaxConsecutiveRefusedFrames = 8; - - /// - /// How long a session whose capped frame was answered waits for the peer to acknowledge the answer - /// before it ends anyway. - /// - internal const long CappedAnswerGraceMs = 2000; - /// /// Gets or sets the logger used by the server. R-AM: the dispatch backstop in DispatchAsync /// logs every untyped fault through this instead of swallowing it silently. @@ -316,9 +300,6 @@ namespace GameCult.Networking { if (!string.Equals(delivered.Frame.ChannelId, "schema", StringComparison.Ordinal)) return false; - // The capped frame was answered: nothing after it is served while the answer is delivered. - if (delivered.Peer.EndingReason != null) - return false; // A peer's message that cannot be decoded (malformed bytes, an unknown schemaVersion) or // handled ends that peer's session, never the poll: the drain goes on serving every other @@ -336,23 +317,11 @@ namespace GameCult.Networking } catch (Exception ex) { - // The peer sent something no runtime can read: the refusal is the peer's. It is answered - // message_invalid and the session kept, until MaxConsecutiveRefusedFrames in a row end it. - var run = _undecodableFramesInARow.GetOrCreateValue(delivered.Peer); - run.Value++; - _transport.SendSchemaMessage(delivered.Peer, CultNetErrorMessage.ForMessageInvalid("message", "CultNet RUDP schema server could not read the message.")); - if (run.Value < MaxConsecutiveRefusedFrames) - { - Logger.LogError($"CultNet RUDP schema server could not decode a peer's message ({ex.GetType().Name}); it was refused and that peer's session kept."); - return false; - } - // The answer was sent reliably: the session ends once the peer has acknowledged it (a lost - // datagram is resent first), or after the grace. - _transport.EndPeerSessionWhenAnswered(delivered.Peer, CultNetRudpSession.RefusedPacketReason, CappedAnswerGraceMs); - Logger.LogError($"CultNet RUDP schema server could not decode a peer's message ({ex.GetType().Name}); that peer's session ends once its answer is acknowledged."); + // The peer sent something no runtime can read: the refusal is the peer's. + var ended = _transport.EndPeerSession(delivered.Peer, CultNetRudpSession.RefusedPacketReason); + Logger.LogError($"CultNet RUDP schema server could not decode a peer's message ({ex.GetType().Name}); {SessionOutcome(ended)}"); return false; } - _undecodableFramesInARow.GetOrCreateValue(delivered.Peer).Value = 0; if (!_handlers.TryGetValue(message.GetType(), out var handler) || handler == null) { diff --git a/src/GameCult.Networking/CultNetSchemaClient.cs b/src/GameCult.Networking/CultNetSchemaClient.cs index 83865e31..5c6117aa 100644 --- a/src/GameCult.Networking/CultNetSchemaClient.cs +++ b/src/GameCult.Networking/CultNetSchemaClient.cs @@ -26,8 +26,7 @@ namespace GameCult.Networking void Connect(string host, int port); /// - /// Sends one schema-v0 message through the transport. Fire-and-forget: an ack or a return is not admission; - /// read the result frame (). + /// Sends one schema-v0 message through the transport. /// void SendCultNet(T message) where T : ICultNetSchemaMessage; diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index 5f37f150..baf0b6d5 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -511,7 +511,7 @@ namespace GameCult.Networking [Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; } /// /// Gets or sets the machine-readable refusal code (R-N), e.g. selection_invalid, - /// cursor_stale, cursor_invalid, reference_outside_target, unowned_schema, write_not_authorized, unsupported_schema_version, message_invalid. Additive to + /// cursor_stale, cursor_invalid, reference_outside_target, unowned_schema, write_not_authorized. Additive to /// cultnet.error.v0; a v0 peer that does not know this field ignores it. /// [Key("code")] public string? Code { get; set; } @@ -555,30 +555,6 @@ namespace GameCult.Networking Details = new CultNetErrorDetails { Field = "recordKey", Value = request.RecordKey.Value } }; - /// - /// Builds the wire error for a message whose schemaVersion this peer does not speak: code - /// unsupported_schema_version, details { field: "schemaVersion", value }. - /// - public static CultNetErrorMessage ForUnsupportedSchemaVersion(string? schemaVersion) => - new CultNetErrorMessage - { - Error = $"unsupported_schema_version: {schemaVersion}", - Code = "unsupported_schema_version", - Details = new CultNetErrorDetails { Field = "schemaVersion", Value = schemaVersion } - }; - - /// - /// Builds the wire error for a request that lacks a required field or carries an ill-typed one: code - /// message_invalid, details { field } naming that request field. - /// - public static CultNetErrorMessage ForMessageInvalid(string field, string reason) => - new CultNetErrorMessage - { - Error = $"message_invalid: {reason}", - Code = "message_invalid", - Details = new CultNetErrorDetails { Field = field } - }; - /// /// Builds the wire error for a cursor refusal (R-N): code cursor_stale with details /// { asOf, current }, or code cursor_invalid with no details. diff --git a/src/GameCult.Networking/CultNetShardWriteForwarding.cs b/src/GameCult.Networking/CultNetShardWriteForwarding.cs index 0fa6c4f7..7038b0a7 100644 --- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs +++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs @@ -68,12 +68,6 @@ namespace GameCult.Networking /// rudp:// endpoints use the C# RUDP schema client and cultnet:// endpoints use LiteNetLib. /// public Func? CreateClient { get; set; } - - /// - /// Gets or sets how long after a forwarded write the primary's refusal may still arrive; a refusal fails the - /// forward with . Defaults to . - /// - public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow; } /// @@ -91,10 +85,7 @@ namespace GameCult.Networking _options = options ?? new CultNetSchemaWriteForwarderOptions(); } - /// - /// Forwards a put and completes on the primary's result frame: a refusal throws - /// , the same failure path as an unreachable primary. - /// + /// public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); @@ -104,10 +95,7 @@ namespace GameCult.Networking await SendAsync(endpoint, message).ConfigureAwait(false); } - /// - /// Forwards a delete and completes on the primary's result frame: a refusal throws - /// . - /// + /// public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); @@ -144,14 +132,7 @@ namespace GameCult.Networking using var client = CreateClient(endpoint); client.Connect(host, port); await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); - await CultNetWriteCompletion.SendAndAwaitResultAsync( - refuse => - { - client.OnCultNet(refuse); - return null; - }, - () => client.SendCultNet(message), - _options.ResultWindow).ConfigureAwait(false); + client.SendCultNet(message); } private ICultNetSchemaClient CreateClient(string endpoint) diff --git a/src/GameCult.Networking/CultNetTransport.cs b/src/GameCult.Networking/CultNetTransport.cs index da465326..ac2c2952 100644 --- a/src/GameCult.Networking/CultNetTransport.cs +++ b/src/GameCult.Networking/CultNetTransport.cs @@ -2759,11 +2759,6 @@ namespace GameCult.Networking // under SessionGate by whichever thread's send found the failure; the polling thread reads // it when it retires the peer. internal byte[]? UnsendableReason { get; set; } - - // Set once the peer's capped frame has been answered: the session ends with this reason when the - // peer has acknowledged every answer, or at the deadline. Under SessionGate. - internal byte[]? EndingReason { get; set; } - internal long EndingDeadlineMs { get; set; } } /// @@ -2933,7 +2928,6 @@ namespace GameCult.Networking public bool TryReceiveOnce(out CultNetRudpSocketServerFrame? delivered) { RetireUnsendablePeers(); - EndAnsweredPeers(); if (_deliveredFrames.Count > 0) { delivered = _deliveredFrames.Dequeue(); @@ -3152,7 +3146,6 @@ namespace GameCult.Networking public void PollResends() { RetireUnsendablePeers(); - EndAnsweredPeers(); if (_socket.Poll(0, SelectMode.SelectRead)) return; foreach (var peer in _peers.Values.ToArray()) @@ -3251,44 +3244,6 @@ namespace GameCult.Networking return true; } - // Polling thread only. The application answered a peer's capped frame: the answer is reliable, so a - // lost datagram is resent, and the session ends with `reason` once the peer has acknowledged every - // answer or `graceMs` have passed (EndAnsweredPeers). Frames of the peer still queued are not delivered. - internal void EndPeerSessionWhenAnswered(CultNetRudpSocketServerPeer peer, byte[] reason, long graceMs) - { - if (peer == null) throw new ArgumentNullException(nameof(peer)); - if (reason == null) throw new ArgumentNullException(nameof(reason)); - lock (peer.SessionGate) - { - if (peer.Session.Ended || peer.EndingReason != null) - return; - peer.EndingReason = reason; - peer.EndingDeadlineMs = NowMs() + graceMs; - } - var kept = _deliveredFrames.Where(frame => !ReferenceEquals(frame.Peer, peer)).ToArray(); - _deliveredFrames.Clear(); - foreach (var frame in kept) - _deliveredFrames.Enqueue(frame); - } - - // Polling thread only. - private void EndAnsweredPeers() - { - foreach (var peer in _peers.Values.ToArray()) - { - byte[]? reason; - lock (peer.SessionGate) - { - reason = peer.EndingReason != null - && (peer.Session.OutstandingReliablePacketCount == 0 || NowMs() >= peer.EndingDeadlineMs) - ? peer.EndingReason - : null; - } - if (reason != null) - EndPeerSession(peer, reason); - } - } - // Polling thread only. A peer a new Connect already replaced was reported then, and its key now // names the peer that replaced it. private void RetireUnsendablePeers() diff --git a/src/GameCult.Networking/CultNetWriteCompletion.cs b/src/GameCult.Networking/CultNetWriteCompletion.cs deleted file mode 100644 index 6d5c226d..00000000 --- a/src/GameCult.Networking/CultNetWriteCompletion.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace GameCult.Networking -{ - /// - /// A peer refused a write: the typed cultnet.error.v0 it answered with. - /// - public sealed class CultNetWriteRefusedException : Exception - { - /// Creates the exception from the refusal frame. - public CultNetWriteRefusedException(CultNetErrorMessage refusal) - : base(refusal?.Error ?? throw new ArgumentNullException(nameof(refusal))) - { - Code = refusal.Code; - Refusal = refusal; - } - - /// Gets the machine-readable refusal code, e.g. write_not_authorized or message_invalid. - public string? Code { get; } - - /// Gets the refusal frame as received. - public CultNetErrorMessage Refusal { get; } - } - - /// - /// Completes a write on the peer's result frame, never on a send's return or a transport acknowledgement: - /// an ack proves delivery, not admission. - /// - public static class CultNetWriteCompletion - { - /// How long after a send a refusal may still arrive when the caller names no window. - public static readonly TimeSpan DefaultResultWindow = TimeSpan.FromMilliseconds(500); - - /// - /// Sends one write and waits for the peer's answer. A refusal frame rejects with - /// whatever its order against the transport acknowledgement. A peer - /// that admits a write sends no success frame, so the end of the window is the interim bound on a refusal - /// (until peers send one); the error frame carries no message id, so any refusal inside the window is this - /// write's. - /// - /// Registers the refusal handler and returns its registration; called before the send. - /// Sends the write. - /// How long a refusal may take to arrive. - /// Cancels the wait. - public static async Task SendAndAwaitResultAsync( - Func, IDisposable?> subscribe, - Action send, - TimeSpan resultWindow, - CancellationToken cancellationToken = default) - { - if (subscribe == null) throw new ArgumentNullException(nameof(subscribe)); - if (send == null) throw new ArgumentNullException(nameof(send)); - var refused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var registration = subscribe(error => refused.TrySetResult(error)); - send(); - var window = Task.Delay(resultWindow, cancellationToken); - await Task.WhenAny(refused.Task, window).ConfigureAwait(false); - if (refused.Task.IsCompleted) throw new CultNetWriteRefusedException(await refused.Task.ConfigureAwait(false)); - await window.ConfigureAwait(false); - } - } -} diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index dea0e411..7347d145 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -431,7 +431,7 @@ opt-in. Text frames and messages over the configured size bound are closed before schema dispatch. The matching `CultNetWebSocketSchemaClient` is suitable for C# headless and -service consumers. Browser applications use `@gamecult/cultmesh-browser`, which owns +service consumers. Browser applications use `cultmesh-browser`, which owns stable Verse/provider identity, document leases, resubscription, and operation correlation above the raw WebSocket route. diff --git a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs index 9b6e3db7..dcabf6e6 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs @@ -291,51 +291,6 @@ public sealed class CultMeshClientTests submitted.Document.SourceRole.Should().Be("pilot"); } - [TestCase("write_not_authorized", 0)] - [TestCase("message_invalid", 0)] - [TestCase("write_not_authorized", 150)] - public async Task SubmitDocument_ARefusalRejectsWithTheTypedError_WhateverItsOrderAgainstTheSend(string code, int delayMs) - { - var connector = new DocumentConnector - { - RefuseSubmissionsWith = code, - RefusalDelay = TimeSpan.FromMilliseconds(delayMs) - }; - using var mesh = new CultMeshClient(new CultMeshClientOptions - { - RendezvousEndpoints = new[] { "rudp://odin:3076" }, - Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, - Sessions = CultMeshTestTrust.LocalSessions, - Connectors = new[] { connector } - }); - var request = new ClientTestDocument { Id = "command:1", Text = "move" }; - - var act = () => mesh.SubmitDocumentAsync(Target, request.Id, request, "pilot-one", "pilot"); - - (await act.Should().ThrowAsync()).Which.Code.Should().Be(code); - } - - [Test] - public async Task SubmitDocument_CompletesOnlyAfterTheResultWindow_NotOnTheSendReturning() - { - var connector = new DocumentConnector(); - using var mesh = new CultMeshClient(new CultMeshClientOptions - { - RendezvousEndpoints = new[] { "rudp://odin:3076" }, - Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, - Sessions = CultMeshTestTrust.LocalSessions, - Connectors = new[] { connector }, - SubmitResultWindow = TimeSpan.FromMilliseconds(400) - }); - var request = new ClientTestDocument { Id = "command:1", Text = "move" }; - await mesh.ConnectAsync(Target, CultMeshProtocols.Documents); - var started = DateTime.UtcNow; - - await mesh.SubmitDocumentAsync(Target, request.Id, request, "pilot-one", "pilot"); - - (DateTime.UtcNow - started).Should().BeGreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380)); - } - [Test] public async Task InvokeOperation_CorrelatesProviderReceiptThroughStableIdentitySession() { @@ -541,8 +496,6 @@ public sealed class CultMeshClientTests public bool SuppressFirstSnapshot { get; set; } public bool SuppressFirstSubscriptionSnapshot { get; set; } public bool RejectOperations { get; set; } - public string? RefuseSubmissionsWith { get; set; } - public TimeSpan RefusalDelay { get; set; } public bool FailFirstOperationBeforeResponse { get; set; } public string ResponseSourceRuntimeId { get; set; } = "aetheria-daemon"; public string ConnectorId => "document-test"; @@ -562,8 +515,6 @@ public sealed class CultMeshClientTests rejectOperations: RejectOperations, failOperationBeforeResponse: FailFirstOperationBeforeResponse && Clients.Count == 0, responseSourceRuntimeId: ResponseSourceRuntimeId); - client.RefuseSubmissionsWith = RefuseSubmissionsWith; - client.RefusalDelay = RefusalDelay; Clients.Add(client); return Task.FromResult(client); } @@ -571,9 +522,6 @@ public sealed class CultMeshClientTests private sealed class DocumentClient : ICultNetSchemaClient, ICultNetSchemaClientHealth, ICultMeshVerifiedSchemaClient { - private readonly List> _errors = new(); - public string? RefuseSubmissionsWith { get; set; } - public TimeSpan RefusalDelay { get; set; } private readonly List> _snapshots = new(); private readonly List> _operationResponses = new(); private readonly CultNetDocumentRegistry _documents; @@ -610,15 +558,6 @@ public sealed class CultMeshClientTests if (message is CultNetDocumentPutRawMessage submitted) { Submitted.Add(submitted); - if (RefuseSubmissionsWith != null) - { - var refusal = new CultNetErrorMessage { Error = "not allowed", Code = RefuseSubmissionsWith }; - // A refusal that follows the send's return (and any transport ack) must still reject the submit. - Task.Delay(RefusalDelay).ContinueWith(_ => - { - foreach (var handler in _errors.ToArray()) handler(refusal); - }); - } return; } if (message is CultNetOperationRequestMessage operation) @@ -703,8 +642,6 @@ public sealed class CultMeshClientTests _snapshots.Add(response => callback((T)(object)response)); if (typeof(T) == typeof(CultNetOperationResponseMessage)) _operationResponses.Add(response => callback((T)(object)response)); - if (typeof(T) == typeof(CultNetErrorMessage)) - _errors.Add(error => callback((T)(object)error)); } public void Dispose() { } public void Fail(Exception error) => _failure.TrySetResult(error); diff --git a/tests/GameCult.Networking.Tests/CarrierVectorTests.cs b/tests/GameCult.Networking.Tests/CarrierVectorTests.cs deleted file mode 100644 index 8d50bb76..00000000 --- a/tests/GameCult.Networking.Tests/CarrierVectorTests.cs +++ /dev/null @@ -1,178 +0,0 @@ -#nullable enable -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Nodes; -using MessagePack; -using NUnit.Framework; - -namespace GameCult.Networking.Tests -{ - // The C# reference's own bytes for the selection/v1 message carriers and the cultnet.error.v0 - // code/details shape, written to contracts/cultnet/interop/carrier-vectors.cs-written.json for - // the runtimes that decode and re-encode them (packages/cultnet-ts reads it; a Rust or Python - // reader may follow). Each vector carries the whole message's MessagePack bytes plus `expected`, - // the reference's own camelCase JSON of the same object (byte[] as base64), so a reader judges - // against what the reference serialized rather than a second hand-written spec. - // - // Gated like SelectionParityVectorTests: CULTNET_WRITE_VECTORS=1 rewrites the committed file; - // otherwise the committed bytes are compared with a fresh serialization so reference drift fails - // here instead of in a reader. - public sealed class CarrierVectorTests - { - private const string LeafA = "sha256:ff35f3d9d6de3f1066b9369e73fe6c7648e88368d18efc2275c994730cd84cff"; - private const string CiterId = "sha256:9d0bc9aebc3619b07aabf564f98667afde41444c1b866661d777687d443608dc"; - - private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; - - private static CultNetSelection FullSelection() => new() - { - Schemas = new[] { "leaf_a.v9", LeafA }, - Keys = new[] { "a-eq", "\U0001F600-row" }, - Fields = new[] - { - new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon", "tool" } }, - new CultNetFieldPredicate { Index = "mass", Op = "ge", Number = "-2233759.25" } - }, - Cites = new CultNetCitation { Target = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }, Role = "Design" }, - Cited = new CultNetIncoming { Role = "Design", Exists = false }, - Projection = CultNetSelectionProjections.Document, - Descending = true, - Limit = 200, - Cursor = "opaque-cursor" - }; - - private static CultNetRawDocumentHeader Header(string key, params string[] tags) => new() - { - SchemaId = LeafA, - SchemaName = "leaf_a", - SchemaVersion = "1", - SchemaContentHash = "abc", - RecordKey = key, - StoredAt = "1970-01-01T00:00:00.000Z", - SourceRuntimeId = "rt", - SourceAgentId = "agent", - SourceRole = "role", - Tags = tags.Length == 0 ? null : tags - }; - - private static IEnumerable<(string Name, ICultNetSchemaMessage Message)> Cases() - { - yield return ("snapshot_request_v1_defaults", new CultNetSnapshotRequestV1Message { MessageId = "m" }); - yield return ("snapshot_request_v1_full_selection_and_shard", new CultNetSnapshotRequestV1Message - { - MessageId = "m-full", Selection = FullSelection(), ShardId = "shard-1", ShardEpoch = 7 - }); - yield return ("database_subscribe_v1_defaults", new CultNetDatabaseSubscribeV1Message { MessageId = "m", SubscriptionId = "sub" }); - yield return ("database_subscribe_v1_full", new CultNetDatabaseSubscribeV1Message - { - MessageId = "m-full", SubscriptionId = "sub-full", Selection = FullSelection(), IncludeSnapshot = false, - ConsumerRuntimeId = "consumer", BodyIds = new[] { "body-a" }, SupportedBodyTransports = new[] { "rudp", "quic" } - }); - yield return ("snapshot_response_raw_v1_headers", new CultNetSnapshotResponseRawV1Message - { - MessageId = "m", Matched = 2, AsOf = 5, Next = "next-cursor", - Headers = new[] { Header("a-eq"), Header("a-lo", "alpha", "beta") }, - ShardId = "shard-1", ShardEpoch = 3, ShardLogSequence = 41 - }); - yield return ("snapshot_response_raw_v1_documents_and_edges", new CultNetSnapshotResponseRawV1Message - { - MessageId = "m", Matched = 1, AsOf = 5, - Documents = new[] - { - new CultNetRawDocumentRecord { SchemaId = LeafA, RecordKey = "a-eq", StoredAt = "1970-01-01T00:00:00.000Z", Payload = new byte[] { 0x81, 0xA1, 0x6B, 0x01 } } - }, - Edges = new[] - { - new CultNetEdge - { - From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design", - To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" } - }, - new CultNetEdge - { - From = new CultNetRecordRef { SchemaId = CiterId, RecordKey = "citer-1" }, Role = "Design", - To = new CultNetRecordRef { SchemaId = LeafA, RecordKey = "a-eq" }, - PayloadEncoding = "messagepack", Payload = new byte[] { 1, 2, 3 } - } - } - }); - yield return ("error_plain", new CultNetErrorMessage { Error = "boom" }); - yield return ("error_selection_invalid", CultNetErrorMessage.ForSelectionInvalid( - new CultNetSelectionInvalidException("fields[0].index", "kind", "fields[0].index \"kind\" is not declared."))); - yield return ("error_cursor_stale", new CultNetErrorMessage - { - Error = "cursor_stale: stale", Code = "cursor_stale", Details = new CultNetErrorDetails { AsOf = 3, Current = 9 } - }); - yield return ("error_cursor_invalid_no_details", new CultNetErrorMessage { Error = "cursor_invalid: bad", Code = "cursor_invalid" }); - yield return ("error_write_not_authorized", new CultNetErrorMessage - { - Error = "write_not_authorized", Code = "write_not_authorized", Details = new CultNetErrorDetails { Field = "recordKey", Value = "k" } - }); - } - - private static object Vector(string name, ICultNetSchemaMessage message) - { - var bytes = MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options); - return new - { - name, - schemaVersion = message.SchemaVersion, - messagePackBase64 = Convert.ToBase64String(bytes), - expected = WithoutDerivedMembers(JsonSerializer.SerializeToNode(message, message.GetType(), JsonOptions)) - }; - } - - // CultNetSelection.HasHop is an [IgnoreMember] convenience, not wire; System.Text.Json would emit it. - private static JsonNode? WithoutDerivedMembers(JsonNode? node) - { - switch (node) - { - case JsonObject obj: - obj.Remove("hasHop"); - foreach (var child in obj.Select(pair => pair.Value).ToArray()) WithoutDerivedMembers(child); - break; - case JsonArray array: - foreach (var child in array) WithoutDerivedMembers(child); - break; - } - return node; - } - - private static string VectorPath() - { - var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); - while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) - directory = directory.Parent; - var root = directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory."); - return Path.Combine(root, "contracts", "cultnet", "interop", "carrier-vectors.cs-written.json"); - } - - [Test] - public void WriteCarrierVectors() - { - if (Environment.GetEnvironmentVariable("CULTNET_WRITE_VECTORS") != "1") - Assert.Ignore("Set CULTNET_WRITE_VECTORS=1 to (re)write the committed vector file."); - - var vectors = Cases().Select(c => Vector(c.Name, c.Message)).ToArray(); - File.WriteAllText(VectorPath(), JsonSerializer.Serialize(new { vectors }, new JsonSerializerOptions { WriteIndented = true })); - } - - [Test] - public void CommittedCarrierVectorsMatchAFreshSerialization() - { - using var committed = JsonDocument.Parse(File.ReadAllText(VectorPath())); - var byName = committed.RootElement.GetProperty("vectors").EnumerateArray() - .ToDictionary(v => v.GetProperty("name").GetString()!, v => v.GetProperty("messagePackBase64").GetString()!); - - Assert.That(byName.Count, Is.EqualTo(Cases().Count()), "the committed file must carry exactly the cases this writer produces"); - foreach (var (name, message) in Cases()) - { - var fresh = Convert.ToBase64String(MessagePackSerializer.Serialize(message.GetType(), message, CultNetSchemaMessageSerialization.Options)); - Assert.That(byName[name], Is.EqualTo(fresh), $"{name}: the committed bytes drifted from the reference serializer"); - } - } - } -} diff --git a/tests/GameCult.Networking.Tests/CultNetClientWriteCompletionTests.cs b/tests/GameCult.Networking.Tests/CultNetClientWriteCompletionTests.cs deleted file mode 100644 index 8641a082..00000000 --- a/tests/GameCult.Networking.Tests/CultNetClientWriteCompletionTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -#nullable enable -using System; -using System.Collections.Concurrent; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using NUnit.Framework; - -namespace GameCult.Networking.Tests -{ - /// - /// 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 C# schema client has no - /// completion call: SendCultNet returns once the put is queued, and the outcome reaches the caller only - /// as the result frame, through OnCultNet. These tests serve it a peer that acknowledges the put and - /// refuses it, the ack before the refusal and after it, and require the refusal to arrive as the outcome. - /// - public class CultNetClientWriteCompletionTests - { - private const uint ConnectionId = 0x10203197; - - // Acknowledges the first reliable schema frame it receives and answers it with write_not_authorized, - // the second packet 150 ms after the first. The refusal carries no acknowledgement of its own. - private static Task RefusingPeer(Socket socket, bool refusalFirst, CancellationToken stop) - { - return Task.Run(() => - { - var session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, ResendDelayMs = 25 }); - var buffer = new byte[65535]; - EndPoint from = new IPEndPoint(IPAddress.Any, 0); - var answered = false; - while (!stop.IsCancellationRequested) - { - if (!socket.Poll(10_000, SelectMode.SelectRead)) - continue; - var received = socket.ReceiveFrom(buffer, ref from); - var packet = CultNetRudpPacketCodec.Decode(buffer.AsSpan(0, received).ToArray()); - void Send(CultNetRudpPacket p) => socket.SendTo(CultNetRudpPacketCodec.Encode(p), from); - if (packet.PacketType == CultNetRudpPacketType.Connect) - { - Send(session.AcceptConnect(packet)); - continue; - } - var result = session.Receive(packet, 1); - if (result.Reply != null) - Send(result.Reply); - if (answered || result.Delivered.Count == 0) - continue; - answered = true; - var ack = session.CreateAckForReceived(packet.Sequence); - var refusal = session.Send("schema", CultNetSchemaMessageSerialization.Serialize(new CultNetErrorMessage - { - Error = "write_not_authorized: the catalog refused the document", - Code = "write_not_authorized" - }), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); - refusal.Ack = 0; - refusal.AckMask = 0; - if (refusalFirst) - { - Send(refusal); - Thread.Sleep(150); - Send(ack); - } - else - { - Send(ack); - Thread.Sleep(150); - Send(refusal); - } - } - }); - } - - [TestCase(false, TestName = "AckThenRefusal")] - [TestCase(true, TestName = "RefusalThenAck")] - public async Task ARefusedPutIsReportedRefusedWhateverTheOrderOfItsAck(bool refusalFirst) - { - using var serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - serverSocket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); - using var stop = new CancellationTokenSource(); - var peer = RefusingPeer(serverSocket, refusalFirst, stop.Token); - using var client = new RudpCultNetSchemaClient("completion", ConnectionId); - var outcomes = new BlockingCollection(); - client.OnCultNet(outcomes.Add); - client.Connect("127.0.0.1", ((IPEndPoint)serverSocket.LocalEndPoint!).Port); - var deadline = DateTime.UtcNow.AddSeconds(3); - while (!client.Connected && DateTime.UtcNow < deadline) - await Task.Delay(5); - Assert.That(client.Connected, "fixture: the client connected"); - - client.SendCultNet(new CultNetDocumentPutRawMessage { MessageId = "completion" }); - - Assert.That(outcomes.TryTake(out var refusal, 3000), Is.True, "the result frame reached the caller"); - Assert.That(refusal!.Code, Is.EqualTo("write_not_authorized")); - stop.Cancel(); - await peer; - } - } -} diff --git a/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs b/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs deleted file mode 100644 index 67c2f06d..00000000 --- a/tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -#nullable enable -using System; -using System.IO; -using NUnit.Framework; - -// snippet:begin -using System.Net; -using System.Net.Sockets; -using System.Threading.Tasks; -using GameCult.Caching; -using GameCult.Networking; -using MessagePack; - -[CultDocument("gamecult.first_call_note", "gamecult.first_call_note.v1")] -[MessagePackObject] -public sealed class Note -{ - [Key(0)] [CultName] public string Title = string.Empty; - [Key(1)] public string Body = string.Empty; -} - -public static class CultNetFirstCall -{ - public static async Task Run() - { - var inbox = new CultCache(); // the receiver's cache - var documents = new CultNetDocumentRegistry(); - var received = new TaskCompletionSource(); - - // Receive: a loopback listener that applies each raw document put to the inbox. - using var server = new TcpFramedCultNetSchemaServer(new TcpListener(IPAddress.Loopback, 0)); - server.OnCultNet(async (put, peer) => - { - await documents.ApplyRawDocumentPutMessageAsync(inbox, put); - received.TrySetResult(true); - }); - - // Send: a typed document as a cultnet.document_put_raw.v0 message. - using var client = new TcpFramedCultNetSchemaClient(); - client.Connect("127.0.0.1", server.LocalEndPoint.Port); - var handle = new CultRecordHandle(new CultRecordKey("note:hello")); - client.SendCultNet(documents.CreateRawDocumentPutMessage( - "put-1", handle, new Note { Title = "Hello", Body = "sent over CultNet" })); - - await received.Task.WaitAsync(TimeSpan.FromSeconds(5)); - return inbox.Get(handle.Key); - } -} -// snippet:end - -namespace GameCult.Networking.Tests -{ - // The C# CultNet first-call snippet that docs/start/README.md shows verbatim: define a typed document, send it - // over a loopback TCP pipe, receive it into a second cache. DocsShowTheTestedSnippet fails when they differ. - public class CultNetFirstCallTests - { - [Test] - public async Task SendATypedDocumentOverLoopbackAndReceiveItIntoASecondCache() - { - var note = await CultNetFirstCall.Run(); - Assert.That(note, Is.Not.Null); - Assert.That(note!.Title, Is.EqualTo("Hello")); - Assert.That(note.Body, Is.EqualTo("sent over CultNet")); - } - - [Test] - public void DocsShowTheTestedSnippet() - { - var source = File.ReadAllText(SourcePath()); - const string begin = "// snippet:begin\n"; - var start = source.IndexOf(begin, StringComparison.Ordinal) + begin.Length; - var snippet = source[start..source.IndexOf("// snippet:end\n", StringComparison.Ordinal)]; - var docs = File.ReadAllText(Path.Combine(Path.GetDirectoryName(SourcePath())!, "..", "..", "docs", "start", "README.md")); - Assert.That(docs.Replace("\r\n", "\n"), Does.Contain("```csharp\n" + snippet + "```"), - "docs/start/README.md no longer shows tests/GameCult.Networking.Tests/CultNetFirstCallTests.cs verbatim"); - } - - static string SourcePath([System.Runtime.CompilerServices.CallerFilePath] string path = "") => path; - } -} diff --git a/tests/GameCult.Networking.Tests/CultNetRudpMultiPeerVectorTests.cs b/tests/GameCult.Networking.Tests/CultNetRudpMultiPeerVectorTests.cs deleted file mode 100644 index 0985d516..00000000 --- a/tests/GameCult.Networking.Tests/CultNetRudpMultiPeerVectorTests.cs +++ /dev/null @@ -1,155 +0,0 @@ -#nullable enable -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Text; -using NUnit.Framework; - -namespace GameCult.Networking.Tests -{ - /// - /// The multi-peer listener's golden in tests/vectors/rudp-multipeer-server: two peers on one socket, the - /// datagrams each sends and the datagrams the C# reference answers with. The C# reference is the only writer - /// (CULTNET_WRITE_VECTORS=1); every other run replays the scenario and compares it byte for byte. Other runtimes' - /// listeners replay the same transcript and are held to the same answers. - /// - public class CultNetRudpMultiPeerVectorTests - { - private const string WriteVariable = "CULTNET_WRITE_VECTORS"; - private const uint ConnectionId = 0x43554c54; - - private sealed class Client - { - public string Label = ""; - public Socket Socket = null!; - public CultNetRudpSession Session = null!; - } - - private static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant(); - - private static Socket Bind() - { - var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); - return socket; - } - - [Test] - public void TheReferenceListenerAnswersTwoPeersWithThePinnedBytes() - { - var transcript = Run(); - var path = Path.Combine(VectorDirectory(), "transcript.txt"); - if (Environment.GetEnvironmentVariable(WriteVariable) == "1") - { - File.WriteAllText(path, transcript); - Assert.Pass("wrote " + path); - } - Assert.That(transcript, Is.EqualTo(File.ReadAllText(path))); - } - - private static string VectorDirectory() - { - var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); - while (directory != null && !Directory.Exists(Path.Combine(directory.FullName, "tests", "vectors"))) - directory = directory.Parent; - if (directory == null) throw new InvalidOperationException("tests/vectors not found above the test directory."); - return Path.Combine(directory.FullName, "tests", "vectors", "rudp-multipeer-server"); - } - - private static string Run() - { - var log = new StringBuilder(); - log.Append("# config connection_id=0x43554c54 initial_sequence=500 max_fragment_bytes=8 accept_payload=616363\n"); - log.Append("# in | send | out ; peer labels name remote endpoints\n"); - var serverSocket = Bind(); - using var server = new CultNetRudpSocketTransportServer(new CultNetRudpSocketTransportServerOptions - { - RuntimeId = "csharp-vector", - Socket = serverSocket, - ConnectionId = ConnectionId, - InitialSequence = 500, - MaxFragmentBytes = 8, - AcceptPayload = Encoding.ASCII.GetBytes("acc") - }); - var target = serverSocket.LocalEndPoint!; - Client Make(string label, uint sequence) => new Client - { - Label = label, - Socket = Bind(), - Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, InitialSequence = sequence }) - }; - var x = Make("X", 10); - var y = Make("Y", 20); - var clients = new[] { x, y }; - - void Deliver(Client client, CultNetRudpPacket packet) - { - var wire = CultNetRudpPacketCodec.Encode(packet); - log.Append($"in {client.Label} {Hex(wire)}\n"); - client.Socket.SendTo(wire, target); - while (!server.TryReceiveOnce(out _)) - System.Threading.Thread.Sleep(1); - Flush(); - } - - void Flush() - { - foreach (var client in clients) - { - var buffer = new byte[65535]; - EndPoint from = new IPEndPoint(IPAddress.Any, 0); - while (client.Socket.Poll(30_000, SelectMode.SelectRead)) - { - var received = client.Socket.ReceiveFrom(buffer, ref from); - var wire = buffer.AsSpan(0, received).ToArray(); - log.Append($"out {client.Label} {Hex(wire)}\n"); - // Clients that stay connected take the server's packets so their acks stay true. - var packet = CultNetRudpPacketCodec.Decode(wire); - if (client.Session.Connected || packet.PacketType == CultNetRudpPacketType.Accept) - client.Session.Receive(packet, 0); - } - } - } - - CultNetRudpSocketServerPeer PeerOf(Client client) => - server.Peers.Single(p => p.RemoteEndPoint.Equals(client.Socket.LocalEndPoint)); - - void Send(Client client, string channel, byte[] payload) - { - log.Append($"send {client.Label} {channel} {Hex(payload)}\n"); - server.Send(PeerOf(client), channel, payload); - Flush(); - } - - CultNetRudpPacket Data(Client client, string text) => - client.Session.Send("schema", Encoding.ASCII.GetBytes(text), new CultNetRudpSendOptions { Reliable = true, Ordered = true }); - - var xConnect = x.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hx")); - Deliver(x, xConnect); - Deliver(y, y.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hy"))); - // A repeat of X's accepted Connect is answered with the Accept owed, not a new session. - Deliver(x, xConnect); - Deliver(x, Data(x, "ping-x")); - Deliver(y, Data(y, "ping-y")); - Send(x, "schema", Encoding.ASCII.GetBytes("pong")); - // Larger than the fragment size: three fragments, one per datagram. - Send(y, "schema", Encoding.ASCII.GetBytes("0123456789abcdefghijklmn")); - Send(x, "latest", Encoding.ASCII.GetBytes("sample")); - // X leaves; Y is undisturbed, and what X sends afterwards belongs to no session. - var late = Data(x, "after-bye"); - Deliver(x, x.Session.CreateDisconnect(Encoding.ASCII.GetBytes("bye"))); - Deliver(x, late); - Send(y, "schema", Encoding.ASCII.GetBytes("still-here")); - // Y reconnects from the same endpoint: a new generation replaces only Y. - y.Session = new CultNetRudpSession(new CultNetRudpSessionOptions { ConnectionId = ConnectionId, InitialSequence = 30 }); - Deliver(y, y.Session.CreateConnect(0, Encoding.ASCII.GetBytes("hy2"))); - Send(y, "schema", Encoding.ASCII.GetBytes("new-gen")); - log.Append($"# peers {server.Peers.Count} dropped {server.Stats.PacketsDropped}\n"); - foreach (var client in clients) client.Socket.Dispose(); - return log.ToString(); - } - } -} diff --git a/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs b/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs index e7900e7c..b8de674f 100644 --- a/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs @@ -14,9 +14,8 @@ namespace GameCult.Networking.Tests { /// /// A schema server's poll serves every peer whatever one peer sends. A message that cannot be - /// decoded is answered message_invalid and its session kept until MaxConsecutiveRefusedFrames in a - /// row end it; one that cannot be handled ends that peer's session. The fault is logged, and the - /// drain goes on to the other peers. + /// decoded or handled is that peer's failure: its session ends, the drop is counted and logged, + /// and the drain goes on to the other peers. /// public class CultNetRudpSchemaServerPeerInputTests { @@ -162,165 +161,30 @@ namespace GameCult.Networking.Tests } [Test] - public async Task APeerWhoseMessageCannotBeDecodedIsAnsweredAndKept() + public async Task APeerWhoseMessageCannotBeDecodedHasItsSessionEnded() { using var scene = await BadThenGood(Malformed()); + var dropped = scene.Server.Stats.PacketsDropped; try { await scene.Server.PollAvailableAsync(16); } catch (MessagePackSerializationException) { Assert.Fail("the poll threw for a peer's input"); } - Assert.That(scene.Ended, Is.Empty); - Assert.That(scene.Server.Peers, Has.Count.EqualTo(2)); - var refusal = ErrorsReceivedBy(scene.Bad).Single(); - Assert.That(refusal.Code, Is.EqualTo("message_invalid")); - Assert.That(refusal.Details!.Field, Is.EqualTo("message")); + // The frame was already acknowledged, so the session cannot be kept: the peer is told, + // the listener forgets it, and the drop is counted. + Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint })); + Assert.That(scene.Server.Peers.Select(peer => peer.RemoteEndPoint), Is.EqualTo(new[] { scene.Good.EndPoint })); + Assert.That(scene.Server.Stats.PacketsDropped, Is.EqualTo(dropped + 1)); + var goodbye = Drain(scene.Bad.Socket).Single(packet => packet.PacketType == CultNetRudpPacketType.Disconnect); + Assert.That(scene.Bad.Session.Receive(goodbye, 1).Disconnected, Is.True); Assert.That(Drain(scene.Good.Socket).Select(packet => packet.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect)); Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1)); } - private static List ErrorsReceivedBy(Client client) - { - var errors = new List(); - foreach (var packet in Drain(client.Socket)) - { - var received = client.Session.Receive(packet, 1); - foreach (var frame in received.Delivered) - if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetErrorMessage error) - errors.Add(error); - } - return errors; - } - - // The bad peer acknowledges what it received and the server polls on. - private static async Task Acknowledge(Scene scene) - { - scene.Bad.Socket.SendTo(CultNetRudpPacketCodec.Encode(scene.Bad.Session.CreateAck()), scene.Server.LocalEndPoint); - await scene.Server.PollAvailableAsync(16); - } - - // The bad peer has sent seven undecodable frames (BadThenGood sent the first), each answered. - private static async Task AtTheCap() - { - var scene = await BadThenGood(Malformed()); - await scene.Server.PollAvailableAsync(16); - ErrorsReceivedBy(scene.Bad); - for (var i = 0; i < 6; i++) - { - SendSchema(scene.Server, scene.Bad, Malformed()); - await scene.Server.PollAvailableAsync(16); - ErrorsReceivedBy(scene.Bad); - } - Assert.That(scene.Ended, Is.Empty); - return scene; - } - - // The capped frame's answer is lost on the wire: the server resends it, and the goodbye follows only - // once the peer has acknowledged it. - [Test] - public async Task ALostCappedAnswerIsResentAndTheGoodbyeWaitsForItsAcknowledgement() - { - using var scene = await AtTheCap(); - SendSchema(scene.Server, scene.Bad, Malformed()); - await scene.Server.PollAvailableAsync(16); - - var lost = Drain(scene.Bad.Socket); - Assert.That(lost.Where(p => p.PacketType == CultNetRudpPacketType.Data), Is.Not.Empty, "fixture: the answer was sent and is lost here"); - Assert.That(lost.Select(p => p.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect), "no goodbye before the answer is acknowledged"); - Assert.That(scene.Ended, Is.Empty); - - var resent = new List(); - for (var attempt = 0; attempt < 40 && resent.Count == 0; attempt++) - { - await Task.Delay(50); - scene.Server.PollResends(); - resent = Drain(scene.Bad.Socket); - } - Assert.That(resent.Select(p => p.PacketType), Has.None.EqualTo(CultNetRudpPacketType.Disconnect), "still no goodbye"); - var answers = new List(); - foreach (var packet in resent) - foreach (var frame in scene.Bad.Session.Receive(packet, 1).Delivered) - if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetErrorMessage error) - answers.Add(error); - Assert.That(answers.Select(e => e.Code), Is.EqualTo(new[] { "message_invalid" }), "the lost answer arrived by resend"); - Assert.That(scene.Ended, Is.Empty); - - await Acknowledge(scene); - Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint }), "acknowledged: the session ends"); - Assert.That(Drain(scene.Bad.Socket).Select(p => p.PacketType), Has.Some.EqualTo(CultNetRudpPacketType.Disconnect)); - } - - // A peer that never acknowledges the answer is not kept for ever: the session ends after the grace. - [Test] - public async Task ACappedSessionWhoseAnswerIsNeverAcknowledgedEndsAfterTheGrace() - { - using var scene = await AtTheCap(); - SendSchema(scene.Server, scene.Bad, Malformed()); - await scene.Server.PollAvailableAsync(16); - Drain(scene.Bad.Socket); - - await Task.Delay(1500); // the grace is two seconds - scene.Server.PollResends(); - Assert.That(scene.Ended, Is.Empty, "inside the grace"); - await Task.Delay(700); - scene.Server.PollResends(); - Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint }), "the grace ran out"); - } - - // Nothing behind the capped frame is served while its answer is delivered. - [Test] - public async Task NothingBehindTheCappedFrameIsServed() - { - using var scene = await AtTheCap(); - SendSchema(scene.Server, scene.Bad, Malformed()); - SendSchema(scene.Server, scene.Bad, CultNetSchemaMessageSerialization.Serialize( - new CultNetShardCatalogRequestMessage { MessageId = "behind-the-cap" })); - await scene.Server.PollAvailableAsync(16); - await scene.Server.PollAvailableAsync(16); - - Assert.That(scene.Served.Select(s => s.MessageId), Does.Not.Contain("behind-the-cap")); - } - - // The numbers are the shared cross-runtime cap, spelled out so changing the constant fails here. - [Test] - public async Task EightConsecutiveUndecodableFramesEndTheSessionAfterTheEighthIsAnswered() - { - Assert.That(RudpCultNetSchemaServer.MaxConsecutiveRefusedFrames, Is.EqualTo(8)); - using var scene = await BadThenGood(Malformed()); - await scene.Server.PollAvailableAsync(16); - ErrorsReceivedBy(scene.Bad); - async Task Refuse(int count) - { - for (var i = 0; i < count; i++) - { - SendSchema(scene.Server, scene.Bad, Malformed()); - await scene.Server.PollAvailableAsync(16); - } - } - - await Refuse(6); // with the first, seven in a row - Assert.That(ErrorsReceivedBy(scene.Bad), Has.Count.EqualTo(6)); - Assert.That(scene.Ended, Is.Empty); - SendSchema(scene.Server, scene.Bad, CultNetSchemaMessageSerialization.Serialize( - new CultNetShardCatalogRequestMessage { MessageId = "resets-the-run" })); - await scene.Server.PollAvailableAsync(16); - Assert.That(scene.Served.Select(s => s.MessageId), Does.Contain("resets-the-run")); - await Refuse(7); - Assert.That(ErrorsReceivedBy(scene.Bad), Has.Count.EqualTo(7)); - Assert.That(scene.Ended, Is.Empty, "a decoded frame reset the run"); - - await Refuse(1); - Assert.That(ErrorsReceivedBy(scene.Bad).Select(e => e.Code), Is.EqualTo(new[] { "message_invalid" }), "the eighth is answered too"); - Assert.That(scene.Ended, Is.Empty, "the session ends once the answer is acknowledged"); - await Acknowledge(scene); - Assert.That(scene.Ended, Is.EqualTo(new[] { scene.Bad.EndPoint })); - Assert.That(scene.Server.Peers.Select(peer => peer.RemoteEndPoint), Is.EqualTo(new[] { scene.Good.EndPoint })); - } - // The peer's malformed message and a well-formed one after it arrive in one datagram's - // delivery: the second is held for order, and both are released together. The session is - // kept through the first, so the second is handled. + // delivery: the second is held for order, and both are released together. The session ends + // on the first, so the second belongs to no session and is never handled. [Test] - public async Task AFrameQueuedBehindAnUndecodableOneIsStillHandled() + public async Task ARefusedPeersFramesStillQueuedAreNotHandled() { var logger = new CapturingLogger(); using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions @@ -341,8 +205,8 @@ namespace GameCult.Networking.Tests await server.PollAvailableAsync(16); - Assert.That(served, Is.EqualTo(new[] { "after-the-refused-one" })); - Assert.That(server.Peers, Has.Count.EqualTo(1)); + Assert.That(served, Is.Empty); + Assert.That(server.Peers, Is.Empty); Assert.That(logger.Errors, Has.Count.EqualTo(1)); bad.Socket.Dispose(); } diff --git a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs index 4172c83b..8b989bb6 100644 --- a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs @@ -220,32 +220,6 @@ namespace GameCult.Networking.Tests Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92177726974655F6E6F745F617574686F72697A65643A206E6F74206772616E746564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A8736F6D653A6B6579A461734F66C0A763757272656E74C0")); } - [Test] - public void ErrorMessage_UnsupportedSchemaVersionRealBytes_MatchesItsSchema() - { - var message = CultNetErrorMessage.ForUnsupportedSchemaVersion("cultnet.hello.v1"); - - Assert.That(message.Code, Is.EqualTo("unsupported_schema_version")); - Assert.That(message.Details!.Field, Is.EqualTo("schemaVersion")); - Assert.That(message.Details.Value, Is.EqualTo("cultnet.hello.v1")); - AssertErrorMatchesSchema(message); - // The bytes cultnet-rs's error_message.rs unsupported_schema_version case decodes and re-encodes. - Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92C756E737570706F727465645F736368656D615F76657273696F6E3A2063756C746E65742E68656C6C6F2E7631AB726F7574696E6748696E74C0A4636F6465BA756E737570706F727465645F736368656D615F76657273696F6EA764657461696C7384A56669656C64AD736368656D6156657273696F6EA576616C7565B063756C746E65742E68656C6C6F2E7631A461734F66C0A763757272656E74C0")); - } - - [Test] - public void ErrorMessage_MessageInvalidRealBytes_MatchesItsSchema() - { - var message = CultNetErrorMessage.ForMessageInvalid("document", "Raw put messages must contain a document map."); - - Assert.That(message.Code, Is.EqualTo("message_invalid")); - Assert.That(message.Details!.Field, Is.EqualTo("document")); - Assert.That(message.Details.Value, Is.Null); - AssertErrorMatchesSchema(message); - // The bytes cultnet-rs's error_message.rs message_invalid case decodes and re-encodes. - Assert.That(Convert.ToHexString(CultNetSchemaMessageSerialization.Serialize(message)), Is.EqualTo("85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D93E6D6573736167655F696E76616C69643A2052617720707574206D65737361676573206D75737420636F6E7461696E206120646F63756D656E74206D61702EAB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A8646F63756D656E74A576616C7565C0A461734F66C0A763757272656E74C0")); - } - private static void AssertErrorMatchesSchema(CultNetErrorMessage message) { var validator = new MiniJsonSchemaValidator(SchemaDir()); diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 2a75b5c2..5a1f570f 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -5213,52 +5213,6 @@ namespace GameCult.Networking.Tests Assert.That(sent.ShardEpoch, Is.EqualTo(3)); } - [TestCase("write_not_authorized", 0)] - [TestCase("message_invalid", 0)] - [TestCase("write_not_authorized", 150)] - public void CultNetSchemaWriteForwarder_RejectsWithTheTypedRefusal_WhateverItsOrderAgainstTheSend(string code, int delayMs) - { - var fake = new CapturingSchemaClient(_ => null); - var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions - { - CreateClient = () => fake, - ResultWindow = TimeSpan.FromSeconds(2) - }); - var shard = new CultNetShardDescriptor( - "notes", "primary", 3, isPrimary: false, - primaryEndpoints: ["cultnet://primary.example.test:4075"]); - fake.OnSend = () => - { - var refusal = new CultNetErrorMessage { Error = "refused", Code = code }; - if (delayMs == 0) fake.Deliver(refusal); - else Task.Delay(delayMs).ContinueWith(_ => fake.Deliver(refusal)); - }; - - var put = Assert.ThrowsAsync(() => forwarder.ForwardPutAsync(shard, new CultNetDocumentPutRawMessage())); - Assert.That(put!.Code, Is.EqualTo(code)); - var delete = Assert.ThrowsAsync(() => forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" })); - Assert.That(delete!.Code, Is.EqualTo(code)); - } - - [Test] - public async Task CultNetSchemaWriteForwarder_DoesNotCompleteOnTheSendReturning() - { - var fake = new CapturingSchemaClient(_ => null); - var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions - { - CreateClient = () => fake, - ResultWindow = TimeSpan.FromMilliseconds(400) - }); - var shard = new CultNetShardDescriptor( - "notes", "primary", 3, isPrimary: false, - primaryEndpoints: ["cultnet://primary.example.test:4075"]); - var started = DateTime.UtcNow; - - await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" }); - - Assert.That(DateTime.UtcNow - started, Is.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380))); - } - [Test] public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets() { @@ -7504,14 +7458,6 @@ namespace GameCult.Networking.Tests _respond = respond; } - public Action? OnSend { get; set; } - - public void Deliver(ICultNetSchemaMessage message) - { - if (!_handlers.TryGetValue(message.GetType(), out var handlers)) return; - foreach (var handler in handlers.ToArray()) handler.DynamicInvoke(message); - } - public bool Connected { get; private set; } public string? ConnectedHost { get; private set; } public int ConnectedPort { get; private set; } @@ -7528,7 +7474,6 @@ namespace GameCult.Networking.Tests where T : ICultNetSchemaMessage { SentMessages.Add(message); - OnSend?.Invoke(); var response = _respond(message); if (response == null) { diff --git a/tests/vectors/rudp-multipeer-server/README.md b/tests/vectors/rudp-multipeer-server/README.md deleted file mode 100644 index 21231461..00000000 --- a/tests/vectors/rudp-multipeer-server/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# rudp-multipeer-server vectors - -Written only by the C# reference, `tests/GameCult.Networking.Tests/CultNetRudpMultiPeerVectorTests.cs`, with -`CULTNET_WRITE_VECTORS=1`. Every other run replays the scenario and compares it byte for byte; a changed vector is a reviewed -commit. Other runtimes' multi-peer listeners replay the same transcript and are held to the same answers -(`packages/cultnet-py/tests/test_rudp_multipeer_server.py`). - -`transcript.txt` is one listener (`connection_id`, `initial_sequence`, `max_fragment_bytes` and `accept_payload` in its first line) -serving two loopback peers, X and Y. Each line is an action or what it caused: `in ` is a datagram the peer sends the -listener, `send ` is a payload the application sends that peer, and the `out ` lines after it are -every datagram the listener put on the wire in answer, X's before Y's. Peers are named by label because ports are ephemeral. The -scenario covers a Connect, a repeat of an accepted Connect, data and acks, a fragmented send, an unreliable `latest` send, one peer's -Disconnect and the datagram from it that follows, and the other peer's reconnect from the same endpoint. diff --git a/tests/vectors/rudp-multipeer-server/transcript.txt b/tests/vectors/rudp-multipeer-server/transcript.txt deleted file mode 100644 index 50f2f64f..00000000 --- a/tests/vectors/rudp-multipeer-server/transcript.txt +++ /dev/null @@ -1,30 +0,0 @@ -# config connection_id=0x43554c54 initial_sequence=500 max_fragment_bytes=8 accept_payload=616363 -# in | send | out ; peer labels name remote endpoints -in X 434e52300001032b43554c540000000a0000000000000000000000000000000000020700636f6e74726f6c6878 -out X 434e52300002032b43554c54000001f40000000a00000000000000000000000000030700636f6e74726f6c616363 -in Y 434e52300001032b43554c54000000140000000000000000000000000000000000020700636f6e74726f6c6879 -out Y 434e52300002032b43554c54000001f40000001400000000000000000000000000030700636f6e74726f6c616363 -in X 434e52300001032b43554c540000000a0000000000000000000000000000000000020700636f6e74726f6c6878 -out X 434e52300002032b43554c54000001f40000000a00000000000000000000000000030700636f6e74726f6c616363 -in X 434e52300003032a43554c540000000b000001f400000000000000000000000000060600736368656d6170696e672d78 -out X 434e52300004002b43554c54000000000000000b00000001000000000000000000000700636f6e74726f6c -in Y 434e52300003032a43554c5400000015000001f400000000000000000000000000060600736368656d6170696e672d79 -out Y 434e52300004002b43554c54000000000000001500000001000000000000000000000700636f6e74726f6c -send X schema 706f6e67 -out X 434e52300003032a43554c54000001f50000000b00000001000000000000000000040600736368656d61706f6e67 -send Y schema 303132333435363738396162636465666768696a6b6c6d6e -out Y 434e523000030b2a43554c54000001f50000001500000001000100000003000000080600736368656d613031323334353637 -out Y 434e523000030b2a43554c54000001f60000001500000001000100010003000000080600736368656d613839616263646566 -out Y 434e523000030b2a43554c54000001f70000001500000001000100020003000000080600736368656d616768696a6b6c6d6e -send X latest 73616d706c65 -out X 434e52300003042a43554c54000000010000000b000000010000000000000000000606006c617465737473616d706c65 -in X 434e52300007002b43554c5400000000000001f500000001000000000000000000030700636f6e74726f6c627965 -in X 434e52300003032a43554c540000000c000001f500000001000000000000000000090600736368656d6161667465722d627965 -send Y schema 7374696c6c2d68657265 -out Y 434e523000030b2a43554c54000001f80000001500000001000200000002000000080600736368656d617374696c6c2d6865 -out Y 434e523000030b2a43554c54000001f90000001500000001000200010002000000020600736368656d617265 -in Y 434e52300001032b43554c540000001e0000000000000000000000000000000000030700636f6e74726f6c687932 -out Y 434e52300002032b43554c54000001f40000001e00000000000000000000000000030700636f6e74726f6c616363 -send Y schema 6e65772d67656e -out Y 434e52300003032a43554c54000001f50000001e00000000000000000000000000070600736368656d616e65772d67656e -# peers 1 dropped 1