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/contracts/cultnet/cultnet-distributed-database.md b/contracts/cultnet/cultnet-distributed-database.md index 932ba861..cc6a6a57 100644 --- a/contracts/cultnet/cultnet-distributed-database.md +++ b/contracts/cultnet/cultnet-distributed-database.md @@ -114,10 +114,7 @@ copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and `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 -`limit_reached` (a receipt-capable server's per-session remembered-write table is full, so a new write is refused; `details` all null) 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. +`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value). ## Live Invariants diff --git a/contracts/cultnet/cultnet.error.schema.json b/contracts/cultnet/cultnet.error.schema.json index 07befacc..341de5af 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", "unsupported_schema_version"] }, "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,7 +36,7 @@ "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"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] }, "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] }, "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/cultnet.error.v1.schema.json b/contracts/cultnet/interop/cultnet.error.v1.schema.json deleted file mode 100644 index 60f24a56..00000000 --- a/contracts/cultnet/interop/cultnet.error.v1.schema.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.error.v1.schema.json", - "title": "CultNet Error Message v1", - "description": "cultnet.error.v0 plus messageId (write-receipt.md). Sent in place of cultnet.error.v0 only to a connection that declared the write-receipt capability. v0 is unchanged: its schema is closed (additionalProperties false), so an extra key would make a v0 peer reject the frame instead of ignoring it. Map-mode: every key is written, messageId nil when the error answers no readable message. The code enum is the v0 enum plus limit_reached (write-receipt.md, Remembered-write limits).", - "type": "object", - "required": [ - "schemaVersion", - "error", - "routingHint", - "code", - "details", - "messageId" - ], - "additionalProperties": false, - "properties": { - "schemaVersion": { - "const": "cultnet.error.v1" - }, - "error": { - "type": "string", - "minLength": 1 - }, - "routingHint": { - "description": "Optional shard routing detail for an authority failure. Null for every selection/cursor refusal code (R-N).", - "type": [ - "object", - "null" - ], - "additionalProperties": false, - "required": [ - "shard", - "reason" - ], - "properties": { - "shard": { - "type": [ - "object", - "null" - ] - }, - "reason": { - "type": "string" - } - } - }, - "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). limit_reached: a receipt-capable server refuses a NEW write because its per-session remembered-write table (1024 entries) or its server-wide memory budget is full; messageId is the write's id.", - "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", - "limit_reached" - ] - }, - "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.", - "type": [ - "object", - "null" - ], - "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). limit_reached: no field; details stay all null. message_invalid also names \"messageId\" (over 256 bytes), \"document\", \"schemaId\" or \"channel\" (write on an unreliable channel).", - "type": [ - "string", - "null" - ] - }, - "value": { - "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", - "type": [ - "string", - "null" - ] - }, - "asOf": { - "description": "cursor_stale: the cursor's own minted asOf.", - "type": [ - "integer", - "null" - ], - "minimum": 0 - }, - "current": { - "description": "cursor_stale: the answering server's current asOf.", - "type": [ - "integer", - "null" - ], - "minimum": 0 - } - } - }, - "messageId": { - "description": "The messageId of the write (or other request) this error answers, echoed byte for byte. Null only when the frame was unreadable or carried no messageId.", - "type": [ - "string", - "null" - ], - "minLength": 1 - } - } -} diff --git a/contracts/cultnet/interop/cultnet.write-admitted.schema.json b/contracts/cultnet/interop/cultnet.write-admitted.schema.json deleted file mode 100644 index 42e86a2e..00000000 --- a/contracts/cultnet/interop/cultnet.write-admitted.schema.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/GameCult/CultLib/contracts/cultnet/interop/cultnet.write-admitted.schema.json", - "title": "CultNet Write Admitted", - "description": "The receipt for one admitted document put or delete (write-receipt.md). Sent only after the write's commit point, only to a connection that declared the write-receipt capability, once per messageId. Map-mode: every key is written, none is nil. A receipt never carries an error, and an error.v1 never carries admission.", - "type": "object", - "required": ["schemaVersion", "messageId", "operation", "schemaId", "recordKey"], - "additionalProperties": false, - "properties": { - "schemaVersion": { "const": "cultnet.write_admitted.v0" }, - "messageId": { "description": "The messageId of the put or delete this admits, echoed byte for byte.", "type": "string", "minLength": 1 }, - "operation": { "enum": ["put", "delete"] }, - "schemaId": { "description": "The schemaId of the admitted write, echoed.", "type": "string", "minLength": 1 }, - "recordKey": { "description": "The recordKey of the admitted write, echoed.", "type": "string", "minLength": 1 } - } -} diff --git a/contracts/cultnet/interop/error-vectors.json b/contracts/cultnet/interop/error-vectors.json index 50ebcbb0..db5e79cd 100644 --- a/contracts/cultnet/interop/error-vectors.json +++ b/contracts/cultnet/interop/error-vectors.json @@ -145,22 +145,6 @@ "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/contracts/cultnet/interop/write-receipt-vectors.json b/contracts/cultnet/interop/write-receipt-vectors.json deleted file mode 100644 index c0313569..00000000 --- a/contracts/cultnet/interop/write-receipt-vectors.json +++ /dev/null @@ -1,1060 +0,0 @@ -{ - "provenance": "Spec-authored with msgpack-python 1.2.4 (str keys in schema order, use_bin_type). Not yet a C# capture: the first conformance PR for each runtime re-captures these with its own encoder and the bytes must match, as error-vectors.json does. Every schema case is checked against cultnet.write-admitted.schema.json / cultnet.error.v1.schema.json in this directory.", - "specification": "write-receipt.md", - "wire": [ - { - "name": "write_admitted_put", - "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31", - "message": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "put-1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - }, - { - "name": "write_admitted_delete", - "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A564656C2D31A96F7065726174696F6EA664656C657465A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31", - "message": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "del-1", - "operation": "delete", - "schemaId": "note.v0", - "recordKey": "note:1" - } - }, - { - "name": "error_v1_write_not_authorized_echoes_id", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32", - "message": { - "schemaVersion": "cultnet.error.v1", - "error": "write_not_authorized: the catalog refused the document", - "routingHint": null, - "code": "write_not_authorized", - "details": { - "field": "recordKey", - "value": "note:2", - "asOf": null, - "current": null - }, - "messageId": "put-2" - } - }, - { - "name": "error_v1_message_invalid_names_member_of_a_batch", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9256D6573736167655F696E76616C69643A207265636F72644B6579206973206D697373696E67AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964B062617463682D312D6D656D6265722D33", - "message": { - "schemaVersion": "cultnet.error.v1", - "error": "message_invalid: recordKey is missing", - "routingHint": null, - "code": "message_invalid", - "details": { - "field": "recordKey", - "value": null, - "asOf": null, - "current": null - }, - "messageId": "batch-1-member-3" - } - }, - { - "name": "error_v1_unreadable_frame_has_null_message_id", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9216D6573736167655F696E76616C69643A20756E7265616461626C65206672616D65AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964C0", - "message": { - "schemaVersion": "cultnet.error.v1", - "error": "message_invalid: unreadable frame", - "routingHint": null, - "code": "message_invalid", - "details": { - "field": "message", - "value": null, - "asOf": null, - "current": null - }, - "messageId": null - } - }, - { - "name": "error_v1_empty_schema_id_is_unreadable_and_names_the_write", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D9216D6573736167655F696E76616C69643A20756E7265616461626C65206672616D65AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D31", - "message": { - "schemaVersion": "cultnet.error.v1", - "error": "message_invalid: unreadable frame", - "routingHint": null, - "code": "message_invalid", - "details": { - "field": "message", - "value": null, - "asOf": null, - "current": null - }, - "messageId": "put-1" - } - }, - { - "name": "error_v1_limit_reached", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235", - "message": { - "schemaVersion": "cultnet.error.v1", - "error": "limit_reached: the session's remembered-write limit is full", - "routingHint": null, - "code": "limit_reached", - "details": { - "field": null, - "value": null, - "asOf": null, - "current": null - }, - "messageId": "put-1025" - } - } - ], - "schemaInvalid": [ - { - "name": "write_admitted_with_an_error_key", - "messagePackHex": "86AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31A56572726F72A46E6F7065", - "message": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "put-1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1", - "error": "nope" - }, - "why": "admission must not carry an error" - }, - { - "name": "write_admitted_empty_message_id", - "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A0A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31", - "message": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - }, - "why": "messageId is non-empty" - }, - { - "name": "write_admitted_unknown_operation", - "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA6757073657274A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31", - "message": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "put-1", - "operation": "upsert", - "schemaId": "note.v0", - "recordKey": "note:1" - }, - "why": "operation is put or delete" - }, - { - "name": "write_admitted_null_record_key", - "messagePackHex": "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579C0", - "message": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "put-1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": null - }, - "why": "map-mode keys are never nil" - }, - { - "name": "error_v1_without_message_id", - "messagePackHex": "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72AF6D6573736167655F696E76616C6964AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0", - "message": { - "schemaVersion": "cultnet.error.v1", - "error": "message_invalid", - "routingHint": null, - "code": "message_invalid", - "details": { - "field": "message", - "value": null, - "asOf": null, - "current": null - } - }, - "why": "messageId is required (null when unknown), never omitted" - }, - { - "name": "error_v1_empty_message_id", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72AF6D6573736167655F696E76616C6964AB726F7574696E6748696E74C0A4636F6465AF6D6573736167655F696E76616C6964A764657461696C7384A56669656C64A76D657373616765A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A0", - "message": { - "schemaVersion": "cultnet.error.v1", - "error": "message_invalid", - "routingHint": null, - "code": "message_invalid", - "details": { - "field": "message", - "value": null, - "asOf": null, - "current": null - }, - "messageId": "" - }, - "why": "null, not empty string, means unknown" - }, - { - "name": "error_v0_is_not_error_v1", - "messagePackHex": "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72B477726974655F6E6F745F617574686F72697A6564AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A16BA461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32", - "message": { - "schemaVersion": "cultnet.error.v0", - "error": "write_not_authorized", - "routingHint": null, - "code": "write_not_authorized", - "details": { - "field": "recordKey", - "value": "k", - "asOf": null, - "current": null - }, - "messageId": "put-2" - }, - "why": "v0 stays closed: a messageId key makes it invalid, which is why v1 exists" - } - ], - "scenarios": { - "semantics": "Each scenario replays events in atMs order against a client that sent each write at its send event; a tick advances the clock without a frame. 'window' names the constant in vectors.constants that applies (server receipt -> RECEIPT_RESULT_WINDOW_MS, legacy -> LEGACY_RESULT_WINDOW_MS); a tick at or after the window settles unanswered writes as unconfirmed. expect gives the outcome the caller was told per write id; recorded, when present, is the write handle's later state. admitted/refused/unconfirmed are three distinct results; no scenario may settle admitted without a write_admitted frame carrying the write's id.", - "cases": [ - { - "name": "receipt_admits", - "description": "A receipt with the id completes the write as admitted.", - "server": "receipt", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 5, - "transportAck": "w1" - }, - { - "atMs": 20, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "w1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - } - ], - "expect": { - "w1": { - "outcome": "admitted" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "receipt_before_transport_ack", - "description": "Order against the transport ack is irrelevant.", - "server": "receipt", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 5, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "w1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - }, - { - "atMs": 9, - "transportAck": "w1" - } - ], - "expect": { - "w1": { - "outcome": "admitted" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "refusal_after_ack", - "description": "A typed refusal after the transport ack refuses; the ack was not admission.", - "server": "receipt", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 5, - "transportAck": "w1" - }, - { - "atMs": 120, - "frame": { - "schemaVersion": "cultnet.error.v1", - "error": "write_not_authorized", - "routingHint": null, - "code": "write_not_authorized", - "details": null, - "messageId": "w1" - } - } - ], - "expect": { - "w1": { - "outcome": "refused", - "code": "write_not_authorized" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "refusal_before_ack", - "description": "A typed refusal before the transport ack refuses.", - "server": "receipt", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 5, - "frame": { - "schemaVersion": "cultnet.error.v1", - "error": "message_invalid", - "routingHint": null, - "code": "message_invalid", - "details": null, - "messageId": "w1" - } - }, - { - "atMs": 9, - "transportAck": "w1" - } - ], - "expect": { - "w1": { - "outcome": "refused", - "code": "message_invalid" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "ack_only_window_ends_unconfirmed", - "description": "The legacy fallback: an acked write that nothing answered ends unconfirmed, never admitted.", - "server": "legacy", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 5, - "transportAck": "w1" - }, - { - "atMs": 500, - "tick": true - } - ], - "expect": { - "w1": { - "outcome": "unconfirmed", - "reason": "window_elapsed" - } - }, - "window": "LEGACY_RESULT_WINDOW_MS" - }, - { - "name": "window_runs_from_send_not_from_ack", - "description": "A late transport ack does not extend the window.", - "server": "legacy", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 450, - "transportAck": "w1" - }, - { - "atMs": 500, - "tick": true - } - ], - "expect": { - "w1": { - "outcome": "unconfirmed", - "reason": "window_elapsed" - } - }, - "window": "LEGACY_RESULT_WINDOW_MS" - }, - { - "name": "silent_receipt_capable_server_is_unconfirmed_too", - "description": "A server that advertised receipts and stayed silent is still only unconfirmed, and the client reports the fault.", - "server": "receipt", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 5, - "transportAck": "w1" - }, - { - "atMs": 5000, - "tick": true - } - ], - "expect": { - "w1": { - "outcome": "unconfirmed", - "reason": "window_elapsed" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "legacy_error_with_one_write_in_flight_refuses", - "description": "A pre-receipt server answers with error.v0 and no id: attributable only because one write is unanswered.", - "server": "legacy", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 30, - "frame": { - "schemaVersion": "cultnet.error.v0", - "error": "write_not_authorized", - "routingHint": null, - "code": "write_not_authorized", - "details": null - } - } - ], - "expect": { - "w1": { - "outcome": "refused", - "code": "write_not_authorized" - } - }, - "window": "LEGACY_RESULT_WINDOW_MS" - }, - { - "name": "legacy_error_with_two_writes_in_flight_is_unattributed", - "description": "An id-less error with two unanswered writes names neither: both stay pending and end unconfirmed.", - "server": "legacy", - "writes": [ - "w1", - "w2" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 1, - "send": "w2" - }, - { - "atMs": 30, - "frame": { - "schemaVersion": "cultnet.error.v0", - "error": "write_not_authorized", - "routingHint": null, - "code": "write_not_authorized", - "details": null - } - }, - { - "atMs": 501, - "tick": true - } - ], - "expect": { - "w1": { - "outcome": "unconfirmed", - "reason": "window_elapsed" - }, - "w2": { - "outcome": "unconfirmed", - "reason": "window_elapsed" - } - }, - "window": "LEGACY_RESULT_WINDOW_MS" - }, - { - "name": "batch_names_the_refused_member", - "description": "A batch is independent writes. The refusal names its member; the others keep their own outcomes.", - "server": "receipt", - "writes": [ - "b1", - "b2", - "b3" - ], - "events": [ - { - "atMs": 0, - "send": "b1" - }, - { - "atMs": 0, - "send": "b2" - }, - { - "atMs": 0, - "send": "b3" - }, - { - "atMs": 10, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "b1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - }, - { - "atMs": 11, - "frame": { - "schemaVersion": "cultnet.error.v1", - "error": "write_not_authorized", - "routingHint": null, - "code": "write_not_authorized", - "details": null, - "messageId": "b2" - } - }, - { - "atMs": 12, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "b3", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - } - ], - "expect": { - "b1": { - "outcome": "admitted" - }, - "b2": { - "outcome": "refused", - "code": "write_not_authorized" - }, - "b3": { - "outcome": "admitted" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "batch_with_one_member_unanswered", - "description": "A member nothing answered is unconfirmed; the batch as a whole is admitted only if every member is.", - "server": "receipt", - "writes": [ - "b1", - "b2" - ], - "events": [ - { - "atMs": 0, - "send": "b1" - }, - { - "atMs": 0, - "send": "b2" - }, - { - "atMs": 10, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "b1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - }, - { - "atMs": 5000, - "tick": true - } - ], - "expect": { - "b1": { - "outcome": "admitted" - }, - "b2": { - "outcome": "unconfirmed", - "reason": "window_elapsed" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "session_ends_before_an_answer", - "description": "A lost session leaves unanswered writes unconfirmed, not failed and not admitted.", - "server": "receipt", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 5, - "transportAck": "w1" - }, - { - "atMs": 40, - "sessionEnd": true - } - ], - "expect": { - "w1": { - "outcome": "unconfirmed", - "reason": "session_ended" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "late_refusal_is_recorded_not_told", - "description": "An answer after the window does not change what the caller was told; the handle records refused_late.", - "server": "legacy", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 500, - "tick": true - }, - { - "atMs": 700, - "frame": { - "schemaVersion": "cultnet.error.v1", - "error": "write_not_authorized", - "routingHint": null, - "code": "write_not_authorized", - "details": null, - "messageId": "w1" - } - } - ], - "expect": { - "w1": { - "outcome": "unconfirmed", - "reason": "window_elapsed", - "recorded": "refused_late" - } - }, - "window": "LEGACY_RESULT_WINDOW_MS" - }, - { - "name": "duplicate_resend_of_an_admitted_write_is_admitted_again", - "description": "The server answers a re-sent admitted messageId with the receipt again, never an error.", - "server": "receipt", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 20, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "w1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - }, - { - "atMs": 30, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "w1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - } - ], - "expect": { - "w1": { - "outcome": "admitted" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "an_answer_for_an_unknown_id_is_ignored", - "description": "A frame naming no write in flight settles nothing.", - "server": "receipt", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 10, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "other", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - }, - { - "atMs": 5000, - "tick": true - } - ], - "expect": { - "w1": { - "outcome": "unconfirmed", - "reason": "window_elapsed" - } - }, - "window": "RECEIPT_RESULT_WINDOW_MS" - }, - { - "name": "late_receipt_is_recorded_admitted_late", - "description": "A receipt after the window: the caller was told unconfirmed, the handle records admitted_late; a caller must consult it before resending.", - "server": "receipt", - "window": "RECEIPT_RESULT_WINDOW_MS", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 5000, - "tick": true - }, - { - "atMs": 6200, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "w1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - } - ], - "expect": { - "w1": { - "outcome": "unconfirmed", - "reason": "window_elapsed", - "recorded": "admitted_late" - } - } - }, - { - "name": "receipt_inside_the_receipt_window_is_not_late", - "description": "The receipt-capable window is 5 s: a receipt at 1.2 s admits.", - "server": "receipt", - "window": "RECEIPT_RESULT_WINDOW_MS", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 1200, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "w1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - } - ], - "expect": { - "w1": { - "outcome": "admitted" - } - } - }, - { - "name": "legacy_window_is_not_used_against_a_receipt_server", - "description": "At 500 ms a receipt-capable server's write is still pending, not unconfirmed.", - "server": "receipt", - "window": "RECEIPT_RESULT_WINDOW_MS", - "writes": [ - "w1" - ], - "events": [ - { - "atMs": 0, - "send": "w1" - }, - { - "atMs": 500, - "tick": true - }, - { - "atMs": 800, - "frame": { - "schemaVersion": "cultnet.write_admitted.v0", - "messageId": "w1", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1" - } - } - ], - "expect": { - "w1": { - "outcome": "admitted" - } - } - } - ] - }, - "constants": { - "RECEIPT_RESULT_WINDOW_MS": 5000, - "LEGACY_RESULT_WINDOW_MS": 500, - "DEDUPE_RETENTION_MS": 600000 - }, - "contentHash": { - "definition": "sha256(msgpack([operation, schemaId, recordKey, payload])), payload a msgpack bin for a put and nil for a delete; hex lowercase", - "cases": [ - { - "name": "put_note_1_payload_a", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1", - "payloadHex": "81A26964A131", - "sha256": "7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d" - }, - { - "name": "put_note_1_payload_b", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:1", - "payloadHex": "81A26964A132", - "sha256": "58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f" - }, - { - "name": "put_note_2_payload_a", - "operation": "put", - "schemaId": "note.v0", - "recordKey": "note:2", - "payloadHex": "81A26964A131", - "sha256": "a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8" - }, - { - "name": "delete_note_1", - "operation": "delete", - "schemaId": "note.v0", - "recordKey": "note:1", - "payloadHex": null, - "sha256": "232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37" - } - ] - }, - "dedupe": { - "semantics": "Replay writes in order against a receipt-capable server whose actors are transport-authenticated. expect lists the answer per write in order and how many times a write was applied to the store in total. 'invalid' means error.v1 message_invalid with details.field messageId and the write's id.", - "cases": [ - { - "name": "resend_same_content_re_admits_without_reapplying", - "writes": [ - { - "actor": "alice", - "messageId": "m1", - "content": "put_note_1_payload_a" - }, - { - "actor": "alice", - "messageId": "m1", - "content": "put_note_1_payload_a" - } - ], - "answers": [ - "admitted", - "admitted" - ], - "applied": 1 - }, - { - "name": "same_id_different_payload_is_refused_not_re_receipted", - "writes": [ - { - "actor": "alice", - "messageId": "m1", - "content": "put_note_1_payload_a" - }, - { - "actor": "alice", - "messageId": "m1", - "content": "put_note_1_payload_b" - } - ], - "answers": [ - "admitted", - "refused:message_invalid" - ], - "applied": 1 - }, - { - "name": "same_id_different_record_key_is_refused", - "writes": [ - { - "actor": "alice", - "messageId": "m1", - "content": "put_note_1_payload_a" - }, - { - "actor": "alice", - "messageId": "m1", - "content": "put_note_2_payload_a" - } - ], - "answers": [ - "admitted", - "refused:message_invalid" - ], - "applied": 1 - }, - { - "name": "same_id_different_operation_is_refused", - "writes": [ - { - "actor": "alice", - "messageId": "m1", - "content": "put_note_1_payload_a" - }, - { - "actor": "alice", - "messageId": "m1", - "content": "delete_note_1" - } - ], - "answers": [ - "admitted", - "refused:message_invalid" - ], - "applied": 1 - }, - { - "name": "the_same_id_from_another_actor_is_an_independent_write", - "writes": [ - { - "actor": "alice", - "messageId": "m1", - "content": "put_note_1_payload_a" - }, - { - "actor": "bob", - "messageId": "m1", - "content": "put_note_1_payload_b" - } - ], - "answers": [ - "admitted", - "admitted" - ], - "applied": 2 - }, - { - "name": "resend_of_a_refused_write_is_refused_the_same_way", - "writes": [ - { - "actor": "mallory", - "messageId": "m1", - "content": "put_note_1_payload_a" - }, - { - "actor": "mallory", - "messageId": "m1", - "content": "put_note_1_payload_a" - } - ], - "answers": [ - "refused:write_not_authorized", - "refused:write_not_authorized" - ], - "applied": 0, - "note": "mallory is not authorized to write note.v0" - }, - { - "name": "the_capability_does_not_authorize", - "writes": [ - { - "actor": "mallory", - "messageId": "m2", - "content": "put_note_2_payload_a" - } - ], - "helloDeclaresReceipts": true, - "answers": [ - "refused:write_not_authorized" - ], - "applied": 0, - "note": "declaring the receipt capability changes the frame the refusal arrives in, never whether it is a refusal" - } - ] - } -} diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md deleted file mode 100644 index 8581cc53..00000000 --- a/contracts/cultnet/interop/write-receipt.md +++ /dev/null @@ -1,83 +0,0 @@ -# CultNet write receipt (specification) - -Status: specification. No runtime implements it yet; the conformance follow-ups below do. -Rules: Heimdall's ruling of 2026-11-08 (a write completes on its result frame; an ack or a return is not admission). -Files here: `cultnet.write-admitted.schema.json`, `cultnet.error.v1.schema.json`, `write-receipt-vectors.json`. They move to `contracts/cultnet/` when the first runtime implements them. - -## The problem - -A put or delete today has two answers on the wire: nothing (admitted, or the server never read it) and `cultnet.error.v0` (refused, with no id saying which write). A transport ack proves delivery. Clients that completed on the ack, or on a window with no refusal, reported refused writes as done (CultLib #55 to #58 bound this with a result window; this spec replaces the window with an answer). - -## Frames - -**`cultnet.write_admitted.v0`**: `{ schemaVersion, messageId, operation: "put"|"delete", schemaId, recordKey }`, every key written, none nil. Sent once a put or delete is admitted. The id echoes the write's `messageId` byte for byte; `operation`, `schemaId`, `recordKey` echo the write. - -**`cultnet.error.v1`**: `cultnet.error.v0` plus a required `messageId` (string or nil). The code enum is v0's plus `limit_reached` (see Remembered-write limits); `cultnet.error.v1.schema.json` is the authority for the closed set. Sent in place of `error.v0` to a receipt-capable connection. `messageId` is the id of the request the error answers; nil only when the frame was unreadable or carried no id. - -Why a new error version and not an extra key on v0: the v0 schema is closed (`additionalProperties: false`) and the TypeScript decoder (Ajv over the closed schemas) rejects unknown keys and unknown `schemaVersion`s, and `parseCultNetMessage` throws on an unknown version; Rust's decoder errors on an unknown version too. Unknown-key tolerance of the Rust, Python and Kotlin error decoders was not checked, so none is assumed, so a surprise key or frame breaks an old client's whole receive path. Old clients are therefore never sent either new frame. This reverses the "old clients ignore it" assumption, which is false for today's decoders. - -## Capability: opting in - -A connection is receipt-capable when its `cultnet.hello.v0` (sent before its first write; the schema channel is reliable and ordered) lists both `cultnet.write_admitted.v0` and `cultnet.error.v1` in `supportedMessageVersions`. A server MUST NOT send either new frame to any other connection, and answers it exactly as before (no receipt, `error.v0`). A receipt-capable server lists both in its own hello, so a client can tell a legacy server from a receipt server that has not answered yet. A client that sends no hello gets legacy behaviour. A server advertises the two frames in its hello only if it implements the receipt rules end to end. - -**The capability changes framing only; it never authorizes.** A hello authenticates nothing and grants nothing: who may write, and which keys, is decided by the server from the transport-authenticated actor exactly as without the capability. Declaring the capability never makes a refused write admitted, never widens an actor's authority, and never changes what a write does; it selects only which frame the answer arrives in. - -## Server rules - -1. **One answer.** For every put or delete on a receipt-capable connection the server sends exactly one of `write_admitted` or `error.v1`, carrying that write's `messageId`. Never both, never two different answers. -2. **After the commit point.** `write_admitted` is sent only after the write is committed by the same rule that makes it visible to readers. Receipt of the bytes, parse, or authorisation alone is not admission. -3. **Id on every refusal.** Every refusal of a write, whatever its code (`write_not_authorized`, `message_invalid`, `unowned_schema`, `unsupported_schema_version`, shard authority errors, ...), is an `error.v1` whose `messageId` is the write's id. When the frame is readable enough to find the id, it is found; `message_invalid` for a missing or ill-typed field still carries the id. Nil only when no id can be read. -4. **Batches.** There is no batch frame; a batch is N writes, each with its own `messageId`, each answered under rules 1 to 3. A failed batch names its member by that id. A server may not collapse a batch into one answer. -5. **Identity and idempotent resend.** A `messageId` is unique per authenticated actor and session: the client mints it, never reuses it for different content in that session, and a server never accepts the id of one actor as the id of another. The server's dedupe key is `(actor, messageId)`, where *actor* is the transport-authenticated identity, never a name the hello or the frame claims. Alongside the key it keeps the **content hash** of the write and the answer it gave. The content hash is SHA-256 of the MessagePack array `[operation, schemaId, recordKey, payload]`, where `payload` is the raw document payload as a MessagePack `bin` for a put and nil for a delete (the vectors give worked hashes). **Scope: at least the session.** The key lives for the life of the session it was made in, and a server may bound how many writes it remembers per session (see Remembered-write limits; a server never forgets an id it promised to answer). A server MAY keep the key longer for the same actor, up to `DEDUPE_RETENTION_MS` (10 minutes), but a client MUST NOT rely on it: **a reconnect is a new session, and a resend on the new session is a new write.** - - Same key, same content hash, answer was `write_admitted`: answer `write_admitted` again with the same body. The write is not applied twice. - - Same key, same content hash, answer was a refusal: the same refusal again. - - **Same key, different content hash: refuse `message_invalid` (details `field: "messageId"`) and never re-send the old receipt.** Old receipts are for the old content; showing one for new content would report an unapplied write as admitted. The original write's state is unchanged. - - **Id size.** A write's `messageId` is at most 256 bytes of UTF-8. A longer id is refused `message_invalid` (`details.field: "messageId"`, the refusal's `messageId` the id as received, text fixed per Fixed texts) BEFORE anything is remembered: no table entry, no digest, no budget charge. Such a refusal is therefore not replayed from the table; a resend is refused afresh, identically. - - **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests. - - **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table. - - **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session. - - **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A `schemaId` or `recordKey` that is empty or only whitespace (present but names nothing) is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"` and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. - - **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`. - - **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. - - Past the session (or a server's longer retention, if it keeps one) a resend is a new write. -6. **Forwarded writes.** A server that forwards a non-primary write answers the original peer only with what the primary answered: the primary's receipt becomes `write_admitted` with the original id, its refusal becomes `error.v1` with the original id. If the forward gets no answer the server relays nothing: no receipt, no error, no synthesized refusal. The client's own rule below then reports the write as unconfirmed. A forwarder never turns an absent or ambiguous primary answer into an admission. -7. **Looks alike.** An admitted and a refused write differ in `schemaVersion`; a receipt has no `error`, an `error.v1` has no admission. A transport ack is neither. - -## Client rules: three outcomes - -A write settles as exactly one of: - -- **admitted**: a `write_admitted` whose `messageId` is the write's. -- **refused**: an `error.v1` whose `messageId` is the write's, carrying its `code` and `details` as the typed error. -- **unconfirmed**: nothing settled it. Reasons: `window_elapsed` (the result window ran from the *send* and passed with no answer; see Result window), `session_ended` (the peer closed the session, or it ended, before the write was answered) and `local_send_failed` (the client's own send of this write failed). A local send failure is never reported as refused: no server answer exists, and the write may or may not have left the machine. Unconfirmed is not success and not refusal. APIs that return a value return the tri-state; APIs that throw throw a distinct refused error and a distinct unconfirmed error and return normally only for admitted. A caller that needs admission treats unconfirmed as not admitted. Resend with the same `messageId` and unchanged content, **on the same session**, is safe against a receipt-capable server (rule 5). After a reconnect the resend is a new write, and a legacy server may apply a resend twice: the caller resends only writes that are idempotent by record key (a put of the same content to the same key, a delete of a key), and reads the record back before resending any other. - -**Result window.** Two named constants (also in the vectors): `RECEIPT_RESULT_WINDOW_MS = 5000`, the default against a receipt-capable server (its hello lists both frames); `LEGACY_RESULT_WINDOW_MS = 500`, used only as the fallback against a server whose hello does not list them (or that sent none). The window runs from the send. A receipt-capable server that has not answered within its window is reported as unconfirmed like any other; clients log it as a server that missed its window. - -**Late answers.** An answer for a write that already settled `unconfirmed` does not change what the caller was told, but the client records it on the write: a late `write_admitted` makes the write's recorded state `admitted_late`, a late refusal makes it `refused_late`. The write handle exposes that state, and a caller that got `unconfirmed` MUST consult it (and the record) before resending: it must not blind-resend a write that was admitted late. A resend on the same session with the same id and content is harmless on a receipt-capable server (rule 5); across a reconnect the caller follows the idempotence rule above. - -**Legacy answers.** An `error.v0`, or an `error.v1` with nil `messageId`, names no write. It is attributed to a write only when exactly one write on the connection is unanswered; it then refuses that write. With two or more unanswered writes it settles none of them and each ends unconfirmed at its window. A frame naming a `messageId` that is not in flight settles nothing. - -**Batches can commit partially.** A batch is N independent writes with no atomicity: some members may be admitted while others are refused or unanswered, and nothing is rolled back. The client reports each member's outcome and a batch result is only their set: admitted if every member is admitted, refused if any member is refused (naming the members), otherwise unconfirmed (naming the unanswered members). The caller owns retry: it resends exactly the members that are not admitted, with their original ids. - -## Changes to what #55 to #58 shipped - -Those clients complete *successfully* when the window ends with no refusal. Under this spec that case is `unconfirmed`. The TypeScript one-shot writer (#62) already reports it so: it resolves `admitted` only on a `write_admitted` carrying the put's id, and no other frame, a put echoed under the same id included, ever counts as admission. A caller whose server cannot answer a write yet (Odin, until it is receipt-capable) opts into the explicit send-only mode per call (`sendOnly: true`), which resolves the distinct outcome `sent` once the transport has acknowledged the put and never `admitted`. The Idunn presence publisher is send-only by its own function (`sendPresenceDocument`, not the `sendOnly` option): the same `sent` result, plus a rejection if a refusal naming the put (or naming nothing) arrives by the transport ack. Receipts and send-only are both opt-in, neither is the default. The C# and Rust clients of #55 to #58 keep the window-success until their conformance work lands, and callers of those must not read it as admission. - -## Vectors - -`write-receipt-vectors.json`: `constants` (the named windows and retention), `contentHash` (worked dedupe hashes), `dedupe` (server replay cases for rule 5), `wire` (MessagePack hex plus the decoded message for each new frame; the bytes are spec-authored with msgpack-python and every runtime's first conformance PR re-captures them with its own encoder and must match), `schemaInvalid` (frames that must be refused by a validator, each with why), and `scenarios` (event replays with the outcome each write must settle as; the clock is abstract; each case names its window constant). A conformant client passes every scenario; a conformant server's answers for the same inputs satisfy server rules 1 to 7. - -## Conformance status - -| Server class | Advertises both frames | Server-wide memory budget (MAY, Remembered-write limits) | -|---|---|---| -| `CultNetDatabaseServer` | yes | not implemented: only the per-session limit of 1024 applies | -| Shard write forwarder | yes | not implemented: only the per-session limit of 1024 applies | -| Rust RUDP document server | yes | implemented (32 MiB default, `max_remembered_write_bytes`) | -| C# RUDP schema server (`CultNetRudpSchemaServer`, `src/GameCult.Networking/CultNetRudpSchemaServer.cs`) | no | n/a | - -Advertising is per server class, not per transport. A class advertises both frames in its hello only if it implements the receipt rules end to end; the C# RUDP schema server stays "no" until it answers writes with receipts. - -## Conformance follow-ups (not in this PR) - -C# server and client, Rust, TypeScript, Python, Kotlin: emit and parse the two frames; hello capability; tri-state client outcome; replace the #55 to #58 window-success; re-capture the wire vectors in each runtime. Server write paths include the shard write forwarder (rule 6) and the Odin catalog. 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/cultnet-rudp-ack-cut.md b/docs/cultnet-rudp-ack-cut.md index 47008c81..1b519484 100644 --- a/docs/cultnet-rudp-ack-cut.md +++ b/docs/cultnet-rudp-ack-cut.md @@ -441,8 +441,8 @@ consumer that waits wants delivered, which is now just Acknowledged. | `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` | | `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) | | `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it | -| `cultmesh-ts/src/index.ts:6211-6303` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts | -| `cultnet-ts/src/idunn-odin-presence-publisher.ts:97-136` `sendPresenceDocument` (formerly `publishDocument`), behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) | +| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts | +| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) | | `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published | | `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) | | C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None | 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..42adc5d9 100644 --- a/docs/start/README.md +++ b/docs/start/README.md @@ -284,7 +284,7 @@ public static class CultNetFirstCall Source: [`packages/cultnet-ts/test/first-call.test.ts`](../../packages/cultnet-ts/test/first-call.test.ts), run by `npm test` in `packages/cultnet-ts` (build `packages/cultcache-ts` first: `npm run build` there). The test imports -from `../src` where the block reads `@gamecult/cultnet-ts`. More: `packages/cultnet-ts/README.md`. +from `../src` where the block reads `cultnet-ts`. More: `packages/cultnet-ts/README.md`. ```ts import { createServer, connect } from "node:net"; @@ -294,7 +294,7 @@ import { CultNetDocumentRegistry, createTcpFramedCultNetPeer, defineCultNetDocumentBinding, -} from "@gamecult/cultnet-ts"; +} from "cultnet-ts"; const noteDocument = defineDocumentType({ type: "note", diff --git a/docs/start/io-game.md b/docs/start/io-game.md index e7f701a7..4781d9bf 100644 --- a/docs/start/io-game.md +++ b/docs/start/io-game.md @@ -9,7 +9,7 @@ leave because both lease the same document. | Piece | Status | | --- | --- | -| Two clients, real WebSockets, real `@gamecult/cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) | +| Two clients, real WebSockets, real `cultmesh-browser` client, one arena: join, see each other, move, leave | **tested** (`arena.test.ts`) | | Edge clamp, and the source below matching the files that ran | **tested** | | 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 | @@ -19,7 +19,7 @@ leave because both lease the same document. | Dropped players: a closed tab that never sent `leave` stays in the arena | **not yet**: operations do not carry a connection identity to the authority | | `CultCache` in the browser | **not yet**: the `@gamecult/cultcache-ts` entry imports Node built-ins, so the client keeps the decoded document in a variable and only `./document` is imported | -No `@gamecult/cultmesh-ts` or `@gamecult/cultnet-ts` TypeScript authority server exists. The server below is the CultNet +No `cultmesh-ts` or `cultnet-ts` TypeScript authority server exists. The server below is the CultNet message handling a provider owes, written in this recipe with the `ws` package. ## Run it @@ -61,8 +61,8 @@ export const arenaSize = 100; 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 { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts"; import { WebSocketServer, type WebSocket } from "ws"; import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; @@ -163,7 +163,7 @@ export async function startArena(port = 0, host = "127.0.0.1", onRequest?: Reque ## client.ts ```ts -import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser"; +import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser"; import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; // Runs unchanged in a browser or in Node 22+: both have a global WebSocket. diff --git a/docs/start/io-game/client.ts b/docs/start/io-game/client.ts index 10c4b4cd..8493e23c 100644 --- a/docs/start/io-game/client.ts +++ b/docs/start/io-game/client.ts @@ -1,4 +1,4 @@ -import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "@gamecult/cultmesh-browser"; +import { CultMeshBrowserClient, CultMeshBrowserOdinRendezvous, decodeCultNetOperationPayload, decodeCultNetPayload } from "cultmesh-browser"; import { arenaDocument, arenaKey, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; // Runs unchanged in a browser or in Node 22+: both have a global WebSocket. diff --git a/docs/start/io-game/server.ts b/docs/start/io-game/server.ts index 7ef552b9..39a1a618 100644 --- a/docs/start/io-game/server.ts +++ b/docs/start/io-game/server.ts @@ -1,8 +1,8 @@ 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 { decodeWireValue, encodeWireValue } from "cultnet-ts/wire"; +import { encodeCultNetMessageForWire, parseCultNetMessage, type CultNetMessage } from "cultnet-ts/contracts"; import { WebSocketServer, type WebSocket } from "ws"; import { arenaDocument, arenaKey, arenaSize, authorityRuntimeId, verseId, type ArenaState } from "./schema.ts"; 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-rs/tests/lookups.rs b/packages/cultcache-rs/tests/lookups.rs index ca5b459d..2467fbc6 100644 --- a/packages/cultcache-rs/tests/lookups.rs +++ b/packages/cultcache-rs/tests/lookups.rs @@ -2,8 +2,7 @@ //! (`src/GameCult.Caching/CultCache.cs`) and the one-global rule its commit enforces. use cultcache_rs::{ - CacheBackingStore, CultCache, CultCacheSecondGlobal, CultCacheSoaTable, DatabaseEntry, - SingleFileMessagePackBackingStore, + CacheBackingStore, CultCache, CultCacheSecondGlobal, DatabaseEntry, SingleFileMessagePackBackingStore, }; #[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)] @@ -322,31 +321,3 @@ fn loading_a_store_that_holds_two_globals_is_refused() { assert!(loading.pull_all_backing_stores().unwrap_err().is::()); 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..93e17239 100644 --- a/packages/cultcache-ts/CHANGELOG.md +++ b/packages/cultcache-ts/CHANGELOG.md @@ -3,26 +3,29 @@ 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 +35,27 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following is, but C# writers can still overwrite records a concurrent TypeScript writer added. - 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/package.json b/packages/cultcache-ts/package.json index b6803979..ffe1463d 100644 --- a/packages/cultcache-ts/package.json +++ b/packages/cultcache-ts/package.json @@ -1,6 +1,6 @@ { "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", 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..4a048dcc 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: 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/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..0fb400a0 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,8 +10,6 @@ from typing import Any import msgpack from cultnet_py import ( - error_message, - message_invalid_error, unsupported_schema_version_error, CultNetRawSnapshotResponse, CultNetSimulationObservationHub, @@ -31,12 +28,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 +51,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 +80,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 +88,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 +158,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: @@ -288,15 +238,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 +267,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 +309,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 +357,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 +373,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 +403,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 +519,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 +542,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 +556,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 +575,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 +598,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 +616,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 +803,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_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..40c911b1 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") @@ -5194,11 +5053,9 @@ class CultMeshTests(unittest.TestCase): self.assertEqual(unsupported["details"]["value"], "cultnet.nope.v0") self.assertIn("unsupported_schema_version", 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["code"], "unsupported_schema_version") self.assertEqual(unsupported_v1["details"]["value"], "cultnet.snapshot_request.v1") 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/src/lib.rs b/packages/cultmesh-rs/src/lib.rs index 33cb4858..5252fe09 100644 --- a/packages/cultmesh-rs/src/lib.rs +++ b/packages/cultmesh-rs/src/lib.rs @@ -82,10 +82,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 +149,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 +865,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 +881,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 +968,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 +1450,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 +1900,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 f97b2578..09c31adf 100644 --- a/packages/cultmesh-rs/src/rudp_document_server.rs +++ b/packages/cultmesh-rs/src/rudp_document_server.rs @@ -1,16 +1,15 @@ 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, readable_message_id, message_id_digest, missing_required_write_field, send_error_code, write_content_hash, + encode_rudp_packet, is_permanent_send_error, send_error_code, }; -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; #[cfg(test)] use std::collections::BTreeSet; use std::io::ErrorKind; use std::net::{SocketAddr, UdpSocket}; -use std::ops::ControlFlow; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; const MAX_UDP_DATAGRAM_BYTES: usize = 65_535; @@ -20,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 @@ -67,16 +56,6 @@ pub struct CultMeshRudpSnapshotQuery { /// Caller-owned admission/persistence port for received raw documents. pub trait CultMeshRudpRawDocumentSink { - /// Whether this put may be written, decided without writing it. A receipt-capable session asks it only - /// the first time it sees a message id; a resend is answered from what the session remembers - /// (write-receipt.md, rule 5). The default admits everything and leaves the decision to - /// `accept_raw_document`. - fn authorize_raw_document(&mut self, _receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { - Ok(()) - } - - /// Commits the document. `Ok` means committed and visible to readers: a receipt-capable peer is told - /// `write_admitted` on the strength of it, so a sink that has only queued the write must not return `Ok`. fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()>; } @@ -189,15 +168,6 @@ pub struct CultMeshRudpDocumentServerOptions { pub resend_delay: Duration, pub max_pending_reliable_packets_per_session: usize, pub max_fragment_bytes: usize, - /// Server-wide memory budget for the remembered writes of all receipt sessions - /// (write-receipt.md, "Remembered-write limits"): at most this many bytes, counted as - /// `REMEMBERED_ENTRY_BYTES` per entry. A new write over it is refused `limit_reached`, the session kept. - pub max_remembered_write_bytes: usize, -} - -impl CultMeshRudpDocumentServerOptions { - /// What one remembered write is charged: the digests of its id and content, and its answer. - pub const REMEMBERED_ENTRY_BYTES: usize = 32 + 32 + 1; } impl Default for CultMeshRudpDocumentServerOptions { @@ -213,7 +183,6 @@ impl Default for CultMeshRudpDocumentServerOptions { resend_delay: Duration::from_millis(50), max_pending_reliable_packets_per_session: 1024, max_fragment_bytes: 1200, - max_remembered_write_bytes: 32 * 1024 * 1024, } } } @@ -227,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. @@ -275,38 +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), - /// A receipt-capable session sent a message id it already used for a write with other content - /// (write-receipt.md, rule 5). Nothing reached the sink; the first write's state is unchanged. - MessageIdReused, - /// A receipt-capable session already remembers `MAX_REMEMBERED_WRITES_PER_SESSION` writes and takes no new - /// id. Nothing reached the sink. - WriteLimitReached, - /// A receipt-capable session sent a message id over `MAX_MESSAGE_ID_BYTES`. Nothing was remembered. - MessageIdTooLong, - /// A receipt-capable session sent a write on an unreliable channel, where no answer could be owed once. - ChannelNotReliable, - /// A receipt-capable session's put or delete has a null or missing `document`. - DocumentMissing, - /// A receipt-capable session's put or delete has a null or missing `schemaId`. - SchemaIdMissing, } 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::MessageIdReused => f.write_str("the message id was already used for different content"), - Self::WriteLimitReached => f.write_str("the session remembers as many writes as it will"), - Self::MessageIdTooLong => f.write_str("the message id is longer than 256 bytes"), - Self::ChannelNotReliable => f.write_str("a receipt session's write arrived on an unreliable channel"), - Self::DocumentMissing => f.write_str("the write has no document"), - Self::SchemaIdMissing => f.write_str("the write has no schemaId"), Self::DocumentUnservable { response_bytes, max_snapshot_response_bytes, @@ -347,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; 17] = [ +pub(crate) const REFUSAL_TEXTS: [&str; 9] = [ "the catalog refused the document", "the document can never be served", "the snapshot source failed", @@ -357,48 +297,12 @@ pub(crate) const REFUSAL_TEXTS: [&str; 17] = [ "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", - "the message id was already used for different content", - "the session's remembered-write limit is full", - "messageId exceeds 256 bytes", - "receipt sessions require the reliable channel", - "document is required", - "schemaId is required", ]; impl CultMeshRudpRejectionReason { /// The fixed text the refused peer is sent for this variant. fn refusal_text(&self) -> &'static str { - REFUSAL_TEXTS[usize::from(self.refusal_index())] - } - - /// The reason a remembered refusal replays as. Its payload (a sink's text, a size) is not remembered: the - /// peer is only ever sent the variant's fixed text, and the caller's rejection is reported once, at first sight. - fn from_refusal_index(index: u8) -> Self { - match index { - 0 => Self::SinkRefused(String::new()), - 1 => Self::DocumentUnservable { response_bytes: 0, max_snapshot_response_bytes: 0, fragment_count: 0, max_fragment_count: 0 }, - 2 => Self::SnapshotSourceFailed(String::new()), - 3 => Self::SnapshotTooManyDocuments { documents: 0, max_snapshot_documents: 0 }, - 4 => Self::SnapshotResponseTooLarge { response_bytes: 0, max_snapshot_response_bytes: 0 }, - 5 => Self::ResponseEncodingFailed(String::new()), - 6 => Self::PayloadBudgetFull, - 7 => Self::ResponseQueueFailed(String::new()), - 8 => Self::ResponseSendFailed(""), - 9 => Self::DeleteNotSupported, - 10 => Self::MessageUnreadable(String::new()), - 11 => Self::MessageIdReused, - 12 => Self::WriteLimitReached, - 13 => Self::MessageIdTooLong, - 14 => Self::ChannelNotReliable, - 15 => Self::DocumentMissing, - _ => Self::SchemaIdMissing, - } - } - - fn refusal_index(&self) -> u8 { - match self { + REFUSAL_TEXTS[match self { Self::SinkRefused(_) => 0, Self::DocumentUnservable { .. } => 1, Self::SnapshotSourceFailed(_) => 2, @@ -408,45 +312,7 @@ impl CultMeshRudpRejectionReason { Self::PayloadBudgetFull => 6, Self::ResponseQueueFailed(_) => 7, Self::ResponseSendFailed(_) => 8, - Self::DeleteNotSupported => 9, - Self::MessageUnreadable(_) => 10, - Self::MessageIdReused => 11, - Self::WriteLimitReached => 12, - Self::MessageIdTooLong => 13, - Self::ChannelNotReliable => 14, - Self::DocumentMissing => 15, - Self::SchemaIdMissing => 16, - } - } -} - -/// 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, - }), + }] } } @@ -455,64 +321,9 @@ 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. - /// - /// A receipt-capable session is sent `cultnet.error.v1` naming the refused message's id; any other - /// session the v0 error it has always been sent. - fn refusal_message(&self, receipts: bool) -> CultNetMessage { - let refusal = self.v0_refusal_message(); - match refusal { - CultNetMessage::Error { error, code, details } if receipts => CultNetMessage::ErrorV1 { - error, - code, - details, - message_id: Some(self.message_id.clone()).filter(|id| !id.is_empty()), - }, - other => other, - } - } - - fn v0_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) - } - (CultMeshRudpRejectionReason::MessageIdReused, _) => { - message_invalid_message("messageId", text) - } - (CultMeshRudpRejectionReason::MessageIdTooLong, _) => message_invalid_message("messageId", text), - (CultMeshRudpRejectionReason::DocumentMissing, _) => message_invalid_message("document", text), - (CultMeshRudpRejectionReason::SchemaIdMissing, _) => message_invalid_message("schemaId", text), - (CultMeshRudpRejectionReason::ChannelNotReliable, _) => message_invalid_message("channel", text), - (CultMeshRudpRejectionReason::WriteLimitReached, _) => CultNetMessage::Error { - error: format!("limit_reached: {text}"), - code: Some(CultNetErrorCode::LimitReached), - details: Some(CultNetErrorDetails::default()), - }, - _ => CultNetMessage::Error { - error: text.into(), - code: None, - details: None, - }, - } - } -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum CultMeshRudpPollOutcome { Idle, @@ -520,41 +331,14 @@ pub enum CultMeshRudpPollOutcome { ApplicationRejected(CultMeshRudpApplicationRejection), } -/// How a refusal sent on a live session fared. -enum Answered { - Sent, - /// The refusal can never be sent as built (a permanent send failure). - Unsendable(std::io::Error), - /// The session could not encode or queue the refusal: it was not sent at all. - Unqueueable, -} - /// Why a session ends before its peer is done with it. 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), - /// The server could not answer a write it owed an answer to. Not a refusal of anything the peer sent. - LocalFailure, -} - -fn write_rejection( - session: CultMeshRudpSessionKey, - operation: CultMeshRudpApplicationOperation, - message_id: &str, - record_key: &str, - reason: CultMeshRudpRejectionReason, -) -> CultMeshRudpApplicationRejection { - CultMeshRudpApplicationRejection { - session, - operation, - message_id: message_id.to_owned(), - record_key: Some(record_key.to_owned()), - reason, - } } struct SessionEntry { @@ -562,56 +346,8 @@ 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, - /// The peer's hello listed `cultnet.write_admitted.v0` and `cultnet.error.v1`: its writes are answered - /// with a receipt or an `error.v1` naming the write's id (write-receipt.md). Framing only; it grants nothing. - receipts: bool, - /// The capability is decided once, by the session's first hello or first write, whichever comes first; - /// a later hello changes nothing. - capability_settled: bool, - /// Answered writes of a receipt-capable session: message id -> (content hash, the answer given), refusals - /// included, so a resend of the same content is answered again without being decided again and the same - /// id with other content is refused. - answered_writes: HashMap<[u8; 32], RememberedWrite>, - /// Writes refused `limit_reached`, kept so their resend replays the refusal. A bounded set outside the - /// entry budget (`MAX_REMEMBERED_WRITES_PER_SESSION`); past it a new write is refused unremembered. - limit_refusals: HashMap<[u8; 32], [u8; 32]>, - /// A limit refusal was answered that `limit_refusals` had no room to remember. That id's resend cannot be - /// told from a new write, so the session refuses every id it has not seen for the rest of its life, even once - /// the server-wide budget has freed: an id refused at a bound is never admitted later. - limit_overflowed: bool, } -/// What the server remembers of a write it answered: digests only, so an entry costs -/// `REMEMBERED_ENTRY_BYTES` whatever the id or payload size. The answer's body is rebuilt from the resend, -/// whose content hash has been checked equal to the original's. -#[derive(Clone, Copy)] -struct RememberedWrite { - content_hash: [u8; 32], - answer: StoredAnswer, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum StoredAnswer { - Admitted, - /// A refusal, by its `REFUSAL_TEXTS` index (`CultMeshRudpRejectionReason::refusal_index`). - Refused(u8), -} - -/// The longest `messageId` a receipt session may send, in UTF-8 bytes. -const MAX_MESSAGE_ID_BYTES: usize = 256; - -/// The most answered writes a receipt-capable session remembers; a session at the limit refuses new writes -/// (`WriteLimitReached`) rather than forget an id it promised to answer idempotently. -const WRITE_ADMITTED_VERSION: &str = "cultnet.write_admitted.v0"; -const ERROR_V1_VERSION: &str = "cultnet.error.v1"; - -const MAX_REMEMBERED_WRITES_PER_SESSION: usize = 1024; - /// A synchronous, multi-session CultNet RUDP raw-document server. /// /// The server owns exactly one UDP socket. Call `poll_once` from an existing @@ -623,9 +359,6 @@ pub struct CultMeshRudpDocumentServer { snapshot_source: Q, clock: C, options: CultMeshRudpDocumentServerOptions, - /// Entries remembered across all live sessions (`answered_writes`), kept in step by `remember` and - /// `drop_session`, so the server-wide budget is a comparison, not a sum over sessions per write. - remembered_writes: usize, packets_dropped: u64, send_failures: u64, /// Peers whose every datagram fails to send, standing in for an unroutable @@ -662,7 +395,6 @@ where snapshot_source, clock, options, - remembered_writes: 0, packets_dropped: 0, send_failures: 0, #[cfg(test)] @@ -753,30 +485,18 @@ where self.packets_dropped += 1; return Ok(CultMeshRudpPollOutcome::Handled); } - let is_schema_data = packet.packet_type == CultNetRudpPacketType::Data && packet.channel_id == "schema"; - if is_schema_data && !packet.reliable { - // An unreliable write cannot be answered once: a receipt session is refused, never applied, and - // anything else unreliable is dropped as before. Either way the peer is not idle. - if let Some(entry) = self.sessions.get_mut(&key) { - entry.last_activity_monotonic_millis = now_monotonic; - } - return self.refuse_unreliable_write(key, &packet.payload); + if packet.packet_type == CultNetRudpPacketType::Data + && packet.channel_id == "schema" + && !packet.reliable + { + return Ok(CultMeshRudpPollOutcome::Handled); } - // A receipt session's write on a sequenced channel (which drops superseded packets) is refused too, but - // only after the session has received and acknowledged it: an unacknowledged packet is resent forever - // and stalls every later ordered write. - let refuse_sequenced = is_schema_data && packet.sequenced && self.receipts(key); let payload_charge = if packet.packet_type == CultNetRudpPacketType::Data { packet.payload.len() } else { 0 }; if !self.payload_budget_allows(key, payload_charge) { - // A receipt-capable session's dropped write would go unanswered; ending the session answers it - // (the peer's writes settle as session_ended). Any other session is dropped from silently, as before. - if self.receipts(key) { - self.end_session(key, SessionEnd::Refused)?; - } return Ok(CultMeshRudpPollOutcome::Handled); } @@ -800,188 +520,33 @@ 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, }; if result.disconnected { - self.drop_session(key); + self.sessions.remove(&key); return Ok(CultMeshRudpPollOutcome::Handled); } - let mut kept_rejection = None; for frame in result.delivered { if frame.channel_id != "schema" { continue; } - if refuse_sequenced { - if let CultMeshRudpPollOutcome::ApplicationRejected(rejection) = - self.refuse_unreliable_write(key, &frame.payload)? - { - kept_rejection = Some(rejection); - } - 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. - // Only an undecodable frame is a protocol violation of a receipt-capable session: its refused - // deletes are refused writes, answered one by one like its refused puts. - let receipt_session = self.receipts(key); - let (is_unreadable, is_delete) = ( - decoded.is_err(), - !receipt_session && 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) => { - // A receipt session's put or delete missing a required field is a refused write that - // names the field, not an unreadable frame. - let missing = if receipt_session { missing_required_write_field(&frame.payload) } else { None }; - Some(CultMeshRudpApplicationRejection { - session: key, - operation: match missing { - Some(("put", _)) => CultMeshRudpApplicationOperation::DocumentPutRaw, - Some(_) => CultMeshRudpApplicationOperation::DocumentDelete, - None => CultMeshRudpApplicationOperation::Unreadable, - }, - message_id: readable_message_id(&frame.payload).unwrap_or_default(), - record_key: None, - reason: match missing { - Some((_, "document")) => CultMeshRudpRejectionReason::DocumentMissing, - Some(_) => CultMeshRudpRejectionReason::SchemaIdMissing, - None => 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(self.receipts(key)); - match self.send_on_live_session(key, &refusal, self.receipts(key))? { - Answered::Sent => {} - Answered::Unsendable(error) => unsendable = Some(error), - Answered::Unqueueable => { - self.end_session(key, SessionEnd::LocalFailure)?; - return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); - } - } - kept_rejection = Some(rejection); - continue; - } - // A refused write of a receipt-capable session is answered with its error.v1 and the session - // goes on: only a protocol violation (the cases above and below) ends a session. - if matches!( - rejection.operation, - CultMeshRudpApplicationOperation::DocumentPutRaw | CultMeshRudpApplicationOperation::DocumentDelete - ) && self.receipts(key) - && !matches!( - rejection.reason, - CultMeshRudpRejectionReason::ResponseSendFailed(_) - ) - { - let refusal = rejection.refusal_message(true); - match self.send_on_live_session(key, &refusal, true)? { - Answered::Sent => {} - Answered::Unsendable(error) => unsendable = Some(error), - // An unanswerable write is answered by ending the session: its peer settles it - // as `session_ended`, never as silence. - Answered::Unqueueable => { - self.end_session(key, SessionEnd::LocalFailure)?; - return Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)); - } - } - 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(self.receipts(key)); - 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 @@ -990,17 +555,13 @@ where rejection.reason, CultMeshRudpRejectionReason::ResponseSendFailed(_) ) { - self.end_session(key, SessionEnd::Rejected(rejection.refusal_message(self.receipts(key))))?; + 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) @@ -1012,44 +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, - }) - } - - /// Refuses a put or delete that arrived on an unreliable or sequenced channel of a receipt session with - /// `message_invalid` (`details.field` `channel`) naming its id. Nothing is remembered or applied: the id - /// stays free for the same write sent reliably. - fn refuse_unreliable_write( - &mut self, - key: CultMeshRudpSessionKey, - payload: &[u8], - ) -> Result { - if !self.receipts(key) { - return Ok(CultMeshRudpPollOutcome::Handled); - } - let Ok(message) = decode_cultnet_message_from_slice(payload, CultNetWireContract::CultNetSchemaV0) else { - return Ok(CultMeshRudpPollOutcome::Handled); - }; - let (operation, message_id, record_key) = match message { - CultNetMessage::DocumentPutRaw { message_id, document } => { - (CultMeshRudpApplicationOperation::DocumentPutRaw, message_id, document.record_key) - } - CultNetMessage::DocumentDelete { message_id, record_key, .. } => { - (CultMeshRudpApplicationOperation::DocumentDelete, message_id, record_key) - } - _ => return Ok(CultMeshRudpPollOutcome::Handled), - }; - let rejection = write_rejection(key, operation, &message_id, &record_key, CultMeshRudpRejectionReason::ChannelNotReliable); - let refusal = rejection.refusal_message(true); - match self.send_on_live_session(key, &refusal, true)? { - Answered::Sent => {} - Answered::Unsendable(error) => self.end_session(key, SessionEnd::Unsendable(error))?, - Answered::Unqueueable => self.end_session(key, SessionEnd::LocalFailure)?, - } - Ok(CultMeshRudpPollOutcome::ApplicationRejected(rejection)) + Ok(CultMeshRudpPollOutcome::Handled) } /// Expire idle sessions and resend reliable packets whose deadline passed. @@ -1058,27 +582,15 @@ where let idle_timeout_ms = duration_millis(self.options.session_idle_timeout); let lifetime_ms = duration_millis(self.options.session_max_lifetime); let before = self.sessions.len(); - let mut freed = 0; self.sessions.retain(|_, entry| { - let live = now.saturating_sub(entry.last_activity_monotonic_millis) <= idle_timeout_ms - && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms; - if !live { - freed += entry.answered_writes.len(); - } - live + now.saturating_sub(entry.last_activity_monotonic_millis) <= idle_timeout_ms + && now.saturating_sub(entry.created_at_monotonic_millis) <= lifetime_ms }); - self.remembered_writes -= freed; 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)); } } @@ -1097,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( @@ -1133,7 +625,7 @@ where .get(&key) .is_some_and(|entry| !entry.session.connect_repeats(packet)) { - self.drop_session(key); + self.sessions.remove(&key); } let reply = match self.sessions.get_mut(&key) { Some(entry) => { @@ -1166,14 +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, - receipts: false, - capability_settled: false, - answered_writes: HashMap::new(), - limit_refusals: HashMap::new(), - limit_overflowed: false, }, ); accept @@ -1199,21 +683,49 @@ where message_id, document, } => { - // The capability is fixed by the first hello or, absent one, the first write. - if let Some(entry) = self.sessions.get_mut(&key) { - entry.capability_settled = true; - } - let receipts = self.receipts(key); - let record_key = document.record_key.clone(); - let rejection = |reason| { - write_rejection( - key, - CultMeshRudpApplicationOperation::DocumentPutRaw, - &message_id, - &record_key, + let reject = |reason| { + Ok(Some(CultMeshRudpApplicationRejection { + session: key, + operation: CultMeshRudpApplicationOperation::DocumentPutRaw, + message_id: message_id.clone(), reason, - ) + })) }; + // Admit only what some snapshot request can return: the smallest + // response that could carry this document is the record the snapshot + // source would serve for it, alone, under the shortest message id + // CultNet encodes, sized by the snapshot path's encoder and fragmented + // as the snapshot path sends it. + let served = match self.snapshot_source.served_record(&document) { + Ok(served) => served, + Err(error) => { + return reject(CultMeshRudpRejectionReason::SnapshotSourceFailed( + format!("{error:#}"), + )); + } + }; + let alone = CultNetMessage::SnapshotResponseRaw { + message_id: SHORTEST_MESSAGE_ID.into(), + documents: vec![served], + }; + let response_bytes = match encode_snapshot_response(&alone) { + Ok(payload) => payload.len(), + Err(error) => { + return reject(CultMeshRudpRejectionReason::ResponseEncodingFailed(error)); + } + }; + let fragment_count = self.fragments_for(response_bytes); + let max_fragment_count = self.max_fragments_per_response(); + if response_bytes > self.options.max_snapshot_response_bytes + || fragment_count > max_fragment_count + { + return reject(CultMeshRudpRejectionReason::DocumentUnservable { + response_bytes, + max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, + fragment_count, + max_fragment_count, + }); + } let receipt = CultMeshRudpRawDocumentReceipt { session: key, message_id: message_id.clone(), @@ -1221,86 +733,10 @@ where received_at_unix_millis: now_unix, document, }; - if !receipts { - return Ok(self.admit_put(receipt, false).err().map(rejection)); - } - let admitted = CultNetMessage::WriteAdmitted { - message_id: message_id.clone(), - operation: "put".into(), - schema_id: receipt.document.schema_id.clone(), - record_key: receipt.document.record_key.clone(), - }; - if message_id.len() > MAX_MESSAGE_ID_BYTES { - return Ok(Some(rejection(CultMeshRudpRejectionReason::MessageIdTooLong))); - } - let content_hash = match write_content_hash( - "put", - &receipt.document.schema_id, - &receipt.document.record_key, - Some(&receipt.document.payload), - ) { - Ok(hash) => hash, - Err(error) => { - return Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseEncodingFailed( - format!("{error:#}"), - )))); - } - }; - // The id is looked up first: a resend is answered with what the session already said, and - // the sink is asked only about an id the session has not seen. - let id_digest = message_id_digest(&message_id); - if let ControlFlow::Break(outcome) = - self.seen_write(key, now, id_digest, content_hash, &admitted, &rejection)? - { - return Ok(outcome); - } - match self.admit_put(receipt, true) { - Ok(()) => { - // Admitted: the sink holds it. Only now is a receipt owed, never before the commit point. - self.remember(key, id_digest, content_hash, StoredAnswer::Admitted); - return self.answer_sent(key, now, &admitted, &rejection); - } - Err(reason) => { - let refused = rejection(reason); - self.remember(key, id_digest, content_hash, StoredAnswer::Refused(refused.reason.refusal_index())); - return Ok(Some(refused)); - } - } - } - CultNetMessage::Hello { - supported_message_versions, - .. - } => { - let listed = |version: &str| { - supported_message_versions - .iter() - .flatten() - .any(|candidate| candidate == version) - }; - let Some(entry) = self.sessions.get_mut(&key).filter(|entry| !entry.capability_settled) else { - return Ok(None); - }; - entry.capability_settled = true; - entry.receipts = listed(WRITE_ADMITTED_VERSION) && listed(ERROR_V1_VERSION); - if entry.receipts { - // The server's own hello tells the client this is a receipt server, not a legacy one - // that has not answered yet. Best effort: the client's window settles a missing hello. - let hello = CultNetMessage::Hello { - runtime_id: "cultmesh-rs-rudp-document-server".into(), - runtime_kind: "cultmesh-rs".into(), - agent_id: None, - role: None, - display_name: None, - supported_document_types: None, - supported_mutation_contracts: None, - supported_message_versions: Some(vec![ - WRITE_ADMITTED_VERSION.into(), - ERROR_V1_VERSION.into(), - ]), - transport_profiles: None, - supports_schema_catalog: None, - }; - self.send_reliable_schema(key, now, &hello); + if let Err(error) = self.sink.accept_raw_document(receipt) { + return reject(CultMeshRudpRejectionReason::SinkRefused(format!( + "{error:#}" + ))); } } CultNetMessage::SnapshotRequest { @@ -1322,7 +758,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::SnapshotSourceFailed(format!( "{error:#}" )), @@ -1334,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, @@ -1352,7 +786,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::ResponseEncodingFailed(error), })); } @@ -1362,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, @@ -1374,7 +806,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::PayloadBudgetFull, })); } @@ -1401,7 +832,6 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::ResponseQueueFailed(format!( "{error:#}" )), @@ -1418,254 +848,16 @@ where session: key, operation: CultMeshRudpApplicationOperation::SnapshotRequest, message_id, - record_key: None, reason: CultMeshRudpRejectionReason::ResponseSendFailed(code), })); } } } - CultNetMessage::DocumentDelete { - message_id, - schema_id, - record_key, - } => { - // A delete settles the connection's capability exactly as a put does. - if let Some(entry) = self.sessions.get_mut(&key) { - entry.capability_settled = true; - } - let rejection = |reason| { - write_rejection( - key, - CultMeshRudpApplicationOperation::DocumentDelete, - &message_id, - &record_key, - reason, - ) - }; - if !self.receipts(key) { - return Ok(Some(rejection(CultMeshRudpRejectionReason::DeleteNotSupported))); - } - if message_id.len() > MAX_MESSAGE_ID_BYTES { - return Ok(Some(rejection(CultMeshRudpRejectionReason::MessageIdTooLong))); - } - let content_hash = match write_content_hash("delete", &schema_id, &record_key, None) { - Ok(hash) => hash, - Err(error) => { - return Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseEncodingFailed( - format!("{error:#}"), - )))); - } - }; - let id_digest = message_id_digest(&message_id); - // A delete is never admitted; the body is the receipt it would be answered with. - let would_admit = CultNetMessage::WriteAdmitted { - message_id: message_id.clone(), - operation: "delete".into(), - schema_id: schema_id.clone(), - record_key: record_key.clone(), - }; - if let ControlFlow::Break(outcome) = - self.seen_write(key, now, id_digest, content_hash, &would_admit, &rejection)? - { - return Ok(outcome); - } - let refused = rejection(CultMeshRudpRejectionReason::DeleteNotSupported); - self.remember(key, id_digest, content_hash, StoredAnswer::Refused(refused.reason.refusal_index())); - return Ok(Some(refused)); - } _ => {} } Ok(None) } - /// What a receipt-capable session already knows of a write, by the digest of its id. `Continue` is a first - /// sighting that fits both bounds. A repeat with the same content is answered with its remembered answer - /// (rebuilt from this resend, whose content hash is the original's), a repeat with other content is - /// `MessageIdReused`. A first sighting past the session table or the server-wide budget is refused - /// `limit_reached` for that write alone, and that refusal is remembered (`limit_refusals`) so its resend - /// replays it; no slot is freed, so the id is never admitted later in the session. A refusal the bounded set had no - /// room for sets `limit_overflowed`, after which the session refuses every unseen id, budget or no budget. - fn seen_write( - &mut self, - key: CultMeshRudpSessionKey, - now: u64, - id_digest: [u8; 32], - content_hash: [u8; 32], - admitted: &CultNetMessage, - rejection: &impl Fn(CultMeshRudpRejectionReason) -> CultMeshRudpApplicationRejection, - ) -> Result>> { - let Some(entry) = self.sessions.get(&key) else { - return Ok(ControlFlow::Continue(())); - }; - let prior = entry.answered_writes.get(&id_digest).copied().or_else(|| { - entry.limit_refusals.get(&id_digest).map(|&content_hash| RememberedWrite { - content_hash, - answer: StoredAnswer::Refused(CultMeshRudpRejectionReason::WriteLimitReached.refusal_index()), - }) - }); - let session_full = entry.answered_writes.len() >= MAX_REMEMBERED_WRITES_PER_SESSION; - let refusal_room = entry.limit_refusals.len() < MAX_REMEMBERED_WRITES_PER_SESSION; - let overflowed = entry.limit_overflowed; - let budget_full = self.remembered_writes - .checked_add(1) - .and_then(|entries| entries.checked_mul(CultMeshRudpDocumentServerOptions::REMEMBERED_ENTRY_BYTES)) - .is_none_or(|bytes| bytes > self.options.max_remembered_write_bytes); - Ok(ControlFlow::Break(match prior { - Some(remembered) if remembered.content_hash == content_hash => { - let answer = match remembered.answer { - StoredAnswer::Admitted => admitted.clone(), - StoredAnswer::Refused(index) => { - rejection(CultMeshRudpRejectionReason::from_refusal_index(index)).refusal_message(true) - } - }; - self.answer_sent(key, now, &answer, rejection)? - } - Some(_) => Some(rejection(CultMeshRudpRejectionReason::MessageIdReused)), - None if session_full || budget_full || overflowed => { - if let Some(entry) = self.sessions.get_mut(&key) { - if refusal_room { - entry.limit_refusals.insert(id_digest, content_hash); - } else { - entry.limit_overflowed = true; - } - } - Some(rejection(CultMeshRudpRejectionReason::WriteLimitReached)) - } - None => return Ok(ControlFlow::Continue(())), - })) - } - - fn remember(&mut self, key: CultMeshRudpSessionKey, id_digest: [u8; 32], content_hash: [u8; 32], answer: StoredAnswer) { - if let Some(entry) = self.sessions.get_mut(&key) { - if entry.answered_writes.insert(id_digest, RememberedWrite { content_hash, answer }).is_none() { - self.remembered_writes += 1; - } - } - } - - /// Removes a session for good and gives its remembered writes back to the server-wide budget. A session taken - /// out and put back within one call is not dropped. - fn drop_session(&mut self, key: CultMeshRudpSessionKey) -> Option { - let entry = self.sessions.remove(&key)?; - self.remembered_writes -= entry.answered_writes.len(); - Some(entry) - } - - /// Sends the answer owed a write. An answer that cannot be queued or sent would leave the write - /// unanswered, and a different answer would contradict what the sink did; so the session ends instead, - /// and the peer's write settles as unconfirmed (`session_ended`). The answer stays remembered. - fn answer_sent( - &mut self, - key: CultMeshRudpSessionKey, - now: u64, - answer: &CultNetMessage, - rejection: &impl Fn(CultMeshRudpRejectionReason) -> CultMeshRudpApplicationRejection, - ) -> Result> { - if self.send_reliable_schema(key, now, answer) { - return Ok(None); - } - self.end_session(key, SessionEnd::LocalFailure)?; - Ok(Some(rejection(CultMeshRudpRejectionReason::ResponseSendFailed( - "the write answer could not be sent".into(), - )))) - } - - /// Whether a put may be committed, committing it. `authorize` asks the sink first, as a receipt-capable - /// session does on an id's first sighting. - fn admit_put( - &mut self, - receipt: CultMeshRudpRawDocumentReceipt, - authorize: bool, - ) -> std::result::Result<(), CultMeshRudpRejectionReason> { - if authorize { - if let Err(error) = self.sink.authorize_raw_document(&receipt) { - return Err(CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))); - } - } - let document = &receipt.document; - // Admit only what some snapshot request can return: the smallest - // response that could carry this document is the record the snapshot - // source would serve for it, alone, under the shortest message id - // CultNet encodes, sized by the snapshot path's encoder and fragmented - // as the snapshot path sends it. - let served = self - .snapshot_source - .served_record(document) - .map_err(|error| CultMeshRudpRejectionReason::SnapshotSourceFailed(format!("{error:#}")))?; - let alone = CultNetMessage::SnapshotResponseRaw { - message_id: SHORTEST_MESSAGE_ID.into(), - documents: vec![served], - }; - let response_bytes = encode_snapshot_response(&alone) - .map_err(CultMeshRudpRejectionReason::ResponseEncodingFailed)? - .len(); - let fragment_count = self.fragments_for(response_bytes); - let max_fragment_count = self.max_fragments_per_response(); - if response_bytes > self.options.max_snapshot_response_bytes || fragment_count > max_fragment_count { - return Err(CultMeshRudpRejectionReason::DocumentUnservable { - response_bytes, - max_snapshot_response_bytes: self.options.max_snapshot_response_bytes, - fragment_count, - max_fragment_count, - }); - } - self.sink - .accept_raw_document(receipt) - .map_err(|error| CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}"))) - } - - /// Queues `message` reliably and in order on the schema channel and sends it; false when it could not be - /// encoded, queued or sent. - fn send_reliable_schema(&mut self, key: CultMeshRudpSessionKey, now: u64, message: &CultNetMessage) -> bool { - let Ok(payload) = encode_cultnet_message_to_vec(message, CultNetWireContract::CultNetSchemaV0) else { - return false; - }; - let max_fragment_bytes = self.options.max_fragment_bytes; - let Some(entry) = self.sessions.get_mut(&key) else { - return false; - }; - let Ok(packets) = entry.session.send_many( - "schema", - payload, - CultNetRudpSendOptions { - reliable: true, - ordered: true, - sequenced: false, - now_ms: now, - reliable_expire_after_ms: None, - }, - Some(max_fragment_bytes), - ) else { - return false; - }; - packets - .iter() - .all(|packet| matches!(self.send_packet(key.remote_addr, packet), Ok(None))) - } - - /// Answers `refusal` on the session while it stays live. - fn send_on_live_session( - &mut self, - key: CultMeshRudpSessionKey, - refusal: &CultNetMessage, - reliable: bool, - ) -> Result { - let Some(mut entry) = self.sessions.remove(&key) else { - return Ok(Answered::Sent); - }; - let packets = self.refusal_packets(&mut entry.session, refusal, reliable); - self.sessions.insert(key, entry); - if packets.is_empty() { - return Ok(Answered::Unqueueable); - } - for packet in &packets { - if let Some(error) = self.send_packet(key.remote_addr, packet)? { - return Ok(Answered::Unsendable(error)); - } - } - Ok(Answered::Sent) - } - /// Ends a session before its peer is done with it, and says goodbye. It is /// the one way a session ends early: every end removes the session, sends /// what the peer is owed, then the goodbye. @@ -1678,15 +870,13 @@ where /// that can never be sent as built is not dropped: the goodbye names its /// error instead. A refusal that cannot be encoded is not sent. fn end_session(&mut self, key: CultMeshRudpSessionKey, end: SessionEnd) -> Result<()> { - let Some(mut entry) = self.drop_session(key) else { + let Some(mut entry) = self.sessions.remove(&key) else { return Ok(()); }; - let local_failure = matches!(end, SessionEnd::LocalFailure); 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)), - SessionEnd::LocalFailure => (Vec::new(), None), }; entry.session.reset_peer_state(); let nothing = entry.session.create_ack(); @@ -1698,10 +888,9 @@ where break; } } - let goodbye = match (&unsendable, local_failure) { - (Some(error), _) => entry.session.end_unsendable_session(error), - (None, true) => entry.session.end_local_failure_session(), - (None, false) => entry.session.end_refused_session(), + let goodbye = match &unsendable { + Some(error) => entry.session.end_unsendable_session(error), + None => entry.session.end_refused_session(), }; // The goodbye is the session's last datagram. If it too can never be // sent, nobody is left to tell. @@ -1709,25 +898,25 @@ where Ok(()) } - fn receipts(&self, key: CultMeshRudpSessionKey) -> bool { - self.sessions.get(&key).is_some_and(|entry| entry.receipts) - } - - /// 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, @@ -1824,9 +1013,6 @@ fn validate_options(options: &CultMeshRudpDocumentServerOptions) -> Result<()> { if options.session_max_lifetime.is_zero() { return Err(anyhow!("session_max_lifetime must be greater than zero")); } - if options.max_remembered_write_bytes == 0 { - return Err(anyhow!("max_remembered_write_bytes must be greater than zero")); - } if options.max_admitted_payload_bytes == 0 { return Err(anyhow!( "max_admitted_payload_bytes must be greater than zero" @@ -1894,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/rudp_document_server.rs b/packages/cultmesh-rs/tests/rudp_document_server.rs index cccff92f..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, @@ -22,10 +22,6 @@ use std::time::Duration; struct SinkState { receipts: Vec, failures_remaining: usize, - /// Record keys the sink refuses to authorize, before and at the write. - denied_keys: Vec, - /// Every `authorize_raw_document` call, by message id. - authorized: Vec, } #[derive(Clone, Default)] @@ -38,29 +34,11 @@ impl Sink { ..Default::default() }))) } - - fn denying(keys: &[&str]) -> Self { - Self(Arc::new(Mutex::new(SinkState { - denied_keys: keys.iter().map(|key| key.to_string()).collect(), - ..Default::default() - }))) - } } impl CultMeshRudpRawDocumentSink for Sink { - fn authorize_raw_document(&mut self, receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> { - self.0.lock().unwrap().authorized.push(receipt.message_id.clone()); - if self.0.lock().unwrap().denied_keys.contains(&receipt.document.record_key) { - anyhow::bail!("not authorized"); - } - Ok(()) - } - fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> { let mut state = self.0.lock().unwrap(); - if state.denied_keys.contains(&receipt.document.record_key) { - anyhow::bail!("not authorized"); - } if state.failures_remaining > 0 { state.failures_remaining -= 1; anyhow::bail!("injected sink failure CANARY-7f3a"); @@ -365,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()? { @@ -396,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}" @@ -406,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; @@ -420,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 @@ -707,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( @@ -779,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); @@ -1651,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, @@ -1744,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"); @@ -1943,1182 +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(()) -} - -// Write receipts (contracts/cultnet/interop/write-receipt.md). A connection whose hello lists both new frames is -// answered with `write_admitted.v0` or `error.v1`, each carrying the write's id; any other connection is answered -// exactly as before. - -fn receipt_hello() -> CultNetMessage { - CultNetMessage::Hello { - runtime_id: "receipt-client".into(), - runtime_kind: "test".into(), - agent_id: None, - role: None, - display_name: None, - supported_document_types: None, - supported_mutation_contracts: None, - supported_message_versions: Some(vec![ - "cultnet.write_admitted.v0".into(), - "cultnet.error.v1".into(), - ]), - transport_profiles: None, - supports_schema_catalog: None, - } -} - -fn put(id: &str, key: &str, payload: Vec) -> CultNetMessage { - CultNetMessage::DocumentPutRaw { - message_id: id.into(), - document: document(key, payload), - } -} - -/// Polls the server until `client` has `count` schema frames or the polling runs out; rejections are expected here. -fn frames_after( - server: &mut CultMeshRudpDocumentServer, - client: &mut CultNetRudpSocketTransportConnection, - count: usize, -) -> Result> { - let mut frames = Vec::new(); - for _ in 0..600 { - server.poll_once()?; - client.poll_resends()?; - while let Some(frame) = client.receive_once()? { - if frame.channel_id == "schema" { - frames.push(decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - )?); - } - } - if frames.len() >= count { - break; - } - thread::sleep(Duration::from_millis(1)); - } - Ok(frames) -} - -/// The hello a receipt server sends a receipt-capable client, ahead of any answer. -fn server_hello() -> CultNetMessage { - CultNetMessage::Hello { - runtime_id: "cultmesh-rs-rudp-document-server".into(), - runtime_kind: "cultmesh-rs".into(), - agent_id: None, - role: None, - display_name: None, - supported_document_types: None, - supported_mutation_contracts: None, - supported_message_versions: Some(vec![ - "cultnet.write_admitted.v0".into(), - "cultnet.error.v1".into(), - ]), - transport_profiles: None, - supports_schema_catalog: None, - } -} - -/// The `count` answers after the server's hello, which must come first. -fn answers_after( - server: &mut CultMeshRudpDocumentServer, - client: &mut CultNetRudpSocketTransportConnection, - count: usize, -) -> Result> { - let mut frames = frames_after(server, client, count + 1)?; - assert_eq!(frames.first(), Some(&server_hello()), "the server answers the hello first"); - Ok(frames.split_off(1)) -} - -fn admitted(id: &str, key: &str) -> CultNetMessage { - CultNetMessage::WriteAdmitted { - message_id: id.into(), - operation: "put".into(), - schema_id: "test.raw.v1".into(), - record_key: key.into(), - } -} - -#[test] -fn an_admitted_put_on_a_receipt_connection_is_answered_with_a_receipt_carrying_its_id() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 201)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - Ok(()) -} - -#[test] -fn a_connection_without_the_capability_gets_no_receipt() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 202)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![]); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the put was admitted all the same"); - Ok(()) -} - -#[test] -fn a_resend_of_an_admitted_write_is_re_receipted_without_applying_it_twice() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 203)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - assert_eq!( - answers_after(&mut server, &mut peer, 2)?, - vec![admitted("m1", "note:1"), admitted("m1", "note:1")] - ); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - Ok(()) -} - -#[test] -fn the_same_id_with_other_content_is_refused_message_invalid_never_re_receipted() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 204)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1, 2, 3]))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![9, 9, 9]))?; - let frames = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(frames[0], admitted("m1", "note:1")); - let CultNetMessage::ErrorV1 { code, details, message_id, .. } = &frames[1] else { - panic!("the second answer must be a refusal, got {:?}", frames[1]); - }; - assert_eq!(*code, Some(CultNetErrorCode::MessageInvalid)); - assert_eq!(message_id.as_deref(), Some("m1")); - assert_eq!(details.as_ref().and_then(|d| d.field.as_deref()), Some("messageId")); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the changed write was not applied"); - Ok(()) -} - -#[test] -fn a_refused_put_on_a_receipt_connection_is_a_typed_error_naming_its_id() -> Result<()> { - let sink = Sink::fail_once(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 205)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m7", "note:7", vec![1]))?; - let frames = answers_after(&mut server, &mut peer, 1)?; - let Some(CultNetMessage::ErrorV1 { code, message_id, .. }) = frames.first() else { - panic!("expected error.v1, got {frames:?}"); - }; - assert_eq!(*code, Some(CultNetErrorCode::WriteNotAuthorized)); - assert_eq!(message_id.as_deref(), Some("m7")); - Ok(()) -} - -#[test] -fn a_hello_listing_only_one_of_the_two_frames_does_not_opt_in() -> Result<()> { - for listed in ["cultnet.write_admitted.v0", "cultnet.error.v1"] { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 206)?; - connect(&mut server, &mut [&mut peer])?; - let mut hello = receipt_hello(); - if let CultNetMessage::Hello { supported_message_versions, .. } = &mut hello { - *supported_message_versions = Some(vec![listed.into()]); - } - send_reliable(&mut peer, &hello)?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![], "listed only {listed}"); - } - Ok(()) -} - -fn refusal_of(message: &CultNetMessage) -> (Option, Option) { - let CultNetMessage::ErrorV1 { code, message_id, .. } = message else { - panic!("expected error.v1, got {message:?}"); - }; - (code.clone(), message_id.clone()) -} - -#[test] -fn a_refused_put_leaves_the_receipt_session_open_for_the_next_write() -> Result<()> { - let sink = Sink::fail_once(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 207)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m2", "note:2", vec![2]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::WriteNotAuthorized), Some("m1".into()))); - assert_eq!(answers[1], admitted("m2", "note:2")); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_reused_id_refuses_the_write_not_the_session() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 208)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![2]))?; - send_reliable(&mut peer, &put("m3", "note:3", vec![3]))?; - let answers = answers_after(&mut server, &mut peer, 3)?; - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("m1".into()))); - assert_eq!(answers[2], admitted("m3", "note:3")); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_full_dedupe_table_refuses_new_writes_and_keeps_the_session_and_its_receipts() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 209)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..1024 { - send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1]))?; - if index % 16 == 15 { - for _ in 0..40 { - server.poll_once()?; - peer.poll_resends()?; - while peer.receive_once()?.is_some() {} - } - } - } - send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; - send_reliable(&mut peer, &put("w0", "note:0", vec![1]))?; - let answers = frames_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); - let CultNetMessage::ErrorV1 { error, details, .. } = &answers[0] else { unreachable!() }; - assert_eq!(error, "limit_reached: the session's remembered-write limit is full", "the cap has its own fixed text"); - assert_eq!(details, &Some(CultNetErrorDetails::default()), "limit_reached names no field: details all null"); - assert_eq!(answers[1], admitted("w0", "note:0"), "a remembered id is still answered idempotently"); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_readable_id_with_a_bad_field_is_refused_with_that_id() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 210)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - // A put with no document: it does not decode, but its id is there to read. - let frame = rmp_serde::to_vec_named(&serde_json::json!({ - "schemaVersion": "cultnet.document_put_raw.v0", - "messageId": "bad-1", - }))?; - peer.send_reliable("schema", frame)?; - peer.send_reliable("schema", garbage())?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("bad-1".into()))); - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), None), "no id can be read"); - Ok(()) -} - -#[test] -fn a_second_hello_does_not_change_the_capability() -> Result<()> { - let mut downgrade = receipt_hello(); - if let CultNetMessage::Hello { supported_message_versions, .. } = &mut downgrade { - *supported_message_versions = None; - } - // Capable, then a hello listing nothing: still capable. - let sink = Sink::default(); - let mut server_a = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server_a.local_addr()?, 212)?; - connect(&mut server_a, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &downgrade)?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(answers_after(&mut server_a, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - // Legacy by its first write, then a capable hello: still legacy. - let mut server_b = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; - let mut late = client(server_b.local_addr()?, 213)?; - connect(&mut server_b, &mut [&mut late])?; - send_reliable(&mut late, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut late, &receipt_hello())?; - send_reliable(&mut late, &put("m2", "note:2", vec![1]))?; - assert_eq!(frames_after(&mut server_b, &mut late, 1)?, vec![]); - Ok(()) -} - -#[test] -fn a_receipt_session_that_runs_out_of_payload_budget_is_ended_not_left_silent() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_admitted_payload_bytes_per_session: 400, - max_snapshot_response_bytes: 200, - ..Default::default() - }; - let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 214)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..20 { - send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1; 64]))?; - } - frames_after(&mut server, &mut peer, usize::MAX)?; - assert_eq!(server.session_count(), 0, "the writes the budget dropped were answered by ending the session"); - Ok(()) -} - -#[test] -fn a_receipt_that_cannot_be_queued_ends_the_session_instead_of_leaving_the_write_unanswered() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_pending_reliable_packets_per_session: 3, - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 215)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - // The peer never reads, so it acknowledges nothing and the server's reliable queue fills. - for index in 0..8 { - send_reliable(&mut peer, &put(&format!("w{index}"), &format!("note:{index}"), vec![1]))?; - for _ in 0..20 { - server.poll_once()?; - } - } - assert_eq!(server.session_count(), 0, "a receipt the queue could not take ended the session"); - // The goodbye names a local failure: it is not the "session refused a packet" goodbye of a protocol refusal. - let mut reason = None; - for _ in 0..200 { - while peer.receive_once()?.is_some() {} - if let Some(found) = peer.disconnect_reason() { - reason = Some(found.to_vec()); - break; - } - thread::sleep(Duration::from_millis(1)); - } - assert_eq!(reason.expect("the peer was told the session ended"), b"server could not answer a write".to_vec()); - Ok(()) -} - -fn delete(id: &str, key: &str) -> CultNetMessage { - CultNetMessage::DocumentDelete { - message_id: id.into(), - schema_id: "test.raw.v1".into(), - record_key: key.into(), - } -} - -#[test] -fn a_resend_is_answered_from_memory_and_the_sink_is_not_asked_again() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 216)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - // The peer's authority over the key ends; the answer already given is still the answer. - sink.0.lock().unwrap().denied_keys.push("note:1".into()); - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - Ok(()) -} - -#[test] -fn a_used_id_with_other_content_is_message_invalid_even_for_a_key_the_peer_may_not_write() -> Result<()> { - let sink = Sink::denying(&["secret"]); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 211)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m1", "secret", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("m1".into()))); - Ok(()) -} - -#[test] -fn a_refused_write_is_remembered_and_its_resend_replays_the_refusal() -> Result<()> { - let sink = Sink::fail_once(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 217)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::WriteNotAuthorized), Some("m1".into()))); - assert_eq!(answers[1], answers[0], "the same refusal again, not a fresh attempt that would now succeed"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); - Ok(()) -} - -/// The sink is asked once, on an id's first sighting: not again for a resend (admitted or refused), and -/// not at all on a session that never declared the receipt capability. The sink here only counts, so a -/// flag that stopped or inverted the ask cannot hide behind the sink refusing at the write. -#[test] -fn authorize_is_called_once_per_first_sighting_and_only_for_receipt_sessions() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 219)?; - let mut legacy = client(server.local_addr()?, 220)?; - connect(&mut server, &mut [&mut peer, &mut legacy])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - assert_eq!(sink.0.lock().unwrap().authorized, vec!["m1".to_string()], "first sighting asks once"); - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - assert_eq!(sink.0.lock().unwrap().authorized, vec!["m1".to_string()], "the resend does not ask again"); - send_reliable(&mut legacy, &put("l1", "note:9", vec![1]))?; - for _ in 0..40 { - server.poll_once()?; - legacy.poll_resends()?; - while legacy.receive_once()?.is_some() {} - } - let state = sink.0.lock().unwrap(); - assert_eq!(state.receipts.len(), 2, "the legacy put was committed"); - assert_eq!(state.authorized, vec!["m1".to_string()], "a legacy session is never asked"); - Ok(()) -} - -#[test] -fn a_delete_that_reuses_a_puts_id_is_message_invalid() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 218)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - send_reliable(&mut peer, &delete("m1", "note:1"))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(answers[0], admitted("m1", "note:1")); - let CultNetMessage::ErrorV1 { code, details, message_id, .. } = &answers[1] else { - panic!("expected error.v1, got {:?}", answers[1]); - }; - assert_eq!(*code, Some(CultNetErrorCode::MessageInvalid)); - assert_eq!(message_id.as_deref(), Some("m1")); - assert_eq!(details.as_ref().and_then(|d| d.field.as_deref()), Some("messageId")); - Ok(()) -} - -#[test] -fn deletes_on_a_receipt_session_are_refused_one_by_one_and_never_end_it() -> Result<()> { - let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 219)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..12 { - send_reliable(&mut peer, &delete(&format!("d{index}"), "note:1"))?; - } - send_reliable(&mut peer, &delete("d3", "note:1"))?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 14)?; - assert_eq!(answers.len(), 14); - assert_eq!(refusal_of(&answers[12]), refusal_of(&answers[3]), "a resent delete replays its refusal"); - assert_eq!(answers[13], admitted("m1", "note:1")); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_delete_as_the_first_frame_settles_the_connection_as_legacy() -> Result<()> { - let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 220)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &delete("d1", "note:1"))?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - let frames = frames_after(&mut server, &mut peer, 3)?; - assert_eq!(frames.len(), 1, "only the legacy delete refusal: {frames:?}"); - assert!(matches!(frames[0], CultNetMessage::Error { .. }), "{:?}", frames[0]); - Ok(()) -} - -#[test] -fn a_refusal_that_cannot_be_queued_ends_the_session_instead_of_vanishing() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_pending_reliable_packets_per_session: 3, - ..Default::default() - }; - let mut server = server(options, Clock::new(70_000), Sink::denying(&["x"]), Source::default())?; - let mut peer = client(server.local_addr()?, 221)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..8 { - send_reliable(&mut peer, &put(&format!("w{index}"), "x", vec![1]))?; - for _ in 0..20 { - server.poll_once()?; - } - } - assert_eq!(server.session_count(), 0, "a refusal the queue could not take ended the session"); - Ok(()) -} - -#[test] -fn refusal_frames_from_a_peer_are_not_protocol_violations() -> Result<()> { - let mut server = server(Default::default(), Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 222)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for _ in 0..12 { - send_reliable( - &mut peer, - &CultNetMessage::ErrorV1 { - error: "message_invalid: nope".into(), - code: Some(CultNetErrorCode::MessageInvalid), - details: None, - message_id: Some("x".into()), - }, - )?; - send_reliable( - &mut peer, - &CultNetMessage::Error { error: "nope".into(), code: None, details: None }, - )?; - } - send_reliable(&mut peer, &put("m1", "note:1", vec![1]))?; - assert_eq!(answers_after(&mut server, &mut peer, 1)?, vec![admitted("m1", "note:1")]); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -#[test] -fn a_put_that_reuses_a_deletes_id_is_message_invalid() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 223)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &delete("d1", "note:1"))?; - send_reliable(&mut peer, &put("d1", "note:1", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("d1".into()))); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "the reusing put was not applied"); - Ok(()) -} - -fn error_text_and_field(message: &CultNetMessage) -> (String, Option) { - let CultNetMessage::ErrorV1 { error, details, .. } = message else { panic!("expected error.v1, got {message:?}") }; - (error.clone(), details.as_ref().and_then(|d| d.field.clone())) -} - -/// The server-wide budget counts fixed-size entries across sessions; a write over it is refused for that write -/// alone, the refusal is remembered (a resend replays it, no slot is freed), and every session is kept. -#[test] -fn a_server_wide_memory_budget_refuses_the_write_over_it_and_keeps_the_sessions() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 2 * 65, - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; - let mut a = client(server.local_addr()?, 230)?; - let mut b = client(server.local_addr()?, 231)?; - connect(&mut server, &mut [&mut a, &mut b])?; - send_reliable(&mut a, &receipt_hello())?; - send_reliable(&mut b, &receipt_hello())?; - send_reliable(&mut a, &put("a1", "note:a1", vec![1]))?; - send_reliable(&mut b, &put("b1", "note:b1", vec![1]))?; - for _ in 0..200 { - server.poll_once()?; - a.poll_resends()?; - b.poll_resends()?; - while a.receive_once()?.is_some() {} - while b.receive_once()?.is_some() {} - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); - send_reliable(&mut a, &put("a2", "note:a2", vec![1]))?; - send_reliable(&mut a, &put("a2", "note:a2", vec![1]))?; - let answers = frames_after(&mut server, &mut a, 2)?; - for answer in &answers { - assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::LimitReached), Some("a2".into())), "the resend replays the refusal"); - let CultNetMessage::ErrorV1 { details, .. } = answer else { unreachable!() }; - assert_eq!(details, &Some(CultNetErrorDetails::default())); - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 2, "the refused write was not applied"); - assert_eq!(server.session_count(), 2, "both sessions are kept"); - send_reliable(&mut a, &put("a1", "note:a1", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut a, 1)?, vec![admitted("a1", "note:a1")], "a remembered id is still answered"); - Ok(()) -} - -/// A message id over 256 bytes is refused before anything is remembered: no entry, no budget charge. -#[test] -fn a_message_id_over_256_bytes_is_refused_before_anything_is_remembered() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 232)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - let long = "x".repeat(257); - send_reliable(&mut peer, &put(&long, "note:1", vec![1]))?; - send_reliable(&mut peer, &put(&long, "note:1", vec![1]))?; - send_reliable(&mut peer, &put(&"y".repeat(256), "note:2", vec![1]))?; - let answers = answers_after(&mut server, &mut peer, 3)?; - for answer in &answers[..2] { - assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::MessageInvalid), Some(long.clone()))); - assert_eq!(error_text_and_field(answer), ("message_invalid: messageId exceeds 256 bytes".into(), Some("messageId".into()))); - } - assert_eq!(answers[2], admitted(&"y".repeat(256), "note:2"), "256 bytes is allowed, and the long id charged no budget"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "the long-id writes were never applied"); - Ok(()) -} - -/// A write on an unreliable channel of a receipt session is refused, not dropped and not accepted. -#[test] -fn an_unreliable_write_on_a_receipt_session_is_refused_message_invalid_naming_the_channel() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 233)?; - peer.send(&receipt_hello())?; - server.poll_once()?; - let payload = encode_cultnet_message_to_vec(&put("u1", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - let mut answers = Vec::new(); - for packet in peer.drain() { - for frame in peer.session.receive(&packet, 4)?.delivered { - answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?); - } - } - let refusals: Vec<_> = answers.iter().filter(|answer| matches!(answer, CultNetMessage::ErrorV1 { .. })).collect(); - assert_eq!(refusals.len(), 1, "the unreliable write is answered, not dropped: {answers:?}"); - assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("u1".into()))); - assert_eq!( - error_text_and_field(refusals[0]), - ("message_invalid: receipt sessions require the reliable channel".into(), Some("channel".into())) - ); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "nothing was applied"); - assert_eq!(server.session_count(), 1); - // The id was not consumed: the same write on the reliable channel is admitted. - peer.send(&put("u1", "note:1", vec![1]))?; - server.poll_once()?; - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1); - Ok(()) -} - -/// A limit refusal is remembered like any refusal: the same id with other content is a reused id, not a fresh -/// limit refusal; and the remembered refusals are a bounded set (1024), past which a refusal is not remembered. -#[test] -fn limit_refusals_are_remembered_in_a_bounded_set() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - ..Default::default() - }; - let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = client(server.local_addr()?, 234)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - send_reliable(&mut peer, &put("full", "note:full", vec![1]))?; - for index in 0..1024 { - send_reliable(&mut peer, &put(&format!("r{index}"), "note:r", vec![1]))?; - if index % 16 == 15 { - for _ in 0..40 { - server.poll_once()?; - peer.poll_resends()?; - while peer.receive_once()?.is_some() {} - } - } - } - // r0 was remembered as refused: other content under its id is a reused id. - send_reliable(&mut peer, &put("r0", "note:other", vec![2]))?; - let answers = frames_after(&mut server, &mut peer, 1)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::MessageInvalid), Some("r0".into()))); - // The 1025th refusal found the set full: it was refused but not remembered. - send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; - send_reliable(&mut peer, &put("over", "note:over", vec![2]))?; - let answers = frames_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::LimitReached), Some("over".into())), "not remembered: refused afresh"); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -/// A refusal the bounded set could not hold is still the session's first answer for that id: when the budget -/// frees (another session ends), the id is not admitted later in the session, and neither is any id the session -/// has not been seen to use, because the refusal that was not remembered cannot be told from a new write. -#[test] -fn a_session_whose_limit_refusals_overflowed_refuses_new_ids_after_the_budget_frees() -> Result<()> { - let clock = Clock::new(70_000); - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - session_max_lifetime: Duration::from_millis(100), - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, clock.clone(), sink.clone(), Source::default())?; - let mut holder = client(server.local_addr()?, 240)?; - connect(&mut server, &mut [&mut holder])?; - send_reliable(&mut holder, &receipt_hello())?; - send_reliable(&mut holder, &put("held", "note:held", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut holder, 2)?[1], admitted("held", "note:held"), "after the server's hello"); - - clock.set_monotonic(70_050); - let mut peer = client(server.local_addr()?, 241)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - for index in 0..1024 { - send_reliable(&mut peer, &put(&format!("r{index}"), "note:r", vec![1]))?; - if index % 16 == 15 { - for _ in 0..40 { - server.poll_once()?; - peer.poll_resends()?; - while peer.receive_once()?.is_some() {} - } - } - } - send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; - let answers = frames_after(&mut server, &mut peer, 1)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into()))); - - clock.set_monotonic(70_101); - assert_eq!(server.maintain()?.sessions_expired, 1, "the holder ended and freed the budget"); - assert_eq!(server.session_count(), 1); - send_reliable(&mut peer, &put("over", "note:over", vec![1]))?; - send_reliable(&mut peer, &put("fresh", "note:fresh", vec![1]))?; - let answers = frames_after(&mut server, &mut peer, 2)?; - assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::LimitReached), Some("over".into())), "never admitted later"); - assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::LimitReached), Some("fresh".into()))); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 1, "only the holder's write was applied"); - Ok(()) -} - -/// A session that ends gives its remembered entries back to the server-wide budget, whichever way it ends. -#[test] -fn a_session_that_ends_frees_its_remembered_writes_from_the_budget() -> Result<()> { - let clock = Clock::new(70_000); - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - session_max_lifetime: Duration::from_millis(100), - ..Default::default() - }; - let sink = Sink::default(); - let mut server = server(options, clock.clone(), sink.clone(), Source::default())?; - let mut first = client(server.local_addr()?, 242)?; - connect(&mut server, &mut [&mut first])?; - send_reliable(&mut first, &receipt_hello())?; - send_reliable(&mut first, &put("a", "note:a", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("a", "note:a"), "after the server's hello"); - - clock.set_monotonic(70_050); - let mut second = client(server.local_addr()?, 243)?; - connect(&mut server, &mut [&mut second])?; - send_reliable(&mut second, &receipt_hello())?; - send_reliable(&mut second, &put("b", "note:b", vec![1]))?; - assert_eq!(refusal_of(&frames_after(&mut server, &mut second, 2)?[1]).0, Some(CultNetErrorCode::LimitReached)); - - clock.set_monotonic(70_101); - assert_eq!(server.maintain()?.sessions_expired, 1); - send_reliable(&mut second, &put("c", "note:c", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut second, 1)?, vec![admitted("c", "note:c")], "the ended session's entry was freed"); - clock.set_monotonic(70_152); - assert_eq!(server.maintain()?.sessions_expired, 1); - assert_eq!(server.session_count(), 0); - let mut third = client(server.local_addr()?, 244)?; - connect(&mut server, &mut [&mut third])?; - send_reliable(&mut third, &receipt_hello())?; - send_reliable(&mut third, &put("d", "note:d", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut third, 2)?[1], admitted("d", "note:d"), "and so was the second's"); - Ok(()) -} - -/// Whichever way a session ends, its remembered writes go back to the budget: here the peer says goodbye, and -/// here the server ends it itself (an unreliable write whose refusal the reliable queue cannot take). -#[test] -fn a_disconnected_or_server_ended_session_frees_its_remembered_writes() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { - max_remembered_write_bytes: 65, - max_pending_reliable_packets_per_session: 3, - ..Default::default() - }; - let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; - let mut first = client(server.local_addr()?, 245)?; - connect(&mut server, &mut [&mut first])?; - send_reliable(&mut first, &receipt_hello())?; - send_reliable(&mut first, &put("a", "note:a", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("a", "note:a")); - first.disconnect(Vec::new())?; - for _ in 0..20 { - server.poll_once()?; - } - assert_eq!(server.session_count(), 0, "the goodbye ended the session"); - - // The budget holds one entry. A raw peer is admitted into it, then ended by the server. - let mut second = RawPeer::connect(&mut server, 246)?; - second.send(&receipt_hello())?; - second.send(&put("b", "note:b", vec![1]))?; - server.poll_once()?; - for index in 0..8 { - let payload = encode_cultnet_message_to_vec(&put(&format!("u{index}"), "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = second.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - second.send_packet(packet)?; - } - server.poll_once()?; - } - assert_eq!(server.session_count(), 0, "the server ended the session"); - - let mut third = client(server.local_addr()?, 247)?; - connect(&mut server, &mut [&mut third])?; - send_reliable(&mut third, &receipt_hello())?; - send_reliable(&mut third, &put("c", "note:c", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut third, 2)?[1], admitted("c", "note:c"), "both ended sessions' entries were freed"); - Ok(()) -} - -/// Receipts need a channel that is reliable and not sequenced: a reliable-sequenced write is refused like an -/// unreliable one, a reliable-unordered write is admitted, and a legacy session's sequenced write is left alone. -#[test] -fn only_reliable_non_sequenced_writes_are_accepted_on_a_receipt_session() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 248)?; - peer.send(&receipt_hello())?; - server.poll_once()?; - let send_with = |peer: &mut RawPeer, server: &mut Server, id: &str, ordered: bool, sequenced: bool| -> Result> { - let payload = encode_cultnet_message_to_vec(&put(id, "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: true, ordered, sequenced, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - let mut answers = Vec::new(); - for packet in peer.drain() { - for frame in peer.session.receive(&packet, 4)?.delivered { - answers.push(decode_cultnet_message_from_slice(&frame.payload, CultNetWireContract::CultNetSchemaV0)?); - } - } - Ok(answers) - }; - let sequenced = send_with(&mut peer, &mut server, "s1", false, true)?; - let refusals: Vec<_> = sequenced.iter().filter(|a| matches!(a, CultNetMessage::ErrorV1 { .. })).collect(); - assert_eq!(refusals.len(), 1, "the sequenced write is answered: {sequenced:?}"); - assert_eq!(refusal_of(refusals[0]), (Some(CultNetErrorCode::MessageInvalid), Some("s1".into()))); - assert_eq!( - error_text_and_field(refusals[0]), - ("message_invalid: receipt sessions require the reliable channel".into(), Some("channel".into())) - ); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0, "nothing was applied"); - - let unordered = send_with(&mut peer, &mut server, "s1", false, false)?; - assert!(unordered.contains(&admitted("s1", "note:1")), "reliable unordered is accepted, and the id was not consumed: {unordered:?}"); - // The same peer: the refused packet was acknowledged, so an ordered stream does not wait on its sequence number. - let ordered = send_with(&mut peer, &mut server, "o1", true, false)?; - assert!(ordered.contains(&admitted("o1", "note:1")), "reliable ordered is accepted: {ordered:?}"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); - - let legacy_sink = Sink::default(); - let mut legacy_server: Server = CultMeshRudpDocumentServer::new( - UdpSocket::bind("127.0.0.1:0")?, - legacy_sink.clone(), - Source::default(), - Clock::new(70_000), - Default::default(), - )?; - let mut legacy = RawPeer::connect(&mut legacy_server, 249)?; - let none = send_with(&mut legacy, &mut legacy_server, "l1", false, true)?; - assert!(none.is_empty(), "a legacy session is sent nothing: {none:?}"); - assert_eq!(legacy_sink.0.lock().unwrap().receipts.len(), 1, "and its reliable-sequenced write is applied as before"); - Ok(()) -} - -/// A refused write is traffic: the peer that sends it is not idle, so its session outlives the idle timeout measured -/// from the refusal and not from the packet before it. -#[test] -fn a_refused_write_keeps_the_session_from_going_idle() -> Result<()> { - let clock = Clock::new(70_000); - let options = CultMeshRudpDocumentServerOptions { session_idle_timeout: Duration::from_secs(1), ..Default::default() }; - let mut server = server(options, clock.clone(), Sink::default(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 252)?; - peer.send(&receipt_hello())?; - server.poll_once()?; - for (at, reliable, sequenced) in [(70_700, false, false), (71_400, true, true)] { - clock.set(at); - let payload = encode_cultnet_message_to_vec(&put("idle", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable, ordered: false, sequenced, now_ms: at, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - assert_eq!(server.maintain()?.sessions_expired, 0, "the refused write at {at} is activity"); - } - assert_eq!(server.session_count(), 1); - Ok(()) -} - -/// A Connect that does not repeat the accepted one is a new client session: it starts with nothing remembered, so -/// the id a receipt session already used is a new write on it, and the old session's entries leave the budget. -#[test] -fn a_reconnect_is_a_new_receipt_session_that_remembers_nothing_of_the_old() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { max_remembered_write_bytes: 65, ..Default::default() }; - let sink = Sink::default(); - let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?; - let target = server.local_addr()?; - let shared = UdpSocket::bind("127.0.0.1:0")?; - let twin = shared.try_clone()?; - let mut first = client_on(shared, target, 250, 50_000)?; - connect(&mut server, &mut [&mut first])?; - send_reliable(&mut first, &receipt_hello())?; - send_reliable(&mut first, &put("m1", "note:a", vec![1]))?; - assert_eq!(frames_after(&mut server, &mut first, 2)?[1], admitted("m1", "note:a")); - - let mut restarted = client_on(twin, target, 250, 7)?; - connect(&mut server, &mut [&mut restarted])?; - assert_eq!(server.session_count(), 1); - send_reliable(&mut restarted, &receipt_hello())?; - send_reliable(&mut restarted, &put("m1", "note:b", vec![2]))?; - let answers = frames_after(&mut server, &mut restarted, 2)?; - assert_eq!(answers[1], admitted("m1", "note:b"), "same id, other content: a new write on a new session, within a one-entry budget"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 2); - Ok(()) -} - -/// A session that never declared the capability gets no refusal for an unreliable write: it is dropped as before. -#[test] -fn an_unreliable_write_on_a_legacy_session_is_dropped_silently() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 235)?; - let payload = encode_cultnet_message_to_vec(&put("u1", "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - assert!(peer.drain().is_empty(), "a legacy session is sent nothing"); - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); - Ok(()) -} - -/// A put or delete whose document or schemaId is null or missing is refused `message_invalid` naming that -/// field (document first), with the spec's fixed text and the write's id; the session goes on. -#[test] -fn a_write_missing_its_document_or_schema_id_is_refused_naming_the_field() -> Result<()> { - let sink = Sink::default(); - let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?; - let mut peer = client(server.local_addr()?, 236)?; - connect(&mut server, &mut [&mut peer])?; - send_reliable(&mut peer, &receipt_hello())?; - let frames = [ - ("p-missing", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-missing"}), "document"), - ("p-null", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-null", "document": null}), "document"), - ("p-noschema", serde_json::json!({"schemaVersion": "cultnet.document_put_raw.v0", "messageId": "p-noschema", - "document": {"recordKey": "k", "storedAt": "t", "payloadEncoding": "messagepack", "payload": [1]}}), "schemaId"), - ("d-null", serde_json::json!({"schemaVersion": "cultnet.document_delete.v0", "messageId": "d-null", "schemaId": null, "recordKey": "k"}), "schemaId"), - ("d-missing", serde_json::json!({"schemaVersion": "cultnet.document_delete.v0", "messageId": "d-missing", "recordKey": "k"}), "schemaId"), - ]; - for (_, frame, _) in &frames { - peer.send_reliable("schema", rmp_serde::to_vec_named(frame)?)?; - } - let answers = answers_after(&mut server, &mut peer, frames.len())?; - for ((id, _, field), answer) in frames.iter().zip(&answers) { - assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::MessageInvalid), Some((*id).into()))); - assert_eq!( - error_text_and_field(answer), - (format!("message_invalid: {field} is required"), Some((*field).into())) - ); - } - assert_eq!(sink.0.lock().unwrap().receipts.len(), 0); - assert_eq!(server.session_count(), 1); - Ok(()) -} - -/// An unreliable write whose refusal the full reliable queue cannot take ends the session as a local failure -/// (the peer's write is then unconfirmed, never silent and never "refused a packet"). -#[test] -fn an_unreliable_refusal_that_cannot_be_queued_ends_the_session_as_a_local_failure() -> Result<()> { - let options = CultMeshRudpDocumentServerOptions { max_pending_reliable_packets_per_session: 3, ..Default::default() }; - let mut server = server(options, Clock::new(70_000), Sink::default(), Source::default())?; - let mut peer = RawPeer::connect(&mut server, 237)?; - peer.send(&receipt_hello())?; - server.poll_once()?; - // The peer acknowledges nothing, so each refusal stays in the server's reliable queue. - for index in 0..8 { - let payload = encode_cultnet_message_to_vec(&put(&format!("u{index}"), "note:1", vec![1]), CultNetWireContract::CultNetSchemaV0)?; - let packets = peer.session.send_many( - "schema", - payload, - cultnet_rs::CultNetRudpSendOptions { reliable: false, ordered: false, sequenced: false, now_ms: 3, reliable_expire_after_ms: None }, - Some(1200), - )?; - for packet in &packets { - peer.send_packet(packet)?; - } - server.poll_once()?; - } - assert_eq!(server.session_count(), 0, "the refusal the queue could not take ended the session"); - let goodbye = peer.drain().into_iter().find(is_goodbye).expect("the peer is told the session ended"); - assert_eq!(goodbye.payload, b"server could not answer a write".to_vec()); - Ok(()) -} diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index 5df85ad3..8b4e0ab5 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -1,27 +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 -### Added - -- `publishRudpDocumentOnce` takes `writeReceipts: true`: it sends a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` and, against a server that lists both back, resolves only on that write's `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` (now also carrying an `error.v1`) on its refusal and with the new `CultMeshRudpWriteUnconfirmedError` (`reason`: `window_elapsed` after 5000 ms, `session_ended`, or `local_send_failed`) when nothing settles it. Without the option, or against a server that did not advertise, there is no hello and the window is 500 ms; a put nothing settled in it is unconfirmed too, no longer "published" (**behaviour change**: a server that applies a put but answers nothing now yields `CultMeshRudpWriteUnconfirmedError`, as the spec requires). A legacy server's echo of the put's id is not admission: only `write_admitted` is. The flush that sends the put is bounded by the result window, and a send not acknowledged by then is `local_send_failed`. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. - ### Breaking -- `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default. -- 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 2f734cfd..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,13 +20,6 @@ import { CultNetRudpSocketTransportConnection, rudpClientBindHost, CultNetSchemaCatalog, - CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, - WriteTracker, - helloAdvertisesWriteReceipts, - resultWindowMs, - cultNetMessageInvalidError, - cultNetTextError, - cultNetWriteNotAuthorizedError, CultNetShardCatalog, cultNetBuiltinSchemaRegistry, decodeRudpPacket, @@ -38,7 +31,6 @@ import { sendRudpDatagram, type CultNetErrorMessage, type CultNetDocumentBinding, - type CultNetDocumentDeleteMessage, type CultNetMessage, type CultNetOperationRequestMessage, type CultNetOperationResponseMessage, @@ -48,9 +40,7 @@ import { type CultNetSchemaCatalogOptions, type CultNetSnapshotResponseRawMessage, type CultNetWireContract, - type WriteSettlement, - type WriteUnconfirmedReason, -} from "@gamecult/cultnet-ts"; +} from "cultnet-ts"; import { CultMeshQuicRealtimeConnector, @@ -4001,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; @@ -4097,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, @@ -4124,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; @@ -4189,64 +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; - } -} - -/** - * Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, the - * session ended first, or the client's own send failed. Not success and not refusal; a caller that needs admission treats it as not admitted. - */ -export class CultMeshRudpWriteUnconfirmedError extends Error { - readonly reason: WriteUnconfirmedReason; - readonly messageId: string; - constructor(messageId: string, reason: WriteUnconfirmedReason) { - super(`Write ${messageId} was not confirmed: ${reason}.`); - this.name = "CultMeshRudpWriteUnconfirmedError"; - this.reason = reason; - this.messageId = messageId; - } -} - export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions { - /** - * How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not - * admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the - * write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never - * published, whatever the server advertised: only a `write_admitted` is admission. The send itself is also - * bounded by the window (`flushTimeoutMs` is the upper bound); a send not acknowledged by then is - * unconfirmed as `local_send_failed`. - */ - resultWindowMs?: number; - /** - * Declare the write-receipt capability: send a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1` - * before the put. A server that lists both back is receipt-capable: the put then resolves only on its - * `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` on its `error.v1`, and rejects with - * `CultMeshRudpWriteUnconfirmedError` when nothing settles it. Unset, no hello is sent and the server answers - * as it always did. - */ - writeReceipts?: boolean; - /** With `writeReceipts`, how long to wait for the server's hello before treating it as legacy. Default 500 ms. */ - helloWaitMs?: number; - /** - * Send the put and report only that it was sent: no hello, no wait for a result frame. Resolves `"sent"` once the - * transport has acknowledged the put (`flushTimeoutMs`), rejects `CultMeshRudpWriteUnconfirmedError` - * (`local_send_failed`) if it cannot be sent, and never resolves `"admitted"`: whether the server applied or - * refused it is unknown. For callers whose server cannot answer a write yet. Opt-in per call; not combinable with - * `writeReceipts`. - */ - sendOnly?: boolean; messageId?: string; sourceRuntimeId?: string; sourceAgentId?: string; @@ -5610,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(); @@ -5622,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)) { @@ -5669,8 +5508,6 @@ export class CultMesh { connectPayload, closed: false, unsendable: false, - unreadableInARow: 0, - refusedDeletesInARow: 0, session: new CultNetRudpSession({ connectionId, initialSequence: options.initialSequence, @@ -5735,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; @@ -5793,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)); @@ -5828,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, @@ -5990,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; @@ -6044,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, @@ -6134,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); } @@ -6216,88 +5908,25 @@ export class CultMesh { recordKey: string, value: CultCacheDocumentValue, options: CultMeshRudpDocumentPublishOptions = {}, - ): Promise<"admitted" | "sent"> { + ): Promise { requireNonEmpty(runtimeId, "runtimeId"); requireNonEmpty(recordKey, "recordKey"); - if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined."); const registry = new CultNetDocumentRegistry([binding]); - const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; - // Built before any connection and outside every try: a put that cannot be built is the caller's error, with its cause. - const put = registry.createRawDocumentPutMessage( - binding, - messageId, - recordKey, - value, - { - sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, - sourceAgentId: options.sourceAgentId, - sourceRole: options.sourceRole, - tags: options.tags, - }, - ); const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { - if (options.sendOnly) { - try { - peer.send(put); - await peer.flush(options.flushTimeoutMs ?? 30_000); - } catch { - throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed"); - } - return "sent"; - } - // A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides - // how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw. - let receiptCapable = false; - let tracker: WriteTracker | undefined; - let settlement: WriteSettlement | undefined; - let wake: (() => void) | undefined; - let serverHello: (() => void) | undefined; - peer.on("message", (message: CultNetMessage) => { - if (message.schemaVersion === "cultnet.hello.v0") { - receiptCapable = helloAdvertisesWriteReceipts(message); - serverHello?.(); - } else { - tracker?.frame(message, Date.now()); - } - }); - peer.on("close", () => tracker?.sessionEnd(Date.now())); - if (options.writeReceipts) { - const answered = new Promise((resolve) => { serverHello = resolve; }); - peer.sendHello({ - schemaVersion: "cultnet.hello.v0", runtimeId, runtimeKind: "cultmesh-ts", - supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS], - }); - let helloTimer: NodeJS.Timeout | undefined; - await Promise.race([answered, new Promise((resolve) => { helloTimer = setTimeout(resolve, options.helloWaitMs ?? 500); })]); - clearTimeout(helloTimer); - } - const writeTracker = new WriteTracker({ - windowMs: options.resultWindowMs ?? resultWindowMs(receiptCapable), - onSettle: (_id, result) => { settlement = result; wake?.(); }, - }); - tracker = writeTracker; - writeTracker.send(messageId, Date.now()); - try { - peer.send(put); - // The flush may not outlast the result window: a send nobody acknowledged by then is a local send failure. - const windowRemaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; - await peer.flush(Math.min(options.flushTimeoutMs ?? 30_000, Math.max(windowRemaining, 1))); - } catch { - // Our own send failed or was never acknowledged: the put may or may not have left. Never a refusal. - writeTracker.sendFailed(messageId, Date.now()); - } - while (!settlement) { - const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; - await new Promise((resolve) => { - const timer = setTimeout(resolve, remaining); - wake = () => { clearTimeout(timer); resolve(); }; - }); - writeTracker.tick(Date.now()); - } - if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response); - if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); - return "admitted"; + peer.send(registry.createRawDocumentPutMessage( + binding, + options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`, + recordKey, + value, + { + sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, + sourceAgentId: options.sourceAgentId, + sourceRole: options.sourceRole, + tags: options.tags, + }, + )); + await peer.flush(options.flushTimeoutMs ?? 30_000); } finally { peer.close(); } @@ -6316,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 5c4a1eaa..00000000 --- a/packages/cultmesh-ts/test/client-write-completion.test.ts +++ /dev/null @@ -1,120 +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, CultMeshRudpWriteUnconfirmedError } 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("a put the server applied but did not answer is unconfirmed, never published", 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 assert.rejects( - 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 }), - (error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed", - ); - assert.deepEqual(puts, ["tests:completion:a"], "the server did apply it; this server just does not answer"); - } 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 68429969..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, CultMeshRudpWriteUnconfirmedError } 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, @@ -2968,30 +2965,25 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () try { await server.start(); - // This server answers a put with nothing, so the writer cannot know the put landed: unconfirmed, though it did. - await assert.rejects( - CultMesh.publishRudpDocumentOnce( - "cultmesh-ts-muninn", - connectionId, - `rudp://127.0.0.1:${server.bind.port}`, - defineCultNetDocumentBinding({ definition: noteDocument }), - "note:odin", - { - noteId: "note:odin", - body: "pay respects once", - }, - { - resendDelayMs: 25, - resendPollMs: 5, - maxFragmentBytes: 1024, - maxPendingReliablePackets: 16, - connectTimeoutMs: 1_000, - flushTimeoutMs: 25, - resultWindowMs: 100, - sourceRole: "test-provider", - }, - ), - (error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed", + await CultMesh.publishRudpDocumentOnce( + "cultmesh-ts-muninn", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + defineCultNetDocumentBinding({ definition: noteDocument }), + "note:odin", + { + noteId: "note:odin", + body: "pay respects once", + }, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + flushTimeoutMs: 25, + sourceRole: "test-provider", + }, ); await waitFor(() => received !== undefined, "published RUDP document"); @@ -3027,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) => ({ @@ -3063,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 }), ]), @@ -3161,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 }), ]), @@ -3243,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 }), @@ -3485,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", @@ -3647,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", @@ -3722,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}`); } @@ -3739,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((resolve) => socket.bind(0, "127.0.0.1", resolve)); - const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 }); - const timers = new Set(); - const seen: string[] = []; - let closed = false; - const send = (packet: Parameters[0], port: number, address: string) => { - if (!closed) socket.send(encodeRudpPacket(packet), port, address); - }; - const say = (message: CultNetMessage, port: number, address: string) => { - const packets = session.sendMany("schema", encodeWireValue(encodeCultNetMessageForWire(message)), { reliable: true, ordered: true, nowMs: Date.now() }); - packets.forEach((p) => send(p, port, address)); - }; - 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 message = decodeWireValue(frame.payload) as { schemaVersion: string; messageId?: string }; - seen.push(message.schemaVersion); - if (message.schemaVersion === "cultnet.hello.v0") { - say({ - schemaVersion: "cultnet.hello.v0", runtimeId: "fake", runtimeKind: "fake", - ...(script.advertise ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}), - }, remote.port, remote.address); - continue; - } - if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue; - const timer = setTimeout(() => { - timers.delete(timer); - if (script.answer === "echo") { - say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: message.messageId!, document: { schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } } as unknown as CultNetMessage, remote.port, remote.address); - } else if (script.answer === "goodbye") { - send(session.endUnsendableSession(new Error("scripted goodbye")), remote.port, remote.address); - } else if (script.answer === "refuse") { - say({ schemaVersion: "cultnet.error.v1", error: "write_not_authorized", routingHint: null, code: "write_not_authorized", details: null, messageId: message.messageId! }, remote.port, remote.address); - } else { - const messageId = script.answer === "wrong-id" ? "someone-else" : message.messageId!; - say({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a" }, remote.port, remote.address); - } - }, script.delayMs ?? 20); - timers.add(timer); - } - if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); - }); - return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, seen, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } }; -} - -const transport = { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 }; -const publish = (server: { endpoint: string }, options: Record) => - CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: "a", text: "x" }, { ...transport, ...options }); - -test("a receipt-capable server's write_admitted completes the put, past the legacy 500 ms window", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", delayMs: 700 }); - try { - await publish(server, { writeReceipts: true }); - assert.deepEqual(server.seen.slice(0, 2), ["cultnet.hello.v0", "cultnet.document_put_raw.v0"]); - } finally { server.close(); } -}); - -test("a receipt-capable server's error.v1 refuses the put with its code", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "refuse" }); - try { - await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) => - error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized" && error.response.schemaVersion === "cultnet.error.v1"); - } finally { server.close(); } -}); - -test("a receipt-capable server that stays silent leaves the put unconfirmed, never published", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); - try { - await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), (error: unknown) => - error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed"); - } finally { server.close(); } -}); - -test("a receipt for another id settles nothing", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "wrong-id" }); - try { - await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), CultMeshRudpWriteUnconfirmedError); - } finally { server.close(); } -}); - -test("a server whose hello does not advertise leaves a silent put unconfirmed after the 500 ms window, never published", async () => { - const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); - const started = Date.now(); - try { - await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) => - error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed"); - assert.ok(Date.now() - started >= 500, "the legacy window ran from the send"); - } finally { server.close(); } -}); - -test("a session that ends before any result leaves the put unconfirmed (session_ended), against a legacy server too", async () => { - for (const advertise of [false, true]) { - const server = await fakeServer(nextConnection, { advertise, answer: "goodbye" }); - try { - await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 3_000 }), (error: unknown) => - error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "session_ended"); - } finally { server.close(); } - } -}); - -test("a put whose send is never acknowledged is unconfirmed as local_send_failed, not an untyped error", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false }); - const started = Date.now(); - try { - await assert.rejects(publish(server, { writeReceipts: true, flushTimeoutMs: 300 }), (error: unknown) => - error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); - assert.ok(Date.now() - started < 2_500, "flushTimeoutMs bounds the send inside the 5 s window"); - } finally { server.close(); } -}); - -test("the flush is bounded by the result window: a send nobody acknowledges is local_send_failed at the window, not at the flush timeout", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false }); - const started = Date.now(); - try { - await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300, flushTimeoutMs: 20_000 }), (error: unknown) => - error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); - assert.ok(Date.now() - started < 3_000, "it did not wait out the 20 s flush timeout"); - } finally { server.close(); } -}); - -test("a server that does not advertise receipts and echoes the put's id back has not admitted it", async () => { - const server = await fakeServer(nextConnection, { advertise: false, answer: "echo" }); - try { - await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300 }), (error: unknown) => - error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed"); - } finally { server.close(); } -}); - -test("a silent server never yields admitted, in either mode: the default is unconfirmed, sendOnly is the distinct outcome sent", async () => { - const dflt = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); - try { - await assert.rejects(publish(dflt, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError); - } finally { dflt.close(); } - const sendOnly = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); - try { - const outcome = await publish(sendOnly, { sendOnly: true }); - assert.equal(outcome, "sent"); - assert.notEqual(outcome, "admitted"); - assert.deepEqual(sendOnly.seen, ["cultnet.document_put_raw.v0"], "sendOnly sends no hello"); - } finally { sendOnly.close(); } -}); - -test("only a write_admitted resolves admitted, and sendOnly does not wait for or act on an answer", async () => { - const admitting = await fakeServer(nextConnection, { advertise: true, answer: "admit" }); - try { - assert.equal(await publish(admitting, { writeReceipts: true }), "admitted"); - } finally { admitting.close(); } - const refusing = await fakeServer(nextConnection, { advertise: true, answer: "refuse", delayMs: 1 }); - try { - assert.equal(await publish(refusing, { sendOnly: true }), "sent", "a refusal is not awaited: sendOnly says only that the put was sent"); - } finally { refusing.close(); } -}); - -test("sendOnly fails local_send_failed when the send is never acknowledged", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false }); - try { - await assert.rejects(publish(server, { sendOnly: true, flushTimeoutMs: 300 }), (error: unknown) => - error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); - } finally { server.close(); } -}); - -test("sendOnly keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => { - const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); - try { - await assert.rejects( - CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, sendOnly: true }), - (error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error, - ); - assert.deepEqual(server.seen, [], "nothing was sent"); - } finally { server.close(); } -}); - -test("the receipt path keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "admit" }); - try { - await assert.rejects( - CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, writeReceipts: true }), - (error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error, - ); - assert.ok(!server.seen.includes("cultnet.document_put_raw.v0"), "no put was sent"); - } finally { server.close(); } -}); - -test("sendOnly cannot be combined with writeReceipts", async () => { - await assert.rejects(publish({ endpoint: "rudp://127.0.0.1:9" }, { sendOnly: true, writeReceipts: true }), TypeError); -}); - -test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); - try { - await assert.rejects(publish(server, { resultWindowMs: 100 }), CultMeshRudpWriteUnconfirmedError); - assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0"]); - } finally { server.close(); } -}); diff --git a/packages/cultnet-py/CHANGELOG.md b/packages/cultnet-py/CHANGELOG.md index b6bfa92b..04df5ddb 100644 --- a/packages/cultnet-py/CHANGELOG.md +++ b/packages/cultnet-py/CHANGELOG.md @@ -2,7 +2,6 @@ ## Unreleased (breaking; version 0.x, so the next minor) -- Added `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed. - Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by `tests/vectors/rudp-multipeer-server/transcript.txt`. The caller drives it (`try_receive_once`, `poll_resends`). Like the C# diff --git a/packages/cultnet-py/README.md b/packages/cultnet-py/README.md index 98a6db93..cca39ba7 100644 --- a/packages/cultnet-py/README.md +++ b/packages/cultnet-py/README.md @@ -8,7 +8,7 @@ wire contracts that both a node and a bare peer have to agree on. It depends on `cultcache-py` and `msgpack`; AES-GCM helpers on `CultNetSecret` light up with the optional `crypto` extra. `cultmesh-py` builds on it. The -layering mirrors `cultnet-rs` and `@gamecult/cultnet-ts`. +layering mirrors `cultnet-rs` and `cultnet-ts`. ## Example diff --git a/packages/cultnet-py/src/cultnet_py/__init__.py b/packages/cultnet-py/src/cultnet_py/__init__.py index bcf2fcf0..3b05079c 100644 --- a/packages/cultnet-py/src/cultnet_py/__init__.py +++ b/packages/cultnet-py/src/cultnet_py/__init__.py @@ -49,7 +49,6 @@ from .messages import ( document_put_raw, encode_witness_artifact_bundle_payload, error_message, - message_invalid_error, unsupported_schema_version_error, hello, login, @@ -263,7 +262,6 @@ __all__ = [ "encode_frame", "encode_rudp_packet", "error_message", - "message_invalid_error", "unsupported_schema_version_error", "hello", "INTEROP_WIRE_CONTRACT", diff --git a/packages/cultnet-py/src/cultnet_py/client.py b/packages/cultnet-py/src/cultnet_py/client.py index 35a960e3..28060753 100644 --- a/packages/cultnet-py/src/cultnet_py/client.py +++ b/packages/cultnet-py/src/cultnet_py/client.py @@ -58,7 +58,6 @@ class CultNetRawClient: create_transport: CultNetSchemaTransportFactory | None = None def send(self, message: CultNetMessage | dict[str, Any]) -> None: - """Fire-and-forget: an ack or a return is not admission; read the result frame.""" wire = message.to_wire() if isinstance(message, CultNetMessage) else message 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/messages.py b/packages/cultnet-py/src/cultnet_py/messages.py index 1a1f8e88..bf21b985 100644 --- a/packages/cultnet-py/src/cultnet_py/messages.py +++ b/packages/cultnet-py/src/cultnet_py/messages.py @@ -44,7 +44,6 @@ CULTNET_ERROR_CODES: tuple[str, ...] = ( "unowned_schema", "write_not_authorized", "unsupported_schema_version", - "message_invalid", ) _ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current") @@ -186,11 +185,6 @@ def unsupported_schema_version_error(schema_version: Any) -> CultNetMessage: ) -def message_invalid_error(field: str, reason: str) -> CultNetMessage: - """The refusal for a request that lacks a required field or carries an ill-typed one (C# ForMessageInvalid).""" - return error_message(f"message_invalid: {reason}", code="message_invalid", details={"field": field}) - - def login_success( *, nonce: str, diff --git a/packages/cultnet-py/src/cultnet_py/schema_catalog.py b/packages/cultnet-py/src/cultnet_py/schema_catalog.py index ee6fe430..fc03c8a2 100644 --- a/packages/cultnet-py/src/cultnet_py/schema_catalog.py +++ b/packages/cultnet-py/src/cultnet_py/schema_catalog.py @@ -22,7 +22,6 @@ CULTNET_ERROR_CODES = ( "unowned_schema", "write_not_authorized", "unsupported_schema_version", - "message_invalid", ) WIRE_MESSAGE_SCHEMA_VERSIONS = ( diff --git a/packages/cultnet-py/src/cultnet_py/transport.py b/packages/cultnet-py/src/cultnet_py/transport.py index 801d1313..3ecfa31b 100644 --- a/packages/cultnet-py/src/cultnet_py/transport.py +++ b/packages/cultnet-py/src/cultnet_py/transport.py @@ -1256,7 +1256,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, @@ -1568,7 +1567,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_error.py b/packages/cultnet-py/tests/test_cultnet_error.py index 6c27bda2..2904e679 100644 --- a/packages/cultnet-py/tests/test_cultnet_error.py +++ b/packages/cultnet-py/tests/test_cultnet_error.py @@ -6,7 +6,7 @@ from pathlib import Path import msgpack -from cultnet_py import CULTNET_ERROR_CODES, error_message, message_invalid_error, parse_message, unsupported_schema_version_error +from cultnet_py import CULTNET_ERROR_CODES, error_message, parse_message, unsupported_schema_version_error # contracts/cultnet/interop/error-vectors.json: bytes captured from MessagePack C# 3.1.7 and pinned # by cultnet-rs/tests/error_message.rs (Rust encodes to exactly these bytes). @@ -51,10 +51,6 @@ class CultNetErrorMessageTests(unittest.TestCase): ) self.assertEqual(selection.to_bytes(), by_name["selection_invalid_field_value"]) self.assertEqual(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() 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..341de5af 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", "unsupported_schema_version"] }, "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,7 +36,7 @@ "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"] }, + "field": { "description": "selection_invalid: the selection field that failed. variant_unsupported: \"subscriptionId\", when a subscription was ended. unowned_schema: \"schemaId\". write_not_authorized: \"recordKey\". unsupported_schema_version: \"schemaVersion\".", "type": ["string", "null"] }, "value": { "description": "selection_invalid: the offending value, when there is one string worth naming. variant_unsupported: the id of the subscription that ended. unowned_schema: the schema id no shard owns. write_not_authorized: the key of the refused write. unsupported_schema_version: the schemaVersion the peer does not speak.", "type": ["string", "null"] }, "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 0ea2aa53..bf661df2 100644 --- a/packages/cultnet-rs/src/contracts.rs +++ b/packages/cultnet-rs/src/contracts.rs @@ -248,10 +248,6 @@ pub enum CultNetErrorCode { UnownedSchema, WriteNotAuthorized, UnsupportedSchemaVersion, - MessageInvalid, - /// `cultnet.error.v1` only: the session's remembered-write limit is full. `cultnet.error.v0` neither - /// encodes nor decodes it, so its enum stays the schema's. - LimitReached, } impl CultNetErrorCode { @@ -265,8 +261,6 @@ impl CultNetErrorCode { Self::UnownedSchema => "unowned_schema", Self::WriteNotAuthorized => "write_not_authorized", Self::UnsupportedSchemaVersion => "unsupported_schema_version", - Self::MessageInvalid => "message_invalid", - Self::LimitReached => "limit_reached", } } @@ -280,8 +274,6 @@ impl CultNetErrorCode { "unowned_schema" => Ok(Self::UnownedSchema), "write_not_authorized" => Ok(Self::WriteNotAuthorized), "unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion), - "message_invalid" => Ok(Self::MessageInvalid), - "limit_reached" => Ok(Self::LimitReached), _ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")), } } @@ -364,26 +356,6 @@ pub enum CultNetMessage { #[serde(default, skip_serializing_if = "Option::is_none")] details: Option, }, - /// `cultnet.error.v1` (contracts/cultnet/interop/write-receipt.md): `cultnet.error.v0` plus the id of the - /// request it answers. Sent only to a connection that declared the write-receipt capability. - #[serde(skip_deserializing)] - #[serde(rename = "cultnet.error.v1", rename_all = "camelCase")] - ErrorV1 { - error: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - code: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - details: Option, - message_id: Option, - }, - /// `cultnet.write_admitted.v0`: the receipt for one admitted put or delete, sent after its commit point. - #[serde(rename = "cultnet.write_admitted.v0", rename_all = "camelCase")] - WriteAdmitted { - message_id: String, - operation: String, - schema_id: String, - record_key: String, - }, #[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")] SampleChangeName { name: String }, #[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")] @@ -568,9 +540,7 @@ pub fn encode_cultnet_message_for_wire( | CultNetMessage::SnapshotResponseRawV1 { .. } | CultNetMessage::ContentChunkRequest { .. } | CultNetMessage::ContentChunkResponse { .. } - | CultNetMessage::Error { .. } - | CultNetMessage::ErrorV1 { .. } - | CultNetMessage::WriteAdmitted { .. } => encode_raw_cultnet_schema_message(message), + | CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message), _ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec( &serde_json::to_value(message)?, )?)?), @@ -600,8 +570,7 @@ pub fn decode_cultnet_message_from_slice( | "cultnet.snapshot_response_raw.v0" | "cultmesh.content_chunk_request.v1" | "cultmesh.content_chunk_response.v1" - | "cultnet.error.v0" - | "cultnet.error.v1", + | "cultnet.error.v0", ) => parse_raw_cultnet_schema_message(&wire_value) .with_context(|| received_version_label(&wire_value))?, _ => cultcache_rs::decode_payload::(bytes) @@ -673,20 +642,6 @@ fn validate_message(message: &CultNetMessage) -> Result<()> { require_non_empty(session, "session")?; } CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?, - CultNetMessage::ErrorV1 { error, message_id, .. } => { - require_non_empty(error, "error")?; - if let Some(message_id) = message_id { - require_non_empty(message_id, "messageId")?; - } - } - CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => { - require_non_empty(message_id, "messageId")?; - if operation != "put" && operation != "delete" { - return Err(anyhow!("operation must be put or delete")); - } - require_non_empty(schema_id, "schemaId")?; - require_non_empty(record_key, "recordKey")?; - } CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?, CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?, CultNetMessage::DocumentDelete { @@ -1063,7 +1018,7 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result { + "cultnet.error.v0" => { let code = get("code") .filter(|value| !value.is_nil()) .map(|value| { @@ -1073,23 +1028,12 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result None, - other => Some(require_legacy_string(other, "messageId")?), - }, - }); - } - Ok(CultNetMessage::Error { error, code, details }) + Ok(CultNetMessage::Error { + error: require_legacy_string(get("error"), "ErrorMessage.Error")?, + code, + details, + }) } _ => Err(anyhow!( "Unsupported raw cultnet.schema.v0 message" @@ -1143,18 +1087,7 @@ fn require_legacy_optional_u64( } fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result { - if let CultNetMessage::Error { code: Some(CultNetErrorCode::LimitReached), .. } = message { - return Err(anyhow!("limit_reached is a cultnet.error.v1 code; cultnet.error.v0 cannot carry it")); - } Ok(rmpv::Value::Map(match message { - // Spec field order (write-receipt.md), not serde_json's sorted map. - CultNetMessage::WriteAdmitted { message_id, operation, schema_id, record_key } => vec![ - (rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.write_admitted.v0")), - (rmpv::Value::from("messageId"), rmpv::Value::from(message_id.as_str())), - (rmpv::Value::from("operation"), rmpv::Value::from(operation.as_str())), - (rmpv::Value::from("schemaId"), rmpv::Value::from(schema_id.as_str())), - (rmpv::Value::from("recordKey"), rmpv::Value::from(record_key.as_str())), - ], CultNetMessage::DocumentPutRaw { message_id, document, @@ -1319,36 +1252,6 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result vec![ - (rmpv::Value::from("schemaVersion"), rmpv::Value::from("cultnet.error.v1")), - (rmpv::Value::from("error"), rmpv::Value::from(error.as_str())), - (rmpv::Value::from("routingHint"), rmpv::Value::Nil), - ( - rmpv::Value::from("code"), - code.as_ref() - .map(|code| rmpv::Value::from(code.as_wire_str())) - .unwrap_or(rmpv::Value::Nil), - ), - ( - rmpv::Value::from("details"), - details - .as_ref() - .map(encode_error_details) - .unwrap_or(rmpv::Value::Nil), - ), - ( - rmpv::Value::from("messageId"), - message_id - .as_deref() - .map(rmpv::Value::from) - .unwrap_or(rmpv::Value::Nil), - ), - ], _ => { return Err(anyhow!( "Message is not a raw cultnet.schema.v0 binary replication message" @@ -1978,71 +1881,3 @@ fn legacy_schema_descriptor(schema: &CultNetSchemaDescriptor) -> Result, -) -> Result<[u8; 32]> { - use sha2::{Digest, Sha256}; - let value = rmpv::Value::Array(vec![ - rmpv::Value::from(operation), - rmpv::Value::from(schema_id), - rmpv::Value::from(record_key), - payload.map_or(rmpv::Value::Nil, |payload| rmpv::Value::Binary(payload.to_vec())), - ]); - let mut bytes = Vec::new(); - rmpv::encode::write_value(&mut bytes, &value)?; - Ok(Sha256::digest(&bytes).into()) -} - -/// SHA-256 of a write's `messageId`: what a receipt server remembers in place of the id itself, so an -/// entry's memory does not depend on the id's length (write-receipt.md, "What is remembered"). -pub fn message_id_digest(message_id: &str) -> [u8; 32] { - use sha2::{Digest, Sha256}; - Sha256::digest(message_id.as_bytes()).into() -} - -/// The `messageId` of a schema frame that does not decode as a message (write-receipt.md, rule 3: a refusal -/// carries the write's id whenever the frame is readable enough to find it). `None` when the bytes are not -/// a MessagePack map or hold no non-empty string `messageId`. -pub fn readable_message_id(bytes: &[u8]) -> Option { - let value = rmpv::decode::read_value(&mut &*bytes).ok()?; - let rmpv::Value::Map(entries) = value else { - return None; - }; - entries.into_iter().find_map(|(key, value)| match (key.as_str(), value) { - (Some("messageId"), rmpv::Value::String(id)) => id.into_str().filter(|id| !id.is_empty()), - _ => None, - }) -} - -/// The first required field (`document`, then `schemaId`) that a put or delete frame lacks or holds as nil -/// (write-receipt.md, "Required fields"), with the write's operation (`"put"` or `"delete"`). `None` when the -/// bytes are not such a frame, or both fields are present. A put's `schemaId` is its document's. -pub fn missing_required_write_field(bytes: &[u8]) -> Option<(&'static str, &'static str)> { - let rmpv::Value::Map(entries) = rmpv::decode::read_value(&mut &*bytes).ok()? else { - return None; - }; - let field = |entries: &[(rmpv::Value, rmpv::Value)], name: &str| { - entries - .iter() - .find(|(key, _)| key.as_str() == Some(name)) - .map(|(_, value)| value.clone()) - .filter(|value| !value.is_nil()) - }; - match field(&entries, "schemaVersion")?.as_str()? { - "cultnet.document_put_raw.v0" => match field(&entries, "document") { - Some(rmpv::Value::Map(document)) => { - field(&document, "schemaId").is_none().then_some(("put", "schemaId")) - } - _ => Some(("put", "document")), - }, - "cultnet.document_delete.v0" => field(&entries, "schemaId").is_none().then_some(("delete", "schemaId")), - _ => None, - } -} diff --git a/packages/cultnet-rs/src/rudp.rs b/packages/cultnet-rs/src/rudp.rs index eae7a2ee..37bf73d8 100644 --- a/packages/cultnet-rs/src/rudp.rs +++ b/packages/cultnet-rs/src/rudp.rs @@ -468,14 +468,6 @@ impl CultNetRudpSession { self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec()) } - /// Ends a session because the server itself could not answer a write it owes an answer to (its answer - /// could not be encoded, queued or sent). The peer did nothing wrong: this goodbye is not the - /// "session refused a packet" of a protocol refusal. - pub fn end_local_failure_session(&mut self) -> CultNetRudpPacket { - self.reset_peer_state(); - self.create_disconnect(RUDP_LOCAL_FAILURE_REASON.to_vec()) - } - /// Ends a session that owes its peer a packet that can never be sent as /// built (see `is_permanent_send_error`). Resending it would fail forever, /// and dropping it would leave a reliable sequence the peer waits on for @@ -2206,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( @@ -2225,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, @@ -2320,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 @@ -2617,8 +2603,6 @@ where /// Sent to a peer whose session refused one of its packets, and reported to /// the local caller as the disconnect reason. const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet"; -/// Goodbye reason of a session whose server could not answer a write (`end_local_failure_session`). -const RUDP_LOCAL_FAILURE_REASON: &[u8] = b"server could not answer a write"; const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out"; /// Prefix of the goodbye reason for a session that owed a packet which can /// never be sent; the failure's fixed name (`send_error_code`) follows it. 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..06e25935 100644 --- a/packages/cultnet-rs/tests/error_message.rs +++ b/packages/cultnet-rs/tests/error_message.rs @@ -188,21 +188,6 @@ fn cases() -> Vec { }), }, }, - 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, - }), - }, - }, ] } diff --git a/packages/cultnet-rs/tests/write_receipt.rs b/packages/cultnet-rs/tests/write_receipt.rs deleted file mode 100644 index 6d06a68c..00000000 --- a/packages/cultnet-rs/tests/write_receipt.rs +++ /dev/null @@ -1,155 +0,0 @@ -//! Wire parity with contracts/cultnet/interop/write-receipt-vectors.json: the content hash, and the two new frames. - -use cultnet_rs::{ - CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract, decode_cultnet_message_from_slice, - encode_cultnet_message_to_vec, write_content_hash, -}; - -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -#[test] -fn the_content_hash_matches_the_spec_vectors() { - let a = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x31]; - let b = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x32]; - let cases = [ - (write_content_hash("put", "note.v0", "note:1", Some(&a)), "7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d"), - (write_content_hash("put", "note.v0", "note:1", Some(&b)), "58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f"), - (write_content_hash("put", "note.v0", "note:2", Some(&a)), "a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8"), - (write_content_hash("delete", "note.v0", "note:1", None), "232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37"), - ]; - for (hash, expected) in cases { - assert_eq!(hex(&hash.unwrap()), expected); - } -} - -#[test] -fn the_new_frames_decode_back_equal() { - let admitted = CultNetMessage::WriteAdmitted { - message_id: "put-1".into(), - operation: "put".into(), - schema_id: "note.v0".into(), - record_key: "note:1".into(), - }; - let bytes = encode_cultnet_message_to_vec(&admitted, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!( - decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(), - admitted - ); - // error.v1 with a nil id round-trips as nil, and the code enum is v0's. - let error = CultNetMessage::ErrorV1 { - error: "message_invalid: unreadable frame".into(), - code: Some(CultNetErrorCode::MessageInvalid), - details: None, - message_id: None, - }; - let bytes = encode_cultnet_message_to_vec(&error, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!( - decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0).unwrap(), - error - ); -} - -fn unhex(text: &str) -> Vec { - (0..text.len()).step_by(2).map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()).collect() -} - -/// The spec's wire vectors (write-receipt-vectors.json `wire`): the bytes decode to the vector's fields, not -/// merely to something that encodes back to itself. -#[test] -fn the_spec_wire_vectors_decode_to_their_expected_fields() { - let admitted = decode_cultnet_message_from_slice( - &unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), - CultNetWireContract::CultNetSchemaV0, - ) - .unwrap(); - assert_eq!( - admitted, - CultNetMessage::WriteAdmitted { - message_id: "put-1".into(), - operation: "put".into(), - schema_id: "note.v0".into(), - record_key: "note:1".into(), - } - ); - let refused = decode_cultnet_message_from_slice( - &unhex("86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32"), - CultNetWireContract::CultNetSchemaV0, - ) - .unwrap(); - let CultNetMessage::ErrorV1 { error, code, details, message_id } = refused else { - panic!("expected error.v1"); - }; - assert_eq!(error, "write_not_authorized: the catalog refused the document"); - assert_eq!(code, Some(CultNetErrorCode::WriteNotAuthorized)); - assert_eq!(message_id.as_deref(), Some("put-2")); - let details = details.unwrap(); - assert_eq!(details.field.as_deref(), Some("recordKey")); - assert_eq!(details.value.as_deref(), Some("note:2")); - assert_eq!((details.as_of, details.current), (None, None)); -} - -/// Spec vector `error_v1_limit_reached`: the bytes the server must send, and what they decode to. -#[test] -fn error_v1_limit_reached_is_the_spec_vector() { - let vector = "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235"; - let message = CultNetMessage::ErrorV1 { - error: "limit_reached: the session's remembered-write limit is full".into(), - code: Some(CultNetErrorCode::LimitReached), - details: Some(CultNetErrorDetails::default()), - message_id: Some("put-1025".into()), - }; - let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!(bytes, unhex(vector), "encoded bytes must equal the spec vector"); - let CultNetMessage::ErrorV1 { error, code, details, message_id } = - decode_cultnet_message_from_slice(&unhex(vector), CultNetWireContract::CultNetSchemaV0).unwrap() - else { - panic!("expected error.v1"); - }; - assert_eq!(error, "limit_reached: the session's remembered-write limit is full"); - assert_eq!(code, Some(CultNetErrorCode::LimitReached)); - assert_eq!(message_id.as_deref(), Some("put-1025")); - assert_eq!(details, Some(CultNetErrorDetails::default())); -} - -/// Spec vectors `write_admitted_put` and `write_admitted_delete`: the encoder emits the spec's field order. -#[test] -fn write_admitted_encodes_to_the_spec_vector_bytes() { - let cases = [ - ("put-1", "put", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), - ("del-1", "delete", "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A564656C2D31A96F7065726174696F6EA664656C657465A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31"), - ]; - for (id, operation, vector) in cases { - let message = CultNetMessage::WriteAdmitted { - message_id: id.into(), - operation: operation.into(), - schema_id: "note.v0".into(), - record_key: "note:1".into(), - }; - let bytes = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(); - assert_eq!(bytes, unhex(vector)); - } -} - -/// `limit_reached` is in the error.v1 enum only: a v0 error neither encodes nor decodes it. -#[test] -fn error_v0_refuses_the_v1_only_limit_reached_code() { - let v0 = CultNetMessage::Error { - error: "x".into(), - code: Some(CultNetErrorCode::LimitReached), - details: None, - }; - assert!(encode_cultnet_message_to_vec(&v0, CultNetWireContract::CultNetSchemaV0).is_err()); - let v1 = CultNetMessage::ErrorV1 { - error: "x".into(), - code: Some(CultNetErrorCode::LimitReached), - details: None, - message_id: None, - }; - let v1_bytes = encode_cultnet_message_to_vec(&v1, CultNetWireContract::CultNetSchemaV0).unwrap(); - let at = v1_bytes.windows(16).position(|w| w == b"cultnet.error.v1").unwrap(); - let mut as_v0 = v1_bytes.clone(); - as_v0[at + 15] = b'0'; - assert!(decode_cultnet_message_from_slice(&as_v0, CultNetWireContract::CultNetSchemaV0).is_err()); -} diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index be6e2d98..a5b8bc4e 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -1,24 +1,18 @@ # 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 -- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`. -- `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 Idunn runtime presence publisher is send-only (`sendPresenceDocument`): it returns `"sent"` once Odin's transport has acknowledged the put and no longer waits up to 500 ms for a success reply, which was never admission. It rejects `IdunnPresenceRefusedError` for a `cultnet.error.v0`, or a `cultnet.error.v1` naming the put or nothing, that arrives by the ack; a refusal after the ack is not awaited, and no other frame, one carrying the put's id included, counts as success. -- 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. diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 07996956..acbf5a6a 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. @@ -80,7 +80,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 +111,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 +129,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 +160,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 +185,7 @@ cuts out the dumbest part of the trip. Apps sharing a CultNet space should be able to ask each other what they can 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 +232,7 @@ import { createTcpFramedCultNetPeer, defineCultNetDocumentBinding, ghostlightAgentStateGeneratedContract, -} from "@gamecult/cultnet-ts"; +} from "cultnet-ts"; const ghostlightStateDefinition = defineDocumentType({ type: "ghostlight.agent-state", @@ -270,7 +270,7 @@ That means a TypeScript runtime can follow the same cryptographic and session story as the C# version instead of inventing one more house religion around "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. @@ -289,33 +289,6 @@ maps them back to the exact C# union payload shape at the wire boundary. serve and dial commands also exercise schema-v0 over the shared RUDP socket transport and advertise both transport profiles. -## Write receipt replay runner - -`WriteTracker` is the client half of `contracts/cultnet/interop/write-receipt.md`: a pure state machine on an abstract -clock that owns one decision, how each sent write settles. `tools/replay-write-receipt-vectors.mjs` replays -`write-receipt-vectors.json` against it (build first, then `node tools/replay-write-receipt-vectors.mjs`): it prints -`PASS`/`FAIL` per case and exits nonzero on any failure. Rust and C# port the same runner by implementing this interface -and replaying the same file. - -Tracker interface (times are integer milliseconds on the vector's clock; the constructor takes the window): - -| Call | Meaning | -| --- | --- | -| `new WriteTracker({ windowMs, onSettle })` | `windowMs` = 5000 when the server's hello lists both `cultnet.write_admitted.v0` and `cultnet.error.v1`, else 500. `onSettle(id, settlement)` fires once per write. | -| `send(id, atMs)` | A write with that `messageId` was sent. Its window runs from here, not from any transport ack. | -| `frame(message, atMs)` | A decoded frame arrived. `write_admitted` and `error.v1` settle the write they name; `error.v0`, or `error.v1` with nil `messageId`, refuses a write only when exactly one is unanswered; an id not in flight settles nothing. | -| `tick(atMs)` | Advance the clock. Every unanswered write with `atMs - sentAt >= windowMs` settles `unconfirmed(window_elapsed)`. `send`, `frame` and `sessionEnd` tick first. | -| `sessionEnd(atMs)` | Every unanswered write settles `unconfirmed(session_ended)`. | -| `state(id)` | `pending`, or the settlement plus `recorded`: `admitted_late` / `refused_late`, set by the first answer that arrives after `unconfirmed`. A settlement never changes. | - -Runner, per case in `scenarios.cases` (the transport ack is not an input: delivery is not an answer): - -1. The fake server states its capability the only way a real one does, in a hello (`server: "receipt"` lists both frames, `"legacy"` lists none); the window comes from that hello and must equal `constants[case.window]`. -2. Events run in `atMs` order: `send` -> `send(id)`, `frame` -> encode the frame to MessagePack, decode and validate it as the client would, then `frame(message)`, `tick` -> `tick`, `sessionEnd` -> `sessionEnd`, `transportAck` -> nothing. -3. For each id in `expect`, the settlement given to the caller has the expected `outcome` (and `code` or `reason` when given), `state(id).recorded` equals the expected `recorded` (absent when absent), and nothing settled twice or outside `expect`. - -The runner also decodes each `wire[].messagePackHex` and compares the fields and the re-encoded bytes to the vector, and requires every `schemaInvalid[]` frame to be refused by the decoder. `dedupe.cases` and `contentHash` are server vectors and not replayed here. - ## Development ```bash 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 6e05c9a5..f3bfb2e8 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"; @@ -9,8 +8,6 @@ import registerSchema from "../contracts/cultnet/cultnet.register.schema.json"; import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json"; import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json"; import errorSchema from "../contracts/cultnet/cultnet.error.schema.json"; -import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json"; -import writeAdmittedSchema from "../contracts/cultnet/interop/cultnet.write-admitted.schema.json"; import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json"; import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json"; import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json"; @@ -64,8 +61,6 @@ export type CultNetSchemaVersion = | "cultnet.verify.v0" | "cultnet.login_success.v0" | "cultnet.error.v0" - | "cultnet.error.v1" - | "cultnet.write_admitted.v0" | "cultnet.sample.change_name.v0" | "cultnet.sample.chat.v0" | "cultnet.document_put.v0" @@ -200,49 +195,10 @@ export const CULTNET_ERROR_CODES = [ "unowned_schema", "write_not_authorized", "unsupported_schema_version", - "message_invalid", ] as const; export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number]; -/** The closed refusal-code set of cultnet.error.v1 (contracts/cultnet/interop/cultnet.error.v1.schema.json): v0's codes plus `limit_reached`. */ -export const CULTNET_ERROR_V1_CODES = [...CULTNET_ERROR_CODES, "limit_reached"] as const; - -export type CultNetErrorV1Code = (typeof CULTNET_ERROR_V1_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; @@ -264,34 +220,6 @@ export interface CultNetErrorMessage { details: CultNetErrorDetails | null; } -/** - * cultnet.error.v0 plus the `messageId` of the request it answers (nil when no id could be read). Sent only to a - * connection whose hello listed both receipt frames (contracts/cultnet/interop/write-receipt.md). - */ -export interface CultNetErrorV1Message extends Omit { - schemaVersion: "cultnet.error.v1"; - code: CultNetErrorV1Code | null; - messageId: string | null; -} - -/** The receipt for one admitted put or delete: every key written, the write's `messageId` echoed byte for byte. */ -export interface CultNetWriteAdmittedMessage { - schemaVersion: "cultnet.write_admitted.v0"; - messageId: string; - operation: "put" | "delete"; - schemaId: string; - recordKey: string; -} - -/** The two frames a receipt-capable hello lists in `supportedMessageVersions`; a peer lists both or neither. */ -export const CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS = ["cultnet.write_admitted.v0", "cultnet.error.v1"] as const; - -/** True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable. */ -export function helloAdvertisesWriteReceipts(hello: Pick): boolean { - const listed = hello.supportedMessageVersions ?? []; - return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.every((version) => listed.includes(version)); -} - export interface CultNetSampleChangeNameMessage { schemaVersion: "cultnet.sample.change_name.v0"; name: string; @@ -807,8 +735,6 @@ export interface GhostlightAgentStateDocument { export type CultNetMessage = | CultNetHelloMessage - | CultNetErrorV1Message - | CultNetWriteAdmittedMessage | CultNetLoginMessage | CultNetRegisterMessage | CultNetVerifyMessage @@ -851,8 +777,6 @@ const CULTNET_MESSAGE_SCHEMAS = [ verifySchema, loginSuccessSchema, errorSchema, - errorV1Schema, - writeAdmittedSchema, sampleChangeNameSchema, sampleChatSchema, documentPutSchema, @@ -1034,8 +958,14 @@ 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"), + // The legacy union ErrorMessage carries only Error; the R-N fields exist on cultnet.schema.v0 alone. + routingHint: null, + code: null, + details: null, + }; case 5: return { schemaVersion: "cultnet.sample.change_name.v0", @@ -1576,8 +1506,6 @@ export const cultNetSchemas = { verifySchema, loginSuccessSchema, errorSchema, - errorV1Schema, - writeAdmittedSchema, sampleChangeNameSchema, sampleChatSchema, documentPutSchema, 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 a5994f56..3713363d 100644 --- a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -52,7 +52,7 @@ export function createIdunnRuntimePresencePublisher( const publish = async (detail: string) => { const signed = signer.sign(detail); messageSequence += 1; - await sendPresenceDocument(endpoint, connectionId, { + await publishDocument(endpoint, connectionId, { schemaVersion: "cultnet.document_put_raw.v0", messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, document: signed.document, @@ -86,19 +86,11 @@ export function createIdunnRuntimePresencePublisher( }; } -/** - * Sends one presence put and says only that it was sent (write-receipt.md: the explicit send-only mode). Odin does not - * answer a write with a receipt yet, so no frame, whatever id it carries, is admission; this never claims it. It returns - * once the transport has acknowledged the put, rejects `IdunnPresenceRefusedError` if a refusal arrives by then, and - * rejects if the put is never acknowledged. - * - * @internal exported for tests - */ -export async function sendPresenceDocument( +async function publishDocument( endpoint: { host: string; port: number }, connectionId: number, message: Record, -): Promise<"sent"> { +): Promise { const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); await new Promise((resolve, reject) => { socket.once("error", reject); @@ -115,34 +107,26 @@ export async function sendPresenceDocument( 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 delivery, not admission. A refusal that arrives by then rejects; no other frame means anything. - 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); - // An error.v1 refuses this put only when it names it (or names nothing). - if (response.schemaVersion === "cultnet.error.v1" && (response.messageId === null || response.messageId === message.messageId)) { - throw new IdunnPresenceRefusedError(response); - } - }; - await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame); - return "sent"; + 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(); } } -/** 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; diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index a8740cae..5e654c28 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -17,15 +17,6 @@ export { type CultNetRawDocumentRecord, type CultNetRawPayloadEncoding, CULTNET_ERROR_CODES, - CULTNET_ERROR_V1_CODES, - CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, - helloAdvertisesWriteReceipts, - type CultNetErrorV1Code, - type CultNetErrorV1Message, - type CultNetWriteAdmittedMessage, - cultNetTextError, - cultNetMessageInvalidError, - cultNetWriteNotAuthorizedError, type CultNetErrorCode, type CultNetErrorDetails, type CultNetErrorMessage, @@ -255,15 +246,3 @@ export { type IdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisherOptions, } from "./idunn-odin-presence-publisher"; - -export { - LEGACY_RESULT_WINDOW_MS, - RECEIPT_RESULT_WINDOW_MS, - WriteTracker, - resultWindowMs, - type WriteRecorded, - type WriteSettlement, - type WriteState, - type WriteTrackerOptions, - type WriteUnconfirmedReason, -} from "./write-tracker"; 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/write-tracker.ts b/packages/cultnet-ts/src/write-tracker.ts deleted file mode 100644 index 2204d371..00000000 --- a/packages/cultnet-ts/src/write-tracker.ts +++ /dev/null @@ -1,144 +0,0 @@ -// The client half of the write receipt (contracts/cultnet/interop/write-receipt.md, "Client rules"): a pure state -// machine with an abstract clock. It owns exactly one decision, how each sent write settles. Transport, timers and -// frame decoding belong to the caller; the replay runner (tools/replay-write-receipt-vectors.mjs) drives this same -// interface from write-receipt-vectors.json. - -import type { CultNetErrorMessage, CultNetErrorV1Message, CultNetMessage } from "./contracts"; - -/** The result window against a server whose hello lists both receipt frames. */ -export const RECEIPT_RESULT_WINDOW_MS = 5000; -/** The result window against a server whose hello does not list them (or that sent none). */ -export const LEGACY_RESULT_WINDOW_MS = 500; - -export function resultWindowMs(serverAdvertisesReceipts: boolean): number { - return serverAdvertisesReceipts ? RECEIPT_RESULT_WINDOW_MS : LEGACY_RESULT_WINDOW_MS; -} - -/** - * Why nothing settled a write: its window ran out, the session ended first, or the client's own send of it failed (it may or - * may not have left the machine, and is never a refusal). - */ -export type WriteUnconfirmedReason = "window_elapsed" | "session_ended" | "local_send_failed"; -/** What a late answer is recorded as on a write that already settled unconfirmed. */ -export type WriteRecorded = "admitted_late" | "refused_late"; - -/** What the caller was told. Settled once; never changed by a later frame. */ -export type WriteSettlement = - | { outcome: "admitted" } - | { outcome: "refused"; code: string | null; error: string; response: CultNetErrorMessage | CultNetErrorV1Message } - | { outcome: "unconfirmed"; reason: WriteUnconfirmedReason }; - -/** A write's state: unanswered, or its settlement plus (after unconfirmed only) the late answer. */ -export type WriteState = - | { outcome: "pending"; recorded?: undefined } - | (WriteSettlement & { recorded?: WriteRecorded }); - -export interface WriteTrackerOptions { - /** The result window, from the send. Use `resultWindowMs(serverAdvertisesReceipts)`. */ - windowMs: number; - /** Called once per write, at the moment it settles. */ - onSettle?: (messageId: string, settlement: WriteSettlement) => void; -} - -interface Entry { - sentAtMs: number; - state: WriteState; -} - -export class WriteTracker { - readonly #windowMs: number; - readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void; - readonly #writes = new Map(); - - constructor(options: WriteTrackerOptions) { - this.#windowMs = options.windowMs; - this.#onSettle = options.onSettle; - } - - /** Registers a write sent at `atMs`. A messageId is minted once per session: re-sending a known id is the same write. */ - send(messageId: string, atMs: number): void { - this.tick(atMs); - if (!this.#writes.has(messageId)) this.#writes.set(messageId, { sentAtMs: atMs, state: { outcome: "pending" } }); - } - - /** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */ - frame(message: CultNetMessage, atMs: number): void { - this.tick(atMs); - switch (message.schemaVersion) { - case "cultnet.write_admitted.v0": - this.#answer(message.messageId, { outcome: "admitted" }, "admitted_late"); - return; - case "cultnet.error.v1": - if (message.messageId !== null) { - this.#answer(message.messageId, refusalOf(message), "refused_late"); - return; - } - break; - case "cultnet.error.v0": - break; - default: - return; - } - // An error that names no write is attributable only when exactly one write is unanswered. - const unanswered = [...this.#writes].filter(([, entry]) => entry.state.outcome === "pending"); - if (unanswered.length === 1) this.#settle(unanswered[0][0], unanswered[0][1], refusalOf(message)); - } - - /** Advances the clock: every unanswered write whose window, counted from its send, has run settles unconfirmed. */ - tick(atMs: number): void { - for (const [messageId, entry] of this.#writes) { - if (entry.state.outcome === "pending" && atMs - entry.sentAtMs >= this.#windowMs) { - this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "window_elapsed" }); - } - } - } - - /** The session ended: every unanswered write settles unconfirmed. */ - sessionEnd(atMs: number): void { - this.tick(atMs); - for (const [messageId, entry] of this.#writes) { - if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" }); - } - } - - /** This client's own send of `messageId` failed: that write settles unconfirmed; the others are untouched. */ - sendFailed(messageId: string, atMs: number): void { - const entry = this.#writes.get(messageId); - if (entry?.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "local_send_failed" }); - this.tick(atMs); - } - - state(messageId: string): WriteState | undefined { - return this.#writes.get(messageId)?.state; - } - - /** Milliseconds from `atMs` until the next unanswered write's window runs out; undefined when none is unanswered. */ - nextDeadlineMs(atMs: number): number | undefined { - let next: number | undefined; - for (const entry of this.#writes.values()) { - if (entry.state.outcome !== "pending") continue; - const remaining = Math.max(0, entry.sentAtMs + this.#windowMs - atMs); - if (next === undefined || remaining < next) next = remaining; - } - return next; - } - - #answer(messageId: string, settlement: WriteSettlement, late: WriteRecorded): void { - const entry = this.#writes.get(messageId); - if (!entry) return; // names a write that is not in flight: settles nothing - if (entry.state.outcome === "pending") { - this.#settle(messageId, entry, settlement); - } else if (entry.state.outcome === "unconfirmed" && entry.state.recorded === undefined) { - entry.state = { ...entry.state, recorded: late }; - } - } - - #settle(messageId: string, entry: Entry, settlement: WriteSettlement): void { - entry.state = settlement; - this.#onSettle?.(messageId, settlement); - } -} - -function refusalOf(message: CultNetErrorMessage | CultNetErrorV1Message): WriteSettlement { - return { outcome: "refused", code: message.code, error: message.error, response: message }; -} diff --git a/packages/cultnet-ts/test/cultnet-error.test.ts b/packages/cultnet-ts/test/cultnet-error.test.ts index 9526cc29..42ef0753 100644 --- a/packages/cultnet-ts/test/cultnet-error.test.ts +++ b/packages/cultnet-ts/test/cultnet-error.test.ts @@ -8,8 +8,6 @@ import { decode, encode } from "@msgpack/msgpack"; import { CULTNET_ERROR_CODES, cultNetSchemas, - cultNetMessageInvalidError, - cultNetWriteNotAuthorizedError, encodeCultNetMessageForWire, parseCultNetMessage, type CultNetErrorMessage, @@ -83,25 +81,3 @@ test("a code outside the closed enum is refused, as is a malformed details objec /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 index 977a6e5b..b389ca95 100644 --- a/packages/cultnet-ts/test/first-call.test.ts +++ b/packages/cultnet-ts/test/first-call.test.ts @@ -6,7 +6,7 @@ import test from "node:test"; // The TypeScript CultNet first-call snippet that docs/start/README.md shows: define a typed document, send it // 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. +// "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"; @@ -72,7 +72,7 @@ test("docs show the tested snippet", async () => { const snippet = source .split("// snippet:begin\n")[1] .split("// snippet:end\n")[0] - .replace('"../src"', '"@gamecult/cultnet-ts"'); + .replace('"../src"', '"cultnet-ts"'); const docs = (await readFile(join(__dirname, "..", "..", "..", "..", "docs", "start", "README.md"), "utf8")).replace(/\r\n/g, "\n"); 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-odin-presence-send-only.test.ts b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts deleted file mode 100644 index fff8e2d5..00000000 --- a/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -// The presence publisher is send-only (write-receipt.md): Odin answers no write with a receipt yet, so no frame, whatever -// id it carries, is admission. Each test serves it a fake Odin that acknowledges the put and answers as scripted. -import assert from "node:assert/strict"; -import { createSocket, type Socket } from "node:dgram"; -import test from "node:test"; -import { encode, decode } from "@msgpack/msgpack"; - -import { encodeCultNetMessageForWire, type CultNetMessage } from "../src/contracts"; -import { IdunnPresenceRefusedError, sendPresenceDocument } from "../src/idunn-odin-presence-publisher"; -import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp"; - -interface Script { - answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other" | "refuse-v1-same" | "refuse-v1-none"; - /** Acknowledge the put? Default true. */ - ack?: boolean; -} - -async function fakeOdin(connectionId: number, script: Script) { - 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 send = (packet: Parameters[0], port: number, address: string) => { - try { socket.send(encodeRudpPacket(packet), port, address); } catch { /* closed */ } - }; - const say = (message: CultNetMessage, port: number, address: string) => { - const packets = session.sendMany("schema", encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0")), { reliable: true, ordered: true, nowMs: Date.now() }); - packets.forEach((p) => send(p, port, address)); - }; - 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 put = decode(frame.payload) as { messageId: string; document: unknown }; - if (script.answer === "echo") say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: put.messageId, document: put.document } as unknown as CultNetMessage, remote.port, remote.address); - else if (script.answer === "admitted") say({ schemaVersion: "cultnet.write_admitted.v0", messageId: put.messageId, operation: "put", schemaId: "s", recordKey: "k" }, remote.port, remote.address); - else if (script.answer === "refuse-v0") say({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }, remote.port, remote.address); - else if (script.answer === "refuse-v1-other") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: "someone-else" }, remote.port, remote.address); - else if (script.answer === "refuse-v1-same") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: put.messageId }, remote.port, remote.address); - else if (script.answer === "refuse-v1-none") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: null }, remote.port, remote.address); - } - if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); - }); - return { port: socket.address().port, close: () => socket.close() }; -} - -let nextConnection = 0x0d1d1000; -const put = (id: string) => ({ schemaVersion: "cultnet.document_put_raw.v0", messageId: id, document: { schemaId: "s", recordKey: "k", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } }); -const sendTo = (server: { port: number }, id: string, connectionId: number) => - sendPresenceDocument({ host: "127.0.0.1", port: server.port }, connectionId, put(id)); - -for (const answer of ["silence", "echo", "admitted", "refuse-v1-other"] as const) { - test(`a ${answer} Odin yields sent, never admitted: no frame carrying the put's id is success`, async () => { - const connectionId = nextConnection++; - const server = await fakeOdin(connectionId, { answer }); - try { - const outcome = await sendTo(server, "presence-1", connectionId); - assert.equal(outcome, "sent"); - assert.notEqual(outcome as string, "admitted"); - } finally { server.close(); } - }); -} - -test("a refusal that arrives by the transport ack rejects the put", async () => { - const connectionId = nextConnection++; - const server = await fakeOdin(connectionId, { answer: "refuse-v0" }); - try { - await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError); - } finally { server.close(); } -}); - -for (const answer of ["refuse-v1-same", "refuse-v1-none"] as const) { - test(`an error.v1 refusal that ${answer === "refuse-v1-same" ? "names this put" : "names no put"} rejects the put`, async () => { - const connectionId = nextConnection++; - const server = await fakeOdin(connectionId, { answer }); - try { - await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError); - } finally { server.close(); } - }); -} - -test("a put the transport never acknowledges is an error, not sent", async () => { - const connectionId = nextConnection++; - const server = await fakeOdin(connectionId, { answer: "silence", ack: false }); - try { - await assert.rejects(sendTo(server, "presence-1", connectionId), /Timed out/); - } finally { server.close(); } -}); diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 50236782..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,28 +332,6 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte assert.equal(peer.failure, undefined, peer.failure?.message); }); -test("the Odin publisher rejects a presence whose refusal arrives before the transport ack", async (context) => { - const endpoint = expectedFacts("service").odinEndpoint!; - const peer = await startOdinPeer(context, endpoint); - peer.reject = true; - peer.order = "refusal-then-ack"; - 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 is send-only: a refusal that follows the transport ack is not awaited", async (context) => { - const endpoint = expectedFacts("service").odinEndpoint!; - const peer = await startOdinPeer(context, endpoint); - peer.reject = true; - peer.order = "ack-then-refusal"; - const signer = signerFor(context, "service"); - signer.reportHealth("active"); - const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - await publisher.publish("denied presence"); -}); - 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); @@ -496,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/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts deleted file mode 100644 index 83c31411..00000000 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { join } from "node:path"; - -import { - CULTNET_ERROR_CODES, - CULTNET_ERROR_V1_CODES, - CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, - LEGACY_RESULT_WINDOW_MS, - RECEIPT_RESULT_WINDOW_MS, - WriteTracker, - cultNetSchemas, - helloAdvertisesWriteReceipts, - parseCultNetMessage, - resultWindowMs, - type CultNetErrorV1Message, - type CultNetMessage, - type CultNetWriteAdmittedMessage, - type WriteSettlement, -} from "../src"; - -const admitted = (messageId: string): CultNetWriteAdmittedMessage => - ({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "note.v0", recordKey: "note:1" }); -const refusedV1 = (messageId: string | null, code: CultNetErrorV1Message["code"] = "write_not_authorized"): CultNetErrorV1Message => - ({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code, details: null, messageId }); -const errorV0 = (): CultNetMessage => - ({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }); - -test("error.v1 accepts every code of its closed enum, limit_reached included, and refuses any other", () => { - const schemaCodes = ((cultNetSchemas.errorV1Schema.properties.code as { enum: (string | null)[] }).enum).filter((c) => c !== null); - assert.deepEqual([...CULTNET_ERROR_V1_CODES].sort(), [...schemaCodes].sort()); - for (const code of CULTNET_ERROR_V1_CODES) parseCultNetMessage({ ...refusedV1("w1"), code }); - assert.throws(() => parseCultNetMessage({ ...refusedV1("w1"), code: "invented" })); -}); - -test("error.v1 echoes messageId byte for byte, allows nil, and requires the key", () => { - assert.equal((parseCultNetMessage(refusedV1("w-é 1")) as CultNetErrorV1Message).messageId, "w-é 1"); - assert.equal((parseCultNetMessage(refusedV1(null)) as CultNetErrorV1Message).messageId, null); - const { messageId: _omitted, ...withoutId } = refusedV1("w1"); - assert.throws(() => parseCultNetMessage(withoutId)); - assert.throws(() => parseCultNetMessage(refusedV1(""))); -}); - -test("error.v0 is unaffected: still closed, no messageId, no limit_reached", () => { - assert.deepEqual([...CULTNET_ERROR_CODES].includes("limit_reached" as never), false); - assert.throws(() => parseCultNetMessage({ ...errorV0(), code: "limit_reached" })); - assert.throws(() => parseCultNetMessage({ ...errorV0(), messageId: "w1" })); - assert.equal(parseCultNetMessage(errorV0()).schemaVersion, "cultnet.error.v0"); -}); - -test("write_admitted needs all five keys, a known operation and no error key", () => { - parseCultNetMessage(admitted("w1")); - parseCultNetMessage({ ...admitted("w1"), operation: "delete" }); - assert.throws(() => parseCultNetMessage({ ...admitted("w1"), operation: "upsert" })); - assert.throws(() => parseCultNetMessage({ ...admitted("w1"), error: "nope" })); - const { recordKey: _omitted, ...short } = admitted("w1"); - assert.throws(() => parseCultNetMessage(short)); -}); - -test("a hello is receipt-capable only when it lists both frames", () => { - assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }), true); - assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.write_admitted.v0"] }), false); - assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.error.v1"] }), false); - assert.equal(helloAdvertisesWriteReceipts({}), false); - const hello = parseCultNetMessage({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k", supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }); - assert.equal(hello.schemaVersion, "cultnet.hello.v0"); -}); - -test("the window is 5000 ms for a receipt-capable server and 500 ms otherwise", () => { - assert.equal(resultWindowMs(true), RECEIPT_RESULT_WINDOW_MS); - assert.equal(resultWindowMs(false), LEGACY_RESULT_WINDOW_MS); - assert.equal(RECEIPT_RESULT_WINDOW_MS, 5000); - assert.equal(LEGACY_RESULT_WINDOW_MS, 500); -}); - -function tracker(windowMs = 500) { - const told: [string, WriteSettlement][] = []; - return { told, t: new WriteTracker({ windowMs, onSettle: (id, s) => told.push([id, s]) }) }; -} - -test("tracker: the window is inclusive at exactly windowMs and open one ms before", () => { - const { t, told } = tracker(); - t.send("w1", 100); - t.tick(599); - assert.equal(t.state("w1")?.outcome, "pending"); - t.tick(600); - assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]); -}); - -test("tracker: each write's window runs from its own send", () => { - const { t } = tracker(); - t.send("w1", 0); - t.send("w2", 300); - t.tick(500); - assert.equal(t.state("w1")?.outcome, "unconfirmed"); - assert.equal(t.state("w2")?.outcome, "pending"); -}); - -test("tracker: a frame arriving after the window cannot admit the write; it is recorded late", () => { - const { t, told } = tracker(); - t.send("w1", 0); - t.frame(admitted("w1"), 500); - assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed", recorded: "admitted_late" }); - assert.equal(told.length, 1); -}); - -test("tracker: a write settles once; later frames neither change it nor flip a late record", () => { - const { t, told } = tracker(); - t.send("w1", 0); - t.frame(refusedV1("w1"), 10); - t.frame(admitted("w1"), 20); - t.frame(refusedV1("w1", "message_invalid"), 30); - t.sessionEnd(40); - assert.equal(told.length, 1); - assert.deepEqual(told[0][1], { outcome: "refused", code: "write_not_authorized", error: "no", response: refusedV1("w1") }); - t.send("w2", 100); - t.tick(600); - t.frame(refusedV1("w2"), 700); - t.frame(admitted("w2"), 800); - assert.equal(t.state("w2")?.outcome === "unconfirmed" && t.state("w2")?.recorded, "refused_late"); -}); - -test("tracker: an id-less error refuses the single unanswered write only, counting answered writes out", () => { - const { t } = tracker(); - t.send("w1", 0); - t.send("w2", 1); - t.frame(admitted("w1"), 5); - t.frame(refusedV1(null), 6); - assert.equal(t.state("w1")?.outcome, "admitted"); - assert.equal(t.state("w2")?.outcome, "refused"); -}); - -test("tracker: an id-less error never reaches a write that already ended unconfirmed", () => { - const { t } = tracker(); - t.send("w1", 0); - t.tick(500); - t.frame(errorV0(), 501); - assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); -}); - -test("tracker: sessionEnd settles unanswered writes only, and an expired window keeps its own reason", () => { - const { t } = tracker(); - t.send("w1", 0); - t.send("w2", 0); - t.send("w3", 400); - t.frame(admitted("w2"), 10); - t.sessionEnd(600); - assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); - assert.equal(t.state("w2")?.outcome, "admitted"); - assert.deepEqual(t.state("w3"), { outcome: "unconfirmed", reason: "session_ended" }); -}); - -test("tracker: re-sending a known id is the same write and does not restart its window", () => { - const { t } = tracker(); - t.send("w1", 0); - t.send("w1", 400); - t.tick(500); - assert.equal(t.state("w1")?.outcome, "unconfirmed"); -}); - -test("tracker: a send past another write's window settles that write at once", () => { - const { t, told } = tracker(); - t.send("w1", 0); - t.send("w2", 600); - assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]); -}); - -test("tracker: only receipt and error frames answer; any other frame naming the id settles nothing", () => { - const { t } = tracker(); - t.send("w1", 0); - t.frame({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k" }, 1); - t.frame({ schemaVersion: "cultnet.document_put_raw.v0", messageId: "w1" } as unknown as CultNetMessage, 2); - assert.equal(t.state("w1")?.outcome, "pending"); -}); - -test("tracker: nextDeadlineMs reports the nearest unanswered window, none when all settled", () => { - const { t } = tracker(); - assert.equal(t.nextDeadlineMs(0), undefined); - t.send("w1", 0); - t.send("w2", 100); - assert.equal(t.nextDeadlineMs(50), 450); - assert.equal(t.nextDeadlineMs(900), 0); - t.frame(admitted("w1"), 60); - assert.equal(t.nextDeadlineMs(150), 450); - t.frame(admitted("w2"), 160); - assert.equal(t.nextDeadlineMs(170), undefined); -}); - -test("the replay runner passes every vector against this runtime", () => { - const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs"); - const run = spawnSync(process.execPath, [script], { encoding: "utf8" }); - assert.equal(run.status, 0, run.stdout + run.stderr); - assert.match(run.stdout, /^PASS scenario\/receipt_admits$/m); - assert.doesNotMatch(run.stdout, /^FAIL/m); -}); - -test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => { - const settled: [string, WriteSettlement][] = []; - const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) }); - tracker.send("w1", 0); - tracker.send("w2", 0); - tracker.sendFailed("w1", 10); - assert.deepEqual(settled, [["w1", { outcome: "unconfirmed", reason: "local_send_failed" }]]); - assert.equal(tracker.state("w2")?.outcome, "pending"); - tracker.frame(admitted("w1"), 20); - assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed", recorded: "admitted_late" }); - tracker.sendFailed("w2", 30); - tracker.sendFailed("w2", 31); - assert.equal(settled.length, 2, "a write settles once"); - tracker.sendFailed("unknown", 40); - assert.equal(tracker.state("unknown"), undefined); -}); - -test("a failed local send advances the clock: other writes whose windows have run settle window_elapsed", () => { - const tracker = new WriteTracker({ windowMs: 1000 }); - tracker.send("w1", 0); - tracker.send("w2", 500); - tracker.sendFailed("w2", 1200); - assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); - assert.deepEqual(tracker.state("w2"), { outcome: "unconfirmed", reason: "local_send_failed" }); -}); - -test("a write whose own send failed settles local_send_failed even when its window has also run", () => { - const tracker = new WriteTracker({ windowMs: 1000 }); - tracker.send("w1", 0); - tracker.sendFailed("w1", 1000); - assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" }); -}); diff --git a/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs b/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs deleted file mode 100644 index 2c4bb403..00000000 --- a/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs +++ /dev/null @@ -1,116 +0,0 @@ -#!/usr/bin/env node -// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: every `scenarios.cases` event -// replay through a fake server and the WriteTracker, and every `wire` / `schemaInvalid` frame through the real -// decoder. Prints PASS/FAIL per case; exit 1 on any failure. Runs against the built package (`npm run build` first). -// -// node tools/replay-write-receipt-vectors.mjs [path/to/write-receipt-vectors.json] -// -// The runner interface a port (Rust, C#) must reproduce is documented in README.md, "Write receipt replay runner". - -import { readFileSync } from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { createRequire } from "node:module"; - -const require = createRequire(import.meta.url); -const here = path.dirname(fileURLToPath(import.meta.url)); -const { decode, encode } = require("@msgpack/msgpack"); -const { - CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, LEGACY_RESULT_WINDOW_MS, RECEIPT_RESULT_WINDOW_MS, WriteTracker, - encodeCultNetMessageForWire, helloAdvertisesWriteReceipts, parseCultNetMessage, resultWindowMs, -} = require(path.join(here, "..", "dist", "index.js")); - -const vectorsPath = process.argv[2] ?? path.join(here, "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json"); -const vectors = JSON.parse(readFileSync(vectorsPath, "utf8")); - -const results = []; -const check = (name, run) => { - try { - const problems = run(); - results.push({ name, problems }); - } catch (error) { - results.push({ name, problems: [`threw: ${error instanceof Error ? error.message : String(error)}`] }); - } -}; -const bytesOf = (hex) => Uint8Array.from(Buffer.from(hex, "hex")); -const hexOf = (bytes) => Buffer.from(bytes).toString("hex").toUpperCase(); -// A frame reaches the client as bytes: the fake server encodes it, the client decodes and validates it. -const delivered = (frame) => parseCultNetMessage(decode(encode(frame))); - -check("constants", () => [ - ...(vectors.constants.RECEIPT_RESULT_WINDOW_MS === RECEIPT_RESULT_WINDOW_MS ? [] : [`RECEIPT window ${RECEIPT_RESULT_WINDOW_MS} != ${vectors.constants.RECEIPT_RESULT_WINDOW_MS}`]), - ...(vectors.constants.LEGACY_RESULT_WINDOW_MS === LEGACY_RESULT_WINDOW_MS ? [] : [`LEGACY window ${LEGACY_RESULT_WINDOW_MS} != ${vectors.constants.LEGACY_RESULT_WINDOW_MS}`]), -]); - -for (const vector of vectors.wire) { - check(`wire/${vector.name}`, () => { - const problems = []; - const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); - if (JSON.stringify(sorted(parsed)) !== JSON.stringify(sorted(vector.message))) problems.push(`decoded ${JSON.stringify(parsed)} != ${JSON.stringify(vector.message)}`); - const reencoded = hexOf(encode(encodeCultNetMessageForWire(parsed))); - if (reencoded !== vector.messagePackHex.toUpperCase()) problems.push(`re-encoded bytes ${reencoded} != captured ${vector.messagePackHex}`); - return problems; - }); -} - -for (const vector of vectors.schemaInvalid) { - check(`schemaInvalid/${vector.name}`, () => { - try { - parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); - } catch { - return []; - } - return [`accepted a frame that must be refused (${vector.why})`]; - }); -} - -for (const scenario of vectors.scenarios.cases) { - check(`scenario/${scenario.name}`, () => { - // The fake server states its capability the only way a real one does: in its hello. - const serverHello = { schemaVersion: "cultnet.hello.v0", runtimeId: "fake-server", runtimeKind: "fake", - ...(scenario.server === "receipt" ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}) }; - const windowMs = resultWindowMs(helloAdvertisesWriteReceipts(serverHello)); - const problems = []; - if (windowMs !== vectors.constants[scenario.window]) problems.push(`window ${windowMs} != ${scenario.window}`); - - const told = new Map(); // what the caller was told, at settle time - const tracker = new WriteTracker({ windowMs, onSettle: (id, settlement) => { - if (told.has(id)) problems.push(`${id} settled twice`); - told.set(id, settlement); - } }); - for (const event of [...scenario.events].sort((a, b) => a.atMs - b.atMs)) { - if ("send" in event) tracker.send(event.send, event.atMs); - else if ("frame" in event) tracker.frame(delivered(event.frame), event.atMs); - else if ("tick" in event) tracker.tick(event.atMs); - else if ("sessionEnd" in event) tracker.sessionEnd(event.atMs); - // transportAck: delivery is not an answer; the tracker has no such input and must not need one. - } - for (const [id, want] of Object.entries(scenario.expect)) { - const got = told.get(id); - if (!got) { problems.push(`${id}: never settled, wanted ${want.outcome}`); continue; } - if (got.outcome !== want.outcome) problems.push(`${id}: told ${got.outcome}, wanted ${want.outcome}`); - if (want.code !== undefined && got.code !== want.code) problems.push(`${id}: code ${got.code}, wanted ${want.code}`); - if (want.reason !== undefined && got.reason !== want.reason) problems.push(`${id}: reason ${got.reason}, wanted ${want.reason}`); - const recorded = tracker.state(id)?.recorded; - if (recorded !== want.recorded) problems.push(`${id}: recorded ${recorded}, wanted ${want.recorded}`); - } - for (const id of told.keys()) if (!(id in scenario.expect)) problems.push(`${id}: settled but the scenario expects nothing for it`); - return problems; - }); -} - -function sorted(value) { - if (Array.isArray(value)) return value.map(sorted); - if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sorted(value[k])])); - return value; -} - -let failed = 0; -for (const { name, problems } of results) { - if (problems.length === 0) { console.log(`PASS ${name}`); continue; } - failed++; - console.log(`FAIL ${name}`); - for (const problem of problems) console.log(` ${problem}`); -} -console.log(`${results.length - failed}/${results.length} passed`); -process.exit(failed === 0 ? 0 : 1); 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..e825a0d4 100644 --- a/src/GameCult.Mesh/docs/browser-verse-transport.md +++ b/src/GameCult.Mesh/docs/browser-verse-transport.md @@ -19,14 +19,14 @@ standalone. 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 +112,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..49a23f79 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 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/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index af561ea5..d9e7ec09 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -1017,8 +1017,6 @@ namespace GameCult.Networking /// /// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's): /// it asks no remote write authority. A peer's put goes through , which does. - /// Returning normally means the put is committed and visible to readers: a receipt-capable peer is told - /// write_admitted on the strength of it, so a write that has only been queued must not return. /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. @@ -1049,7 +1047,6 @@ namespace GameCult.Networking /// /// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote /// write authority. A peer's delete goes through , which does. - /// Returning normally means the delete is committed and visible to readers (see ). /// /// No shard owns the schema and key; nothing was written. /// The change committed and was published, but the shard log could not record it. diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 1746391e..149b9619 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -22,7 +22,6 @@ namespace GameCult.Networking private readonly Func _snapshotHandlerV1; private readonly Func _putHandler; private readonly Func _deleteHandler; - private readonly Func _helloHandler; private readonly Func _shardCatalogHandler; private readonly Func _shardLogHandler; private readonly Func _subscribeHandler; @@ -46,7 +45,6 @@ namespace GameCult.Networking _snapshotHandlerV1 = HandleSnapshotRequestV1Async; _putHandler = HandlePutAsync; _deleteHandler = HandleDeleteAsync; - _helloHandler = HandleHelloAsync; _shardCatalogHandler = HandleShardCatalogRequestAsync; _shardLogHandler = HandleShardLogRequestAsync; _subscribeHandler = HandleSubscribeAsync; @@ -57,7 +55,6 @@ namespace GameCult.Networking _server.OnCultNet(_snapshotHandlerV1); _server.OnCultNet(_putHandler); _server.OnCultNet(_deleteHandler); - _server.OnCultNet(_helloHandler); _server.OnCultNet(_shardCatalogHandler); _server.OnCultNet(_shardLogHandler); _server.OnCultNet(_subscribeHandler); @@ -220,7 +217,6 @@ namespace GameCult.Networking _server.RemoveCultNetMessageListener(_snapshotHandlerV1); _server.RemoveCultNetMessageListener(_putHandler); _server.RemoveCultNetMessageListener(_deleteHandler); - _server.RemoveCultNetMessageListener(_helloHandler); _server.RemoveCultNetMessageListener(_shardCatalogHandler); _server.RemoveCultNetMessageListener(_shardLogHandler); _server.RemoveCultNetMessageListener(_subscribeHandler); @@ -266,7 +262,7 @@ namespace GameCult.Networking catch (Exception ex) { _server.Logger.LogError($"CultNet snapshot v1 request failed: {ex.Message}"); - peer.SendCultNet(new CultNetErrorMessage { Error = "the snapshot request could not be answered" }); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } return Task.CompletedTask; @@ -286,8 +282,7 @@ namespace GameCult.Networking } catch (Exception ex) { - _server.Logger.LogError($"CultNet shard log request failed: {ex.Message}"); - peer.SendCultNet(new CultNetErrorMessage { Error = "the shard log request could not be answered" }); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } return Task.CompletedTask; @@ -328,256 +323,74 @@ namespace GameCult.Networking }; } - // A hello decides the connection's write framing if it is the first thing to (write-receipt.md, "Capability"). The - // server answers a receipt-capable hello with its own, so the client can tell a receipt server from a legacy one. - private Task HandleHelloAsync(CultNetHelloMessage hello, CultNetServerPeer peer) - { - if (peer.Transport != null && peer.Transport.SettleWriteFraming(hello)) - { - Answer(peer, new CultNetHelloMessage - { - RuntimeId = "gamecult-networking-database-server", - RuntimeKind = "dotnet", - SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] - }); - } - - return Task.CompletedTask; - } - - // The write's receipt state, or null when its connection did not opt in (it is then answered exactly as before). - private CultNetReceiptedWrite? BeginReceipted( - CultNetServerPeer peer, - string operation, - string? messageId, - string? schemaId, - string? recordKey, - byte[]? payload) - { - var transport = peer.Transport; - if (transport == null) return null; - transport.SettleWriteFraming(null); - var session = transport.WriteReceipts; - return session == null - ? null - : new CultNetReceiptedWrite(session, message => Answer(peer, message), operation, messageId, schemaId, recordKey, payload); - } - - // Every frame a write can be answered with. Mock point for tests: the answer leaves through the peer's transport. An - // answer that cannot be queued ends the connection: the peer settles its write as session_ended, never as silence. - private void Answer(CultNetServerPeer peer, ICultNetSchemaMessage message) - { - try - { - Send(peer, message); - } - catch (Exception ex) - { - _server.Logger.LogWarning($"CultNet write answer could not be sent; ending the connection: {ex.Message}"); - peer.Transport?.End(); - } - } - - private static void Send(CultNetServerPeer peer, ICultNetSchemaMessage message) - { - switch (message) - { - case CultNetWriteAdmittedMessage admitted: peer.SendCultNet(admitted); break; - case CultNetErrorV1Message error: peer.SendCultNet(error); break; - case CultNetErrorMessage error: peer.SendCultNet(error); break; - case CultNetHelloMessage hello: peer.SendCultNet(hello); break; - default: throw new ArgumentException($"{message.GetType().Name} is not a write answer.", nameof(message)); - } - } - - // A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other. - // An empty or blank schemaId or recordKey names no record: refused as a frame that does not read, exactly as the Rust - // server's decoder refuses it (write-receipt.md, "Required fields"), never handed to the store. - private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) => - string.IsNullOrWhiteSpace(schemaId) || string.IsNullOrWhiteSpace(recordKey); - - private static CultNetErrorMessage UnreadableWriteTarget() => - CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"); - - private static void Refuse(CultNetServerPeer peer, CultNetReceiptedWrite? write, CultNetErrorMessage error) - { - if (write != null) write.Refused(error); - else peer.SendCultNet(error); - } - - // Rule 6: a forward is answered with what the primary answered, under the original id. A refusal the primary sent is - // relayed; silence or any other failure relays nothing, so the client settles it unconfirmed. - private void RelayForwardFailure(CultNetReceiptedWrite write, Exception failure) - { - if (failure is CultNetWriteRefusedException refused) - { - write.Refused(refused.Refusal); - return; - } - - _server.Logger.LogWarning($"CultNet forwarded write was not answered by its primary: {failure.Message}"); - // Only a forward that provably never left (not sent) may be forwarded again. Every other failure, silence or not, is - // unconfirmed: the primary may have applied it, so the id is never forwarded again on this session. - if (failure is CultNetWriteNotSentException) write.NotSent(); - else write.LeftSilent(); - } - private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer) { - var write = BeginReceipted(peer, "put", message.MessageId, message.Document?.SchemaId, message.Document?.RecordKey, message.Document?.Payload); - var idRefusal = write?.IdRefusal; - if (idRefusal != null) - { - write!.Refused(idRefusal); - return; - } - - var missing = message.Document == null ? "document" : message.Document.SchemaId == null ? "schemaId" : null; - if (missing != null) - { - Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid(missing, $"{missing} is required")); - return; - } - - if (IsUnreadableWriteTarget(message.Document!.SchemaId, message.Document.RecordKey)) - { - Refuse(peer, write, UnreadableWriteTarget()); - return; - } - - if (write != null && !write.Begin()) return; - - var authorize = Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch); try { - await _database.ApplyPutAsync(message, authorize).ConfigureAwait(false); - write?.Admitted(); + await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch)) + .ConfigureAwait(false); } catch (CultNetRemoteWriteRefusedException ex) { _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); - Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); + peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { - bool forwarded; - try - { - forwarded = await TryForwardPutAsync(ex, message).ConfigureAwait(false); - } - catch (CultNetWriteUnconfirmedException) when (write == null) - { - // A peer that did not opt in is told nothing of a forward its primary left unanswered. - return; - } - catch (Exception failure) when (write != null) - { - RelayForwardFailure(write, failure); - return; - } - - if (forwarded) - { - write?.Admitted(); - } - else + if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false)) { _server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}"); - Refuse(peer, write, CreateRoutingError(ex)); + peer.SendCultNet(CreateRoutingError(ex)); } } catch (CultNetUnownedSchemaException ex) { _server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}"); - Refuse(peer, write, CultNetErrorMessage.ForUnownedSchema(ex)); + peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex)); } catch (Exception ex) when (CommittedWithLogFailure(ex)) { // The write committed and was published; only its shard log failed, so the peer is not told it was refused. _server.Logger.LogWarning($"CultNet raw put committed but its shard log failed: {ex.Message}"); - write?.Admitted(); } catch (Exception ex) { _server.Logger.LogError($"CultNet raw put failed: {ex.Message}"); - Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText }); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } } private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, CultNetServerPeer peer) { - var write = BeginReceipted(peer, "delete", message.MessageId, message.SchemaId, message.RecordKey, null); - var idRefusal = write?.IdRefusal; - if (idRefusal != null) - { - write!.Refused(idRefusal); - return; - } - - if (message.SchemaId == null) - { - Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid("schemaId", "schemaId is required")); - return; - } - - if (IsUnreadableWriteTarget(message.SchemaId, message.RecordKey)) - { - Refuse(peer, write, UnreadableWriteTarget()); - return; - } - - if (write != null && !write.Begin()) return; - - var authorize = Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch); try { - await _database.ApplyDeleteAsync(message, authorize).ConfigureAwait(false); - write?.Admitted(); + await _database.ApplyDeleteAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch)) + .ConfigureAwait(false); } catch (CultNetRemoteWriteRefusedException ex) { _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); - Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); + peer.SendCultNet(CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason)); } catch (CultNetShardAuthorityException ex) { - bool forwarded; - try + if (!await TryForwardDeleteAsync(ex, message).ConfigureAwait(false)) { - forwarded = await TryForwardDeleteAsync(ex, message).ConfigureAwait(false); - } - catch (CultNetWriteUnconfirmedException) when (write == null) - { - return; - } - catch (Exception failure) when (write != null) - { - RelayForwardFailure(write, failure); - return; - } - - if (forwarded) - { - write?.Admitted(); - } - else - { - Refuse(peer, write, CreateRoutingError(ex)); + peer.SendCultNet(CreateRoutingError(ex)); } } catch (CultNetUnownedSchemaException ex) { _server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}"); - Refuse(peer, write, CultNetErrorMessage.ForUnownedSchema(ex)); + peer.SendCultNet(CultNetErrorMessage.ForUnownedSchema(ex)); } catch (Exception ex) when (CommittedWithLogFailure(ex)) { _server.Logger.LogWarning($"CultNet raw delete committed but its shard log failed: {ex.Message}"); - write?.Admitted(); } catch (Exception ex) { - Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText }); + peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message }); } } @@ -588,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; } @@ -630,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; } @@ -775,7 +588,7 @@ namespace GameCult.Networking var refusedKey = change.GetType().GetProperty("Key")?.GetValue(change) is CultRecordKey changed ? changed.Value : string.Empty; lock (refused) refused.Add(refusedKey); - sendError(new CultNetErrorMessage { Error = "the record is a variant CultNet cannot carry" }); + sendError(new CultNetErrorMessage { Error = refusal.Message }); return null; } @@ -853,14 +666,11 @@ namespace GameCult.Networking exception is CultNetShardLogException || exception is AggregateException aggregate && aggregate.Flatten().InnerExceptions.Any(inner => inner is CultNetShardLogException); - // Fixed text: the routing refusal never carries an exception's own message (the shard and reason travel in the hint). - internal const string RoutingRefusalText = "the write belongs to another shard primary"; - private static CultNetErrorMessage CreateRoutingError(CultNetShardAuthorityException exception) { return new CultNetErrorMessage { - Error = RoutingRefusalText, + Error = exception.Message, RoutingHint = new CultNetShardRoutingHint { Reason = exception.Reason, 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 79f0ce20..5c6117aa 100644 --- a/src/GameCult.Networking/CultNetSchemaClient.cs +++ b/src/GameCult.Networking/CultNetSchemaClient.cs @@ -26,9 +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 (). Throws only - /// when it can prove no byte was written; any other exception leaves the send ambiguous and a forwarded write unconfirmed. + /// Sends one schema-v0 message through the transport. /// void SendCultNet(T message) where T : ICultNetSchemaMessage; diff --git a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs index cdcca912..d098a923 100644 --- a/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs +++ b/src/GameCult.Networking/CultNetSchemaMessageSerialization.cs @@ -53,8 +53,6 @@ namespace GameCult.Networking CultNetSchemaVersions.Verify => Deserialize(payload), CultNetSchemaVersions.LoginSuccess => Deserialize(payload), CultNetSchemaVersions.Error => Deserialize(payload), - CultNetSchemaVersions.ErrorV1 => Deserialize(payload), - CultNetSchemaVersions.WriteAdmitted => Deserialize(payload), CultNetSchemaVersions.SampleChangeName => Deserialize(payload), CultNetSchemaVersions.SampleChat => Deserialize(payload), CultNetSchemaVersions.OperationRequest => Deserialize(payload), diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index 090c6a45..6b97c792 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -48,14 +48,6 @@ namespace GameCult.Networking /// public const string Error = "cultnet.error.v0"; /// - /// error v1 contract identifier: error v0 plus the id of the request it answers (write-receipt.md). - /// - public const string ErrorV1 = "cultnet.error.v1"; - /// - /// write admitted receipt contract identifier (write-receipt.md). - /// - public const string WriteAdmitted = "cultnet.write_admitted.v0"; - /// /// sample change name contract identifier. /// public const string SampleChangeName = "cultnet.sample.change_name.v0"; @@ -519,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, unsupported_schema_version. Additive to /// cultnet.error.v0; a v0 peer that does not know this field ignores it. /// [Key("code")] public string? Code { get; set; } @@ -546,7 +538,7 @@ namespace GameCult.Networking public static CultNetErrorMessage ForUnownedSchema(CultNetUnownedSchemaException ex) => new CultNetErrorMessage { - Error = "unowned_schema: no shard owns the schema and key", + Error = $"unowned_schema: {ex.Message}", Code = "unowned_schema", Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId } }; @@ -575,18 +567,6 @@ namespace GameCult.Networking 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. @@ -629,80 +609,6 @@ namespace GameCult.Networking }; } - /// - /// cultnet.error.v1 (contracts/cultnet/interop/write-receipt.md): the v0 error plus , - /// the id of the request it answers. Sent only to a connection that declared the write-receipt capability. - /// - [MessagePackObject] - public class CultNetErrorV1Message : ICultNetSchemaMessage - { - /// Gets or sets the schema version. - [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.ErrorV1; - /// Gets or sets the error. - [Key("error")] public string Error { get; set; } = string.Empty; - /// Gets or sets optional shard routing details for authority failures. - [Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; } - /// Gets or sets the machine-readable refusal code (the v0 enum). - [Key("code")] public string? Code { get; set; } - /// Gets or sets the code-specific structured detail. - [Key("details")] public CultNetErrorDetails? Details { get; set; } - /// Gets or sets the id of the request this answers; null only when no id could be read. - [Key("messageId")] public string? MessageId { get; set; } - - /// Builds the v1 form of answering . - public static CultNetErrorV1Message From(CultNetErrorMessage error, string? messageId) - { - if (error == null) throw new ArgumentNullException(nameof(error)); - return new CultNetErrorV1Message - { - Error = error.Error, - RoutingHint = error.RoutingHint, - Code = error.Code, - Details = error.Details, - MessageId = string.IsNullOrEmpty(messageId) ? null : messageId - }; - } - - /// - /// The refusal of a write a session has no room to remember (write-receipt.md, rule 5): code limit_reached, - /// details present and all nil. Exists only as error.v1; v0 has no such code. - /// - public static CultNetErrorV1Message LimitReached(string? messageId) => new CultNetErrorV1Message - { - Error = "limit_reached: the session's remembered-write limit is full", - Code = "limit_reached", - Details = new CultNetErrorDetails(), - MessageId = string.IsNullOrEmpty(messageId) ? null : messageId - }; - - /// Gets the v0 form, dropping the id. - public CultNetErrorMessage ToV0() => new CultNetErrorMessage - { - Error = Error, - RoutingHint = RoutingHint, - Code = Code, - Details = Details - }; - } - - /// - /// cultnet.write_admitted.v0: the receipt for one admitted put or delete, sent after its commit point. - /// - [MessagePackObject] - public class CultNetWriteAdmittedMessage : ICultNetSchemaMessage - { - /// Gets or sets the schema version. - [Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.WriteAdmitted; - /// Gets or sets the messageId of the admitted write, echoed. - [Key("messageId")] public string MessageId { get; set; } = string.Empty; - /// Gets or sets put or delete. - [Key("operation")] public string Operation { get; set; } = string.Empty; - /// Gets or sets the schemaId of the admitted write, echoed. - [Key("schemaId")] public string SchemaId { get; set; } = string.Empty; - /// Gets or sets the recordKey of the admitted write, echoed. - [Key("recordKey")] public string RecordKey { get; set; } = string.Empty; - } - /// /// Code-specific structured detail carried on (R-N). Fields are /// populated only for the code that defines them; every field is otherwise omitted (nil on the wire). diff --git a/src/GameCult.Networking/CultNetShardWriteForwarding.cs b/src/GameCult.Networking/CultNetShardWriteForwarding.cs index e4e66298..7038b0a7 100644 --- a/src/GameCult.Networking/CultNetShardWriteForwarding.cs +++ b/src/GameCult.Networking/CultNetShardWriteForwarding.cs @@ -10,16 +10,12 @@ namespace GameCult.Networking public interface ICultNetShardWriteForwarder { /// - /// Forwards a raw document put to the shard owner. Completes only when the owner answered that it admitted the write; - /// throws when it refused, when - /// nothing could have reached it (the only failure after which the id may be forwarded again), and any other exception - /// when the frame may have gone out and was not answered (unconfirmed, including ). The server relays to the original peer exactly what the owner answered. + /// Forwards a raw document put to the shard owner. /// Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message); /// - /// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as - /// does. + /// Forwards a raw document delete to the shard owner. /// Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message); } @@ -72,14 +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 the primary has to answer a forwarded write (from the send). Its receipt completes the forward, - /// its refusal fails it with , and silence fails it with - /// . Defaults to ; a - /// primary that predates write receipts never answers, so forwarding to one is unconfirmed after this window. - /// - public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow; } /// @@ -97,30 +85,24 @@ namespace GameCult.Networking _options = options ?? new CultNetSchemaWriteForwarderOptions(); } - /// - /// Forwards a put under its original message id and completes on the primary's result frame: a receipt returns, a refusal - /// throws and silence throws . - /// + /// public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); - if (shard == null) throw new ArgumentNullException(nameof(shard)); + var endpoint = ResolvePrimaryEndpoint(shard); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; - await SendAsync(shard, message, message.MessageId).ConfigureAwait(false); + await SendAsync(endpoint, message).ConfigureAwait(false); } - /// - /// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as - /// does. - /// + /// public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) { if (message == null) throw new ArgumentNullException(nameof(message)); - if (shard == null) throw new ArgumentNullException(nameof(shard)); + var endpoint = ResolvePrimaryEndpoint(shard); message.ShardId ??= shard.ShardId; message.ShardEpoch ??= shard.Epoch; - await SendAsync(shard, message, message.MessageId).ConfigureAwait(false); + await SendAsync(endpoint, message).ConfigureAwait(false); } /// @@ -144,51 +126,13 @@ namespace GameCult.Networking return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port); } - // The write frame has not been handed to the transport until this returns: any failure here is CultNetWriteNotSentException, - // the one failure the caller may retry under the same id. - private async Task ConnectBeforeSendAsync(CultNetShardDescriptor shard, string messageId) + private async Task SendAsync(string endpoint, T message) where T : ICultNetSchemaMessage { - ICultNetSchemaClient? client = null; - try - { - var endpoint = ResolvePrimaryEndpoint(shard); - var (host, port) = ParseEndpoint(endpoint); - client = CreateClient(endpoint); - client.Connect(host, port); - await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false); - // The hello comes first on the ordered schema channel: it opts this connection in to the primary's receipts. - client.SendCultNet(new CultNetHelloMessage - { - RuntimeId = "gamecult-networking-shard-forwarder", - RuntimeKind = "dotnet", - SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] - }); - return client; - } - catch (Exception failure) - { - client?.Dispose(); - if (failure is CultNetWriteNotSentException) throw; - throw new CultNetWriteNotSentException(messageId, failure); - } - } - - private async Task SendAsync(CultNetShardDescriptor shard, T message, string messageId) where T : ICultNetSchemaMessage - { - using var client = await ConnectBeforeSendAsync(shard, messageId).ConfigureAwait(false); - // From the write's send on, every failure (timeout, reset, silence, an exception from the send itself) propagates as it - // is: the frame may have gone out, so the caller treats it as unconfirmed. - await CultNetWriteCompletion.SendAndAwaitReceiptAsync( - (admitted, refuse, refuseV1) => - { - client.OnCultNet(admitted); - client.OnCultNet(refuse); - client.OnCultNet(refuseV1); - return null; - }, - () => client.SendCultNet(message), - messageId, - _options.ResultWindow).ConfigureAwait(false); + var (host, port) = ParseEndpoint(endpoint); + using var client = CreateClient(endpoint); + client.Connect(host, port); + await WaitForConnectionAsync(client, endpoint).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 fc41b2f4..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() @@ -3533,8 +3488,7 @@ namespace GameCult.Networking /// public sealed class LiteNetLibTransportConnection { - private readonly Action? _deliver; - private readonly Action? _end; + private readonly NetPeer? _peer; private readonly CultNetTransportStats _stats = new CultNetTransportStats(); /// @@ -3542,99 +3496,10 @@ namespace GameCult.Networking /// public LiteNetLibTransportConnection(NetPeer peer, CultNetTransportProfile? profile = null) { - if (peer == null) throw new ArgumentNullException(nameof(peer)); - _deliver = payload => peer.Send(payload, DeliveryMethod.ReliableOrdered); - _end = peer.Disconnect; + _peer = peer ?? throw new ArgumentNullException(nameof(peer)); Profile = profile ?? CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer"); } - // The mock point for what leaves the connection: the peer's reliable ordered send. - internal LiteNetLibTransportConnection(Action deliver, Action? end = null) - { - _deliver = deliver ?? throw new ArgumentNullException(nameof(deliver)); - _end = end; - Profile = CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer"); - } - - /// - /// Ends the connection. A write that can be neither answered nor refused is answered by this goodbye: the peer sees - /// its session end and settles the write as unconfirmed (session_ended), never as silence. - /// - internal void End() => _end?.Invoke(); - - /// - /// Answers a schema frame that did not decode with a message_invalid error.v1 naming its id when it holds one; - /// when that answer cannot be sent the connection ends instead. - /// - internal void AnswerUnreadableSchemaFrame(byte[] payload) - { - try - { - SendSchema(CultNetErrorV1Message.From( - CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"), - CultNetReadableMessageId.From(payload))); - } - catch (Exception) - { - End(); - } - } - - /// - /// A put or delete that arrived on an unreliable or sequenced channel of a receipt session (write-receipt.md, Reliable channel only): - /// answered message_invalid, details channel, fixed text, carrying the write's id, and not applied. True when - /// the frame was such a write and has been answered here. - /// - internal bool RefuseUnreliableWrite(ICultNetSchemaMessage message, DeliveryMethod method) - { - if (WriteReceipts == null) return false; - if (method is DeliveryMethod.ReliableOrdered or DeliveryMethod.ReliableUnordered) return false; - var messageId = message switch - { - CultNetDocumentPutRawMessage put => put.MessageId, - CultNetDocumentDeleteMessage delete => delete.MessageId, - _ => null - }; - if (messageId == null && message is not (CultNetDocumentPutRawMessage or CultNetDocumentDeleteMessage)) return false; - try - { - SendSchema(CultNetErrorV1Message.From( - CultNetErrorMessage.ForMessageInvalid("channel", "receipt sessions require the reliable channel"), messageId)); - } - catch (Exception) - { - End(); - } - - return true; - } - - private readonly object _writeFramingGate = new(); - private bool _writeFramingSettled; - - /// - /// The write-receipt state of a connection that opted in (write-receipt.md); null for every other connection, which is - /// answered exactly as before. - /// - internal CultNetWriteReceiptSession? WriteReceipts { get; private set; } - - /// - /// Decides this connection's write framing once: by its first hello, or by its first write if no hello came first. A - /// later hello changes nothing. The capability changes framing only; it never authorizes. Returns true when this call - /// opted the connection in. - /// - internal bool SettleWriteFraming(CultNetHelloMessage? hello) - { - lock (_writeFramingGate) - { - if (_writeFramingSettled) return false; - _writeFramingSettled = true; - if (hello == null || !CultNetWriteReceiptCapability.Declares(hello)) return false; - WriteReceipts = new CultNetWriteReceiptSession(); - return true; - } - } - /// /// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification. /// @@ -3670,14 +3535,14 @@ namespace GameCult.Networking public void Send(string channelId, byte[] payload) { if (payload == null) throw new ArgumentNullException(nameof(payload)); - if (_deliver == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport."); + if (_peer == null) throw new InvalidOperationException("A LiteNetLib peer is required to send through this transport."); if (!string.Equals(channelId, "schema", StringComparison.Ordinal) && !string.Equals(channelId, "legacy", StringComparison.Ordinal)) { throw new InvalidOperationException($"litenetlib transport only supports schema and legacy channels, got \"{channelId}\"."); } - _deliver(payload); + _peer.Send(payload, DeliveryMethod.ReliableOrdered); _stats.BytesSent += payload.Length; _stats.FramesSent++; } diff --git a/src/GameCult.Networking/CultNetWriteCompletion.cs b/src/GameCult.Networking/CultNetWriteCompletion.cs deleted file mode 100644 index 8767be6e..00000000 --- a/src/GameCult.Networking/CultNetWriteCompletion.cs +++ /dev/null @@ -1,141 +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; } - } - - /// - /// A write nothing answered within its window: neither admitted nor refused (write-receipt.md, "unconfirmed"). - /// - public sealed class CultNetWriteUnconfirmedException : Exception - { - /// Creates the exception for the write . - public CultNetWriteUnconfirmedException(string messageId) - : base($"No admission or refusal arrived for write '{messageId}'.") - { - MessageId = messageId; - } - - /// Gets the id of the unconfirmed write. - public string MessageId { get; } - } - - /// - /// A forwarded write that provably never left this process: the connection failed, or the send failed before any byte was - /// written. It is the only forwarding failure after which the same id may be forwarded again. A send port that cannot prove - /// nothing was written must not throw this; every other failure of a forward is unconfirmed. - /// - public sealed class CultNetWriteNotSentException : Exception - { - /// Creates the exception for the write ; is for logs only. - public CultNetWriteNotSentException(string messageId, Exception? cause = null) - : base($"Write '{messageId}' was not sent.", cause) - { - MessageId = messageId; - } - - /// Gets the id of the write that was not sent. - public string MessageId { 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); - - /// How long a receipt-capable peer has to answer a write (write-receipt.md, RECEIPT_RESULT_WINDOW_MS). - public static readonly TimeSpan ReceiptResultWindow = TimeSpan.FromMilliseconds(5000); - - /// - /// Sends one write to a receipt-capable peer and waits for its answer: a - /// write_admitted naming returns, an error.v1 naming it throws - /// , and an id-less error (v0, or v1 with no id) is attributed to this write - /// because it is the only one in flight on the connection. Silence throws . - /// - public static async Task SendAndAwaitReceiptAsync( - Func, Action, Action, IDisposable?> subscribe, - Action send, - string messageId, - TimeSpan resultWindow, - CancellationToken cancellationToken = default) - { - if (subscribe == null) throw new ArgumentNullException(nameof(subscribe)); - if (send == null) throw new ArgumentNullException(nameof(send)); - var answer = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var registration = subscribe( - admitted => - { - if (string.Equals(admitted.MessageId, messageId, StringComparison.Ordinal)) answer.TrySetResult(null); - }, - error => answer.TrySetResult(error), - error => - { - if (error.MessageId == null || string.Equals(error.MessageId, messageId, StringComparison.Ordinal)) - answer.TrySetResult(error.ToV0()); - }); - send(); - var window = Task.Delay(resultWindow, cancellationToken); - await Task.WhenAny(answer.Task, window).ConfigureAwait(false); - if (!answer.Task.IsCompleted) - { - await window.ConfigureAwait(false); - throw new CultNetWriteUnconfirmedException(messageId); - } - - var refusal = await answer.Task.ConfigureAwait(false); - if (refusal != null) throw new CultNetWriteRefusedException(refusal); - } - - /// - /// 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/CultNetWriteReceipts.cs b/src/GameCult.Networking/CultNetWriteReceipts.cs deleted file mode 100644 index 2a90f35d..00000000 --- a/src/GameCult.Networking/CultNetWriteReceipts.cs +++ /dev/null @@ -1,396 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Security.Cryptography; -using GameCult.Caching; -using MessagePack; - -namespace GameCult.Networking -{ - /// - /// The content hash a write-receipt server binds to a (peer, messageId) dedupe key - /// (contracts/cultnet/interop/write-receipt.md, rule 5). - /// - public static class CultNetWriteContentHash - { - /// - /// SHA-256 of the MessagePack array [operation, schemaId, recordKey, payload], payload a bin - /// for a put and nil for a delete. - /// - public static byte[] Compute(string operation, string schemaId, string recordKey, byte[]? payload) - { - var buffer = new System.Buffers.ArrayBufferWriter(); - var writer = new MessagePackWriter(buffer); - writer.WriteArrayHeader(4); - writer.Write(operation); - writer.Write(schemaId); - writer.Write(recordKey); - if (payload == null) writer.WriteNil(); - else writer.Write(payload); - writer.Flush(); - using var sha = SHA256.Create(); - return sha.ComputeHash(buffer.WrittenSpan.ToArray()); - } - } - - /// - /// Whether a peer's hello opted in to write receipts: it lists both cultnet.write_admitted.v0 and - /// cultnet.error.v1. The capability changes framing only; it never authorizes. - /// - internal static class CultNetWriteReceiptCapability - { - public static bool Declares(CultNetHelloMessage hello) - { - var versions = hello.SupportedMessageVersions; - return versions != null - && Array.IndexOf(versions, CultNetSchemaVersions.WriteAdmitted) >= 0 - && Array.IndexOf(versions, CultNetSchemaVersions.ErrorV1) >= 0; - } - } - - /// A SHA-256 digest held by value: the fixed-size stand-in for an id or a content hash. - internal readonly struct CultNetDigest : IEquatable - { - private readonly ulong _a, _b, _c, _d; - - private CultNetDigest(ulong a, ulong b, ulong c, ulong d) { _a = a; _b = b; _c = c; _d = d; } - - public static CultNetDigest Of(byte[] bytes) - { - using var sha = SHA256.Create(); - var hash = sha.ComputeHash(bytes); - return new CultNetDigest( - BitConverter.ToUInt64(hash, 0), BitConverter.ToUInt64(hash, 8), - BitConverter.ToUInt64(hash, 16), BitConverter.ToUInt64(hash, 24)); - } - - public bool Equals(CultNetDigest other) => _a == other._a && _b == other._b && _c == other._c && _d == other._d; - public override bool Equals(object? obj) => obj is CultNetDigest other && Equals(other); - public override int GetHashCode() => unchecked((int)_a ^ (int)(_a >> 32)); - public static bool operator ==(CultNetDigest left, CultNetDigest right) => left.Equals(right); - public static bool operator !=(CultNetDigest left, CultNetDigest right) => !left.Equals(right); - } - - /// - /// What a session keeps of a refusal (write-receipt.md, What is remembered): its code, the field its details name, and its - /// routing hint (a shard descriptor the server owns). Never the write's strings or an authority's reason: the replay is built - /// from fixed text and the fields of the resend, which carries the same content. - /// - internal readonly struct CultNetRememberedRefusal - { - public string? Code { get; } - public bool HasDetails { get; } - public string? Field { get; } - public CultNetShardRoutingHint? Hint { get; } - - public CultNetRememberedRefusal(string? code, bool hasDetails, string? field, CultNetShardRoutingHint? hint) - { - Code = code; HasDetails = hasDetails; Field = field; Hint = hint; - } - - public static CultNetRememberedRefusal Of(CultNetErrorMessage error) => - new(error.Code, error.Details != null, error.Details?.Field, error.RoutingHint); - - private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value; - - /// The refusal, in fixed text, naming the write's own record key or schema id where its details do. - public CultNetErrorMessage ToError(string schemaId, string recordKey) => new() - { - Error = Code switch - { - "unowned_schema" => "unowned_schema: no shard owns the schema and key", - null => Hint != null ? CultNetDatabaseServer.RoutingRefusalText : CultNetWriteReceiptSession.WriteFailedText, - _ => Code - }, - Code = Code, - RoutingHint = Hint, - Details = HasDetails - ? new CultNetErrorDetails - { - Field = Field, - Value = Field switch { "recordKey" => NullIfEmpty(recordKey), "schemaId" => NullIfEmpty(schemaId), _ => null } - } - : null - }; - } - - /// - /// One receipt-capable peer's writes: message id to content hash and the answer given, refusals included, so a resend of the - /// same content is answered again without being decided again and the same id with other content is refused. Ids and content - /// hashes are held as fixed-size digests; the answer is held as its kind (and, for a refusal, its code and the field its details name), - /// never the strings of the write. - /// - internal sealed class CultNetWriteReceiptSession - { - /// The most writes a peer is remembered for, refused ones included; a peer at the limit has new writes refused. - internal const int MaxRememberedWrites = 1024; - - /// The most ids refused at the limit that are remembered, so their resend is judged the same way. - internal const int MaxRememberedLimitRefusals = 1024; - - /// The longest messageId a write may carry, in UTF-8 bytes (write-receipt.md, Id size). - internal const int MaxMessageIdBytes = 256; - - /// What a write is refused with when it failed for a reason no typed refusal names. Never an exception's own text. - internal const string WriteFailedText = "the write could not be applied"; - - private readonly object _gate = new(); - private readonly Dictionary _writes = new(); - private readonly Dictionary _limitRefusals = new(); - - private sealed class Entry - { - public Entry(CultNetDigest hash) { Hash = hash; } - public CultNetDigest Hash { get; set; } - public bool Abandoned { get; set; } - public bool Admitted { get; set; } - public CultNetRememberedRefusal? Refusal { get; set; } - } - - public enum Begin { New, ReplayAdmitted, ReplayRefusal, Unanswered, DifferentContent, Full } - - private static CultNetDigest IdDigest(string messageId) => CultNetDigest.Of(System.Text.Encoding.UTF8.GetBytes(messageId)); - - /// - /// Looks up first. An id not seen is claimed for this content (); - /// otherwise the outcome says what already holds it, and is the refusal to repeat. - /// - public Begin TryBegin(string messageId, byte[] hash, out CultNetRememberedRefusal? refusal) - { - var id = IdDigest(messageId); - var content = CultNetDigest.Of(hash); - lock (_gate) - { - refusal = null; - if (_writes.TryGetValue(id, out var entry)) - { - if (entry.Abandoned) - { - // Not sent, so judged afresh in the slot it already holds; no slot is ever freed. - entry.Abandoned = false; - entry.Hash = content; - return Begin.New; - } - - if (entry.Hash != content) return Begin.DifferentContent; - if (entry.Admitted) return Begin.ReplayAdmitted; - if (entry.Refusal != null) - { - refusal = entry.Refusal; - return Begin.ReplayRefusal; - } - - // No answer yet: the first attempt is still being applied, or its primary left it unanswered for good. - return Begin.Unanswered; - } - - if (_writes.Count >= MaxRememberedWrites) - { - if (_limitRefusals.TryGetValue(id, out var refused)) return refused == content ? Begin.Full : Begin.DifferentContent; - if (_limitRefusals.Count < MaxRememberedLimitRefusals) _limitRefusals[id] = content; - return Begin.Full; - } - - _writes[id] = new Entry(content); - return Begin.New; - } - } - - /// The write was admitted: remember it so a resend repeats the receipt. - public void Admitted(string messageId) - { - lock (_gate) - { - if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Admitted = true; - } - } - - /// The write was refused: remember the refusal so a resend repeats it. - public void Refused(string messageId, CultNetRememberedRefusal refusal) - { - lock (_gate) - { - if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Refusal = refusal; - } - } - - /// - /// The write never reached anyone who could have applied it: a repeat is judged afresh. The entry keeps its slot, so - /// forgetting a write never makes room for an id that was refused at the limit. - /// - public void Abandoned(string messageId) - { - lock (_gate) - { - if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Abandoned = true; - } - } - } - - /// - /// The messageId of a schema frame that does not decode (write-receipt.md, rule 3: a refusal carries the write's id whenever - /// the frame is readable enough to find it). - /// - internal static class CultNetReadableMessageId - { - /// The frame's top-level string messageId, or null when the bytes are not a map holding one. - public static string? From(byte[] payload) - { - try - { - var reader = new MessagePackReader(payload); - var entries = reader.ReadMapHeader(); - for (var index = 0; index < entries; index++) - { - var key = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null; - if (key == "messageId" && reader.NextMessagePackType == MessagePackType.String) - { - var id = reader.ReadString(); - return string.IsNullOrEmpty(id) ? null : id; - } - - if (key == null) reader.Skip(); - reader.Skip(); - } - } - catch (Exception) - { - // Not a readable map: no id. - } - - return null; - } - } - - /// - /// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5). - /// - internal sealed class CultNetReceiptedWrite - { - private readonly CultNetWriteReceiptSession _session; - private readonly Action _send; - private readonly string _operation; - private readonly string _messageId; - private readonly string _schemaId; - private readonly string _recordKey; - private readonly byte[]? _payload; - private bool _claimed; - private bool _answered; - - public CultNetReceiptedWrite( - CultNetWriteReceiptSession session, - Action send, - string operation, - string? messageId, - string? schemaId, - string? recordKey, - byte[]? payload) - { - _session = session; - _send = send; - _operation = operation; - _messageId = messageId ?? string.Empty; - _schemaId = schemaId ?? string.Empty; - _recordKey = recordKey ?? string.Empty; - _payload = payload; - } - - /// - /// Looks the write's id up before anything else is decided (rule 5). True when the id is new and the write should go on - /// to authorization and the commit; false when it was answered here (a replay, a reused id, a full table) or is owed no - /// further answer (still in flight, or left silent by its primary). - /// - public bool Begin() - { - var outcome = _session.TryBegin(_messageId, CultNetWriteContentHash.Compute(_operation, _schemaId, _recordKey, _payload), out var refusal); - switch (outcome) - { - case CultNetWriteReceiptSession.Begin.New: - _claimed = true; - return true; - case CultNetWriteReceiptSession.Begin.ReplayAdmitted: - _answered = true; - _send(AdmittedReceipt()); - return false; - case CultNetWriteReceiptSession.Begin.ReplayRefusal: - _answered = true; - _send(CultNetErrorV1Message.From(refusal!.Value.ToError(_schemaId, _recordKey), _messageId)); - return false; - case CultNetWriteReceiptSession.Begin.Unanswered: - // The first attempt is still being applied and will answer this id, or its primary left it unanswered: - // then the claim stays, so the id is never sent again on this session, and the write stays unconfirmed. - _answered = true; - return false; - case CultNetWriteReceiptSession.Begin.DifferentContent: - Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "the message id was already used for different content")); - return false; - default: - _answered = true; - _send(CultNetErrorV1Message.LimitReached(_messageId)); - return false; - } - } - - private CultNetWriteAdmittedMessage AdmittedReceipt() => new() - { - MessageId = _messageId, - Operation = _operation, - SchemaId = _schemaId, - RecordKey = _recordKey - }; - - /// The write committed: remember the receipt and send it. - public void Admitted() - { - if (_answered) return; - _answered = true; - if (_claimed) _session.Admitted(_messageId); - _send(AdmittedReceipt()); - } - - /// The write was refused: remember the refusal (a resend repeats it) and send its one error.v1. - public void Refused(CultNetErrorMessage error) - { - if (_answered) return; - _answered = true; - if (_claimed) - { - // A remembered refusal is answered from what is remembered, so the replay is the refusal again. - var kept = CultNetRememberedRefusal.Of(error); - _session.Refused(_messageId, kept); - error = kept.ToError(_schemaId, _recordKey); - } - - _send(CultNetErrorV1Message.From(error, _messageId)); - } - - /// - /// A forward that may have reached its primary and is unconfirmed (silence, reset, timeout, an ambiguous send): no answer is - /// owed or possible, and the claim stays so the id is never forwarded again on this session. Sends nothing. - /// - public void LeftSilent() - { - if (_answered) return; - _answered = true; - } - - /// A forward that was provably not sent (connect failed, or the send failed before any byte was written): forget the claim, send nothing. - public void NotSent() - { - if (_answered) return; - _answered = true; - if (_claimed) _session.Abandoned(_messageId); - _claimed = false; - } - - /// - /// Why the write's id cannot be used at all, before anything is remembered: it is missing (refused with no id) or longer - /// than UTF-8 bytes. Null when the id is usable. - /// - public CultNetErrorMessage? IdRefusal => - _messageId.Length == 0 - ? CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId") - : System.Text.Encoding.UTF8.GetByteCount(_messageId) > CultNetWriteReceiptSession.MaxMessageIdBytes - ? CultNetErrorMessage.ForMessageInvalid("messageId", "messageId exceeds 256 bytes") - : null; - } -} 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/src/GameCult.Networking/Server.cs b/src/GameCult.Networking/Server.cs index 08af0cc4..128eb47d 100644 --- a/src/GameCult.Networking/Server.cs +++ b/src/GameCult.Networking/Server.cs @@ -23,7 +23,7 @@ namespace GameCult.Networking private const string EmailPattern = @"^([0-9a-zA-Z]([\+\-_\.][0-9a-zA-Z]+)*)+@(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]*\.)+[a-zA-Z0-9]{2,17})$"; private const string UsernamePattern = @"^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$"; - private const int DefaultPort = 3075; + private const int ServerPort = 3075; private const float SessionTimeoutSeconds = 1800; // 30 minutes private const float SessionRefreshThresholdSeconds = 300; // 5 minutes private const int MaxConnectionAttemptsPerMinute = 30; @@ -63,12 +63,6 @@ namespace GameCult.Networking /// public bool LogSensitivePayloads { get; set; } - /// - /// Gets or sets the UDP port binds (default 3075; 0 asks the OS for a free one). After - /// it holds the port actually bound. - /// - public int Port { get; set; } = DefaultPort; - private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d); /// @@ -80,7 +74,7 @@ namespace GameCult.Networking { TransportId = "litenetlib-server", Host = "0.0.0.0", - Port = Port + Port = ServerPort }); /// @@ -363,8 +357,7 @@ namespace GameCult.Networking UnsyncedEvents = true, NatPunchEnabled = true }; - _netManager.Start(Port); - Port = _netManager.LocalPort; + _netManager.Start(ServerPort); listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}"); @@ -415,25 +408,11 @@ namespace GameCult.Networking { var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame); Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}"); - if (transport.RefuseUnreliableWrite(cultNetMessage, method)) return; await HandleCultNetSchemaMessageAsync(peer, user, cultNetMessage).ConfigureAwait(false); return; } - Message message; - try - { - message = LiteNetLibTransportConnection.DecodeLegacy(frame); - } - catch (Exception ex) when (transport.WriteReceipts != null) - { - // A frame that decodes on neither channel is classed legacy, so this is where an unreadable frame lands. - // A receipt-capable peer is told which frame was unreadable (by its id, if the frame still holds one). - Logger.LogWarning($"CultNet frame unreadable: {ex.Message}"); - transport.AnswerUnreadableSchemaFrame(frame.Payload); - return; - } - + var message = LiteNetLibTransportConnection.DecodeLegacy(frame); if (LogSensitivePayloads) { Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(frame.Payload))}"); @@ -528,7 +507,7 @@ namespace GameCult.Networking } }); - Logger.LogInfo($"Server started on port {Port}."); + Logger.LogInfo($"Server started on port {ServerPort}."); } private async Task HandleCultNetSchemaMessageAsync(NetPeer peer, User user, ICultNetSchemaMessage message) 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/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/CultNetDocumentRegistryVariantTests.cs b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs index 5713b0da..64de1093 100644 --- a/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs @@ -418,7 +418,6 @@ namespace GameCult.Networking.Tests CultNetDatabaseChangeKind.Added, VariantKey, schemaId, database.Shards[0], cache.Get(VariantKey)!, previousDocument: null); Assert.That(Deliver(databaseServer, added, selection, refused, errors), Is.Null); Assert.That(errors, Has.Count.EqualTo(1)); - Assert.That(errors[0].Error, Is.EqualTo("the record is a variant CultNet cannot carry")); var removed = new CultNetDatabaseChange( CultNetDatabaseChangeKind.Removed, VariantKey, schemaId, database.Shards[0], document: null, previousDocument: cache.Get(VariantKey)!); diff --git a/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs b/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs index 2bb59952..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,205 +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)); } - // The RUDP schema server implements no receipt rules, so it must not advertise them (write-receipt.md, Capability and the - // conformance table): whatever hello it sends lists neither frame. - [Test] - public async Task TheRudpSchemaServerNeverAdvertisesTheReceiptFrames() - { - using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions - { - RuntimeId = "csharp-rudp-no-receipts", - ConnectionId = ConnectionId - }); - var client = await Connect(server); - try - { - SendSchema(server, client, CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage - { - RuntimeId = "receipt-client", - RuntimeKind = "test", - SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] - })); - await server.PollAvailableAsync(16); - - var hellos = new List(); - foreach (var packet in Drain(client.Socket)) - foreach (var frame in client.Session.Receive(packet, 1).Delivered) - if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetHelloMessage hello) - hellos.Add(hello); - - foreach (var hello in hellos) - { - var listed = hello.SupportedMessageVersions ?? Array.Empty(); - Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.WriteAdmitted)); - Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.ErrorV1)); - } - } - finally - { - client.Socket.Dispose(); - } - } - - 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 @@ -381,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..23bb5f5b 100644 --- a/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSchemaContractTests.cs @@ -233,19 +233,6 @@ namespace GameCult.Networking.Tests 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/CultNetWriteReceiptLoopbackTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptLoopbackTests.cs deleted file mode 100644 index d5e32fa0..00000000 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptLoopbackTests.cs +++ /dev/null @@ -1,290 +0,0 @@ -#nullable enable -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using GameCult.Caching; -using LiteNetLib; -using NUnit.Framework; -using static GameCult.Networking.Tests.NetworkingTests; - -namespace GameCult.Networking.Tests -{ - // The receipt server on a real socket: Server.Start on an ephemeral loopback port and real LiteNetLib clients. The - // handler-level tests (CultNetWriteReceiptServerTests) hand frames to the handlers; only a running server decides what - // a frame that does not decode is answered with. - public sealed class CultNetWriteReceiptLoopbackTests - { - private static readonly TimeSpan Cap = TimeSpan.FromSeconds(10); - private static readonly CultRecordKey KeyA = new("tests:loopback:a"); - private static readonly CultRecordKey KeyB = new("tests:loopback:b"); - private const string ShardId = "loopback-shard"; - - private sealed class Client : IDisposable - { - private readonly NetManager _manager; - private readonly NetPeer _peer; - private readonly BlockingCollection _received = new(); - public int Closed; - - public Client(int port, string key) - { - var listener = new EventBasedNetListener(); - var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var decoder = new LiteNetLibTransportConnection(); - listener.PeerConnectedEvent += _ => connected.TrySetResult(true); - listener.PeerDisconnectedEvent += (_, _) => Interlocked.Increment(ref Closed); - listener.NetworkReceiveEvent += (_, reader, _, _) => - _received.Add(LiteNetLibTransportConnection.DecodeSchema(decoder.Receive(reader.GetRemainingBytes()))); - _manager = new NetManager(listener) { UnsyncedEvents = true }; - _manager.Start(0); - _peer = _manager.Connect("127.0.0.1", port, key); - if (!connected.Task.Wait(Cap)) Assert.Fail("the client did not connect"); - } - - public void Send(T message) where T : ICultNetSchemaMessage => - _peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered); - - public void SendUnreliable(T message) where T : ICultNetSchemaMessage => - _peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.Unreliable); - - public void Send(T message, DeliveryMethod method) where T : ICultNetSchemaMessage => - _peer.Send(CultNetSchemaMessageSerialization.Serialize(message), method); - - public void SendRaw(byte[] bytes) => _peer.Send(bytes, DeliveryMethod.ReliableOrdered); - - public T Next() where T : ICultNetSchemaMessage - { - if (!_received.TryTake(out var message, Cap)) Assert.Fail($"no {typeof(T).Name} arrived within {Cap}"); - Assert.That(message, Is.InstanceOf()); - return (T)message!; - } - - public void ExpectNothingMore(string because) - { - // A reliable ordered channel: anything the server sent before the probe's answer has already arrived. - Assert.That(_received.Count, Is.Zero, because); - } - - public void Dispose() - { - _manager.Stop(); - _received.Dispose(); - } - } - - private sealed class Authority : ICultNetRemoteWriteAuthority - { - public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) => - request.RecordKey.Equals(KeyB) ? CultNetRemoteWriteDecision.Refuse("denied") : CultNetRemoteWriteDecision.Grant(); - } - - private sealed class Running : IDisposable - { - public Server Server = null!; - public CultCache Cache = null!; - public CultNetDatabase Database = null!; - public string SchemaId = string.Empty; - private CultNetDatabaseServer _databaseServer = null!; - - public static Running Start() - { - var cache = new CultCache(); - var schemaId = cache.Registry.GetRequired().SchemaId; - var database = new CultNetDatabase(cache, new CultNetDatabaseOptions - { - Shards = [new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: true, schemaIds: [schemaId])] - }); - var server = new Server(cache, ServerSecurityOptions.Development()) { Port = 0 }; - var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions - { - RemoteWriteAuthority = new Authority() - }); - server.Start(); - return new Running { Server = server, Cache = cache, Database = database, SchemaId = schemaId, _databaseServer = databaseServer }; - } - - public Client Connect() => new(Server.Port, ServerSecurityOptions.Development().ConnectionKey); - - public CultNetDocumentPutRawMessage PutOf(string id, CultRecordKey key, string text) - { - var message = Database.Documents.CreateRawDocumentPutMessage(id, new CultRecordHandle(key), - new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = text }); - message.ShardId = ShardId; - message.ShardEpoch = 1; - return message; - } - - public void Dispose() - { - _databaseServer.Dispose(); - Server.Dispose(); - } - } - - private static CultNetHelloMessage HelloOf(params string[] versions) => - new() { RuntimeId = "loopback-client", RuntimeKind = "test", SupportedMessageVersions = versions }; - - private static readonly string[] BothFrames = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]; - - [Test] - public void StartBindsTheRequestedPortAndReportsTheOneBound() - { - using var running = Running.Start(); - Assert.That(running.Server.Port, Is.Not.Zero.And.Not.EqualTo(3075)); - } - - // contracts/cultnet/interop/write-receipt-vectors.json, error_v1_unreadable_frame_has_null_message_id: code - // message_invalid, details.field "message", messageId null. - [TestCase(new byte[] { 0xC1 }, TestName = "AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId_NeverUsedByte")] - [TestCase(new byte[] { 0x92, 0x01, 0x02 }, TestName = "AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId_NotAMap")] - public void AnUnreadableFrameFromAReceiptPeerIsAnsweredWithMessageInvalidAndNoId(byte[] garbage) - { - using var running = Running.Start(); - using var client = running.Connect(); - client.Send(HelloOf(BothFrames)); - client.Next(); - - client.SendRaw(garbage); - - var error = client.Next(); - Assert.That(error.Code, Is.EqualTo("message_invalid")); - Assert.That(error.Details!.Field, Is.EqualTo("message")); - Assert.That(error.MessageId, Is.Null); - } - - [Test] - public async Task AReceiptPeerIsStillServedAfterAnUnreadableFrameAndALegacyPeerIsAnsweredAsBefore() - { - using var running = Running.Start(); - using var receipt = running.Connect(); - receipt.Send(HelloOf(BothFrames)); - receipt.Next(); - receipt.SendRaw(new byte[] { 0xC1 }); - receipt.Next(); - - receipt.Send(running.PutOf("r1", KeyA, "one")); - Assert.That(receipt.Next().MessageId, Is.EqualTo("r1")); - - using var legacy = running.Connect(); - legacy.Send(HelloOf()); // a hello that lists neither frame is answered with nothing - legacy.SendRaw(new byte[] { 0xC1 }); // unanswered: were it answered, error.v1 would arrive ahead of the refusal - legacy.Send(running.PutOf("l1", KeyB, "two")); - var refusal = legacy.Next(); - Assert.That(refusal, Is.TypeOf()); - Assert.That(refusal.Code, Is.EqualTo("write_not_authorized")); - legacy.Send(running.PutOf("l2", KeyA, "three")); - await WaitFor(() => running.Cache.Get(KeyA)?.Text == "three"); - legacy.ExpectNothingMore("a legacy peer is never sent a receipt or error.v1"); - Assert.That(running.Cache.Get(KeyB), Is.Null); - } - - // write-receipt.md, Reliable channel only: a write on an unreliable channel of a receipt session is refused - // message_invalid, details.field "channel", fixed text, carrying the write's id; it is not applied. - [Test] - public async Task AWriteOnAnUnreliableChannelOfAReceiptPeerIsRefusedAndNotApplied() - { - using var running = Running.Start(); - using var client = running.Connect(); - client.Send(HelloOf(BothFrames)); - client.Next(); - - client.SendUnreliable(running.PutOf("u1", KeyA, "lost")); - - var error = client.Next(); - Assert.That(error.Code, Is.EqualTo("message_invalid")); - Assert.That(error.Details!.Field, Is.EqualTo("channel")); - Assert.That(error.Error, Is.EqualTo("message_invalid: receipt sessions require the reliable channel")); - Assert.That(error.MessageId, Is.EqualTo("u1")); - - // The session goes on, and the same id on the reliable channel is a new write, not a replayed refusal. - client.Send(running.PutOf("u1", KeyA, "kept")); - Assert.That(client.Next().MessageId, Is.EqualTo("u1")); - await WaitFor(() => running.Cache.Get(KeyA)?.Text == "kept"); - } - - // write-receipt.md, Reliable channel only: reliable and not sequenced. Reliable ordered and unordered qualify; reliable - // sequenced does not, and nor does any unreliable method. - [TestCase(DeliveryMethod.ReliableSequenced, true)] - [TestCase(DeliveryMethod.Sequenced, true)] - [TestCase(DeliveryMethod.Unreliable, true)] - [TestCase(DeliveryMethod.ReliableUnordered, false)] - [TestCase(DeliveryMethod.ReliableOrdered, false)] - public async Task OnlyAReliableNonSequencedChannelCarriesAWriteOfAReceiptPeer(DeliveryMethod method, bool refused) - { - using var running = Running.Start(); - using var client = running.Connect(); - client.Send(HelloOf(BothFrames)); - client.Next(); - - client.Send(running.PutOf("c1", KeyA, "text"), method); - - if (refused) - { - var error = client.Next(); - Assert.That(error.Details!.Field, Is.EqualTo("channel")); - Assert.That(error.MessageId, Is.EqualTo("c1")); - Assert.That(running.Cache.Get(KeyA), Is.Null); - } - else - { - Assert.That(client.Next().MessageId, Is.EqualTo("c1")); - await WaitFor(() => running.Cache.Get(KeyA)?.Text == "text"); - } - } - - [Test] - public void ADeleteOnAnUnreliableChannelOfAReceiptPeerIsRefusedWithItsId() - { - using var running = Running.Start(); - using var client = running.Connect(); - client.Send(HelloOf(BothFrames)); - client.Next(); - - client.SendUnreliable(new CultNetDocumentDeleteMessage - { - MessageId = "ud1", SchemaId = running.SchemaId, RecordKey = KeyA.Value, ShardId = ShardId, ShardEpoch = 1 - }); - - var error = client.Next(); - Assert.That(error.Code, Is.EqualTo("message_invalid")); - Assert.That(error.Details!.Field, Is.EqualTo("channel")); - Assert.That(error.MessageId, Is.EqualTo("ud1")); - } - - [Test] - public async Task OnlyWritesAreRefusedOnTheUnreliableChannelAndALegacyPeerIsLeftAsBefore() - { - using var running = Running.Start(); - using var receipt = running.Connect(); - receipt.Send(HelloOf(BothFrames)); - receipt.Next(); - receipt.SendUnreliable(HelloOf(BothFrames)); // not a write: answered with nothing on the unreliable channel - receipt.Send(running.PutOf("r1", KeyA, "one")); - Assert.That(receipt.Next().MessageId, Is.EqualTo("r1"), "nothing else was sent ahead of the receipt"); - receipt.Send(running.PutOf("r2", KeyA, "two")); - Assert.That(receipt.Next().MessageId, Is.EqualTo("r2")); - receipt.ExpectNothingMore("the unreliable hello was answered with nothing"); - - using var legacy = running.Connect(); - legacy.Send(HelloOf()); - legacy.SendUnreliable(running.PutOf("l1", KeyB, "two")); // refused by the authority, answered as v0 as before - Assert.That(legacy.Next().Code, Is.EqualTo("write_not_authorized")); - legacy.SendUnreliable(running.PutOf("l2", KeyA, "three")); - await WaitFor(() => running.Cache.Get(KeyA)?.Text == "three"); - legacy.ExpectNothingMore("a legacy peer is never sent a receipt, error.v1 or a channel refusal"); - } - - private static async Task WaitFor(Func condition) - { - var deadline = DateTime.UtcNow + Cap; - while (!condition()) - { - if (DateTime.UtcNow > deadline) Assert.Fail("the condition did not hold within the cap"); - await Task.Delay(5); - } - } - } -} diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs deleted file mode 100644 index c572cfbf..00000000 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs +++ /dev/null @@ -1,1272 +0,0 @@ -#nullable enable -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Threading.Tasks; -using GameCult.Caching; -using NUnit.Framework; -using static GameCult.Networking.Tests.NetworkingTests; - -namespace GameCult.Networking.Tests -{ - // The database server's half of contracts/cultnet/interop/write-receipt.md. A peer whose hello lists both new frames is - // answered with write_admitted or error.v1, each naming the write's id; any other peer is answered as before. The handlers - // are reached through their private delegates; a peer's transport is a capture of what leaves it. - public sealed class CultNetWriteReceiptServerTests - { - private const string ShardId = "receipt-shard"; - private static readonly CultRecordKey KeyA = new("tests:receipt:a"); - private static readonly CultRecordKey KeyB = new("tests:receipt:b"); - - private sealed class Authority : ICultNetRemoteWriteAuthority - { - public HashSet Denied { get; } = new(); - public int Asked { get; private set; } - public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) - { - Asked++; - return Denied.Contains(request.RecordKey.Value) ? CultNetRemoteWriteDecision.Refuse("denied") : CultNetRemoteWriteDecision.Grant(); - } - } - - private sealed class Wire - { - public List Sent { get; } = new(); - /// When set, the connection cannot queue what it is sent. - public bool Unsendable { get; set; } - public int Ended { get; private set; } - public LiteNetLibTransportConnection Transport { get; } - public CultNetServerPeer Peer { get; } - - public Wire() - { - Transport = new LiteNetLibTransportConnection( - bytes => - { - if (Unsendable) throw new InvalidOperationException("the send queue is full"); - Sent.Add(LiteNetLibTransportConnection.DecodeSchema(LiteNetLibTransportConnection.Decode(bytes))); - }, - () => Ended++); - Peer = (CultNetServerPeer)RuntimeHelpers.GetUninitializedObject(typeof(CultNetServerPeer)); - typeof(CultNetServerPeer).GetField("k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance)! - .SetValue(Peer, Transport); - } - } - - private sealed class Rig - { - public CultCache Cache = null!; - public CultNetDatabase Database = null!; - public CultNetDatabaseServer DatabaseServer = null!; - public Authority Authority = null!; - public string SchemaId = string.Empty; - public Func PutHandler = null!; - public Func DeleteHandler = null!; - public Func HelloHandler = null!; - - public int LogLength => Database.GetMutationLog(ShardId).Count; - public string? TextOf(CultRecordKey key) => Cache.Get(key)?.Text; - - public CultNetDocumentPutRawMessage PutOf(string id, CultRecordKey key, string text) - { - var message = Database.Documents.CreateRawDocumentPutMessage(id, new CultRecordHandle(key), - new NetworkSchemaNote { Schema = "tests.networking_note.v1", Text = text }); - message.ShardId = ShardId; - message.ShardEpoch = 1; - return message; - } - - public CultNetDocumentDeleteMessage DeleteOf(string id, CultRecordKey key) => - new() { MessageId = id, SchemaId = SchemaId, RecordKey = key.Value, ShardId = ShardId, ShardEpoch = 1 }; - - public Task Put(Wire wire, string id, CultRecordKey key, string text) => PutHandler(PutOf(id, key, text), wire.Peer); - public Task Delete(Wire wire, string id, CultRecordKey key) => DeleteHandler(DeleteOf(id, key), wire.Peer); - public Task Hello(Wire wire, params string[] versions) => HelloHandler(HelloOf(versions), wire.Peer); - } - - private static CultNetHelloMessage HelloOf(params string[] versions) => - new() { RuntimeId = "test-client", RuntimeKind = "test", SupportedMessageVersions = versions }; - - private static readonly string[] BothFrames = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1]; - - private static Rig Build(Authority? authority = null, bool primary = true, ICultNetShardWriteForwarder? forwarder = null) - { - var cache = new CultCache(); - var schemaId = cache.Registry.GetRequired().SchemaId; - var database = new CultNetDatabase(cache, new CultNetDatabaseOptions - { - Shards = - [ - new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: primary, schemaIds: [schemaId], - primaryEndpoints: primary ? null : ["cultnet://runtime-owner:3075"]) - ] - }); - var host = new Server(cache, ServerSecurityOptions.Development()); - authority ??= new Authority(); - var databaseServer = new CultNetDatabaseServer(host, database, new CultNetDatabaseServerOptions - { - RemoteWriteAuthority = authority, - ForwardNonPrimaryWrites = forwarder != null, - WriteForwarder = forwarder - }); - const BindingFlags flags = BindingFlags.NonPublic | BindingFlags.Instance; - T Handler(string field) => (T)typeof(CultNetDatabaseServer).GetField(field, flags)!.GetValue(databaseServer)!; - return new Rig - { - Cache = cache, - Database = database, - DatabaseServer = databaseServer, - Authority = authority, - SchemaId = schemaId, - PutHandler = Handler>("_putHandler"), - DeleteHandler = Handler>("_deleteHandler"), - HelloHandler = Handler>("_helloHandler") - }; - } - - private static async Task<(Rig Rig, Wire Wire)> Capable() - { - var rig = Build(); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); // the server's own hello is checked on its own - return (rig, wire); - } - - private static CultNetWriteAdmittedMessage AssertAdmitted(ICultNetSchemaMessage message, string id, string operation, string recordKey) - { - var admitted = (CultNetWriteAdmittedMessage)message; - Assert.That(admitted.MessageId, Is.EqualTo(id)); - Assert.That(admitted.Operation, Is.EqualTo(operation)); - Assert.That(admitted.RecordKey, Is.EqualTo(recordKey)); - return admitted; - } - - private static CultNetErrorV1Message AssertRefused(ICultNetSchemaMessage message, string? id, string? code) - { - var error = (CultNetErrorV1Message)message; - Assert.That(error.MessageId, Is.EqualTo(id)); - Assert.That(error.Code, Is.EqualTo(code)); - return error; - } - - [Test] - public async Task AnAdmittedPutOnAReceiptConnectionIsAnsweredAfterItsCommit() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Clear(); - - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); - var admitted = AssertAdmitted(wire.Sent.Single(), "m1", "put", KeyA.Value); - Assert.That(admitted.SchemaId, Is.EqualTo(rig.SchemaId)); - } - - [Test] - public async Task AnAdmittedDeleteIsAnsweredWithAReceiptNamingTheDelete() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - wire.Sent.Clear(); - - await rig.Delete(wire, "d1", KeyA); - - Assert.That(rig.TextOf(KeyA), Is.Null); - AssertAdmitted(wire.Sent.Single(), "d1", "delete", KeyA.Value); - } - - [Test] - public async Task TheServerAnswersAReceiptHelloWithItsOwnListingBothFrames() - { - var rig = Build(); - var wire = new Wire(); - - await rig.Hello(wire, BothFrames); - - var hello = (CultNetHelloMessage)wire.Sent.Single(); - Assert.That(hello.SupportedMessageVersions, Is.EquivalentTo(BothFrames)); - } - - [TestCase("")] - [TestCase("cultnet.write_admitted.v0")] - [TestCase("cultnet.error.v1")] - public async Task APeerThatDoesNotListBothFramesIsAnsweredAsBeforeAndTheServerSaysNothingOfReceipts(string listed) - { - var rig = Build(); - var wire = new Wire(); - await rig.Hello(wire, listed.Length == 0 ? [] : [listed]); - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(rig.TextOf(KeyA), Is.EqualTo("one"), "admitted all the same"); - Assert.That(wire.Sent, Is.Empty, "no receipt, no hello"); - - rig.Authority.Denied.Add(KeyB.Value); - await rig.Put(wire, "m2", KeyB, "two"); - Assert.That(wire.Sent.Single(), Is.TypeOf(), "a refusal is the v0 error, without an id"); - } - - [Test] - public async Task APeerThatNeverSaidHelloGetsNoReceipt() - { - var rig = Build(); - var wire = new Wire(); - - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty); - } - - [Test] - public async Task ASecondHelloDoesNotChangeTheCapability() - { - var (rig, wire) = await Capable(); - await rig.Hello(wire); - await rig.Put(wire, "m1", KeyA, "one"); - AssertAdmitted(wire.Sent.Single(), "m1", "put", KeyA.Value); - - var late = Build(); - var legacy = new Wire(); - await late.Put(legacy, "m1", KeyA, "one"); - await late.Hello(legacy, BothFrames); - await late.Put(legacy, "m2", KeyB, "two"); - Assert.That(legacy.Sent, Is.Empty, "a hello after the first write does not opt the connection in"); - } - - [Test] - public async Task ACapabilityNeverAuthorizes() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - - await rig.Put(wire, "m1", KeyA, "one"); - await rig.Delete(wire, "d1", KeyA); - - Assert.That(rig.TextOf(KeyA), Is.Null); - AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); - AssertRefused(wire.Sent[1], "d1", "write_not_authorized"); - Assert.That(wire.Sent, Has.Count.EqualTo(2), "one answer per write"); - } - - [Test] - public async Task AResendOfAnAdmittedWriteIsReceiptedAgainWithoutBeingAppliedAgain() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - var logAfterFirst = rig.LogLength; - - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(wire.Sent, Has.Count.EqualTo(2)); - AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); - Assert.That(rig.LogLength, Is.EqualTo(logAfterFirst), "the resend was not committed again"); - } - - [Test] - public async Task TheSameIdWithOtherContentIsRefusedAndNeverReReceipted() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - - await rig.Put(wire, "m1", KeyA, "other"); - await rig.Put(wire, "m1", KeyB, "one"); - await rig.Delete(wire, "m1", KeyA); - - Assert.That(rig.TextOf(KeyA), Is.EqualTo("one")); - Assert.That(rig.TextOf(KeyB), Is.Null); - Assert.That(wire.Sent, Has.Count.EqualTo(4)); - for (var index = 1; index < 4; index++) - { - var error = AssertRefused(wire.Sent[index], "m1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("messageId")); - } - } - - [Test] - public async Task AReusedIdIsJudgedBeforeAuthorityIsAskedAgain() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - rig.Authority.Denied.Add(KeyB.Value); - - await rig.Put(wire, "m1", KeyB, "other"); - - AssertRefused(wire.Sent[1], "m1", "message_invalid"); - Assert.That(rig.Authority.Asked, Is.EqualTo(1), "authority is asked on an id's first sighting only"); - } - - [Test] - public async Task AResendIsAnsweredFromMemoryWithoutAskingAuthorityAgain() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - rig.Authority.Denied.Add(KeyA.Value); - - await rig.Put(wire, "m1", KeyA, "one"); - - AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); - Assert.That(rig.Authority.Asked, Is.EqualTo(1)); - } - - [Test] - public async Task ARefusedWriteIsRememberedSoItsResendReplaysTheRefusal() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - await rig.Put(wire, "m1", KeyA, "one"); - rig.Authority.Denied.Clear(); - - await rig.Put(wire, "m1", KeyA, "one"); - - var first = AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); - var again = AssertRefused(wire.Sent[1], "m1", "write_not_authorized"); - Assert.That(again.Error, Is.EqualTo(first.Error)); - Assert.That(rig.TextOf(KeyA), Is.Null, "the resend was not decided afresh"); - Assert.That(rig.Authority.Asked, Is.EqualTo(1)); - } - - [Test] - public async Task ARefusedIdReusedForOtherContentIsMessageInvalid() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - await rig.Put(wire, "m1", KeyA, "one"); - - await rig.Put(wire, "m1", KeyB, "other"); - - AssertRefused(wire.Sent[1], "m1", "message_invalid"); - Assert.That(rig.TextOf(KeyB), Is.Null); - } - - [Test] - public async Task RefusedWritesCountAgainstTheRememberedWriteLimit() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - { - await rig.Put(wire, $"r{index}", KeyA, $"v{index}"); - } - - wire.Sent.Clear(); - await rig.Put(wire, "over", KeyB, "x"); - await rig.Put(wire, "r0", KeyA, "v0"); - - AssertRefused(wire.Sent[0], "over", "limit_reached"); - AssertRefused(wire.Sent[1], "r0", "write_not_authorized"); - Assert.That(rig.TextOf(KeyB), Is.Null); - } - - [Test] - public async Task APutAndADeleteNeverShareAnId() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "p1", KeyA, "one"); - await rig.Delete(wire, "d1", KeyA); - wire.Sent.Clear(); - - await rig.Delete(wire, "p1", KeyA); - await rig.Put(wire, "d1", KeyA, "one"); - - AssertRefused(wire.Sent[0], "p1", "message_invalid"); - AssertRefused(wire.Sent[1], "d1", "message_invalid"); - Assert.That(rig.TextOf(KeyA), Is.Null, "neither reuse was applied"); - } - - [Test] - public async Task ADeleteAsTheFirstFrameSettlesTheConnectionAsLegacy() - { - var rig = Build(); - var wire = new Wire(); - await rig.Delete(wire, "d1", KeyA); - await rig.Hello(wire, BothFrames); - await rig.Put(wire, "m1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty, "a hello after the first delete does not opt the connection in"); - } - - [Test] - public async Task AnAnswerThatCannotBeSentEndsTheConnectionInsteadOfLeavingTheWriteSilent() - { - var (rig, wire) = await Capable(); - wire.Unsendable = true; - await rig.Put(wire, "m1", KeyA, "one"); - Assert.That(wire.Ended, Is.EqualTo(1), "a receipt that cannot be queued"); - - rig.Authority.Denied.Add(KeyB.Value); - await rig.Put(wire, "m2", KeyB, "two"); - Assert.That(wire.Ended, Is.EqualTo(2), "a refusal that cannot be queued"); - - wire.Transport.AnswerUnreadableSchemaFrame(new byte[] { 0xC1 }); - Assert.That(wire.Ended, Is.EqualTo(3), "an unreadable-frame answer that cannot be queued"); - } - - [Test] - public async Task AnUnexpectedFailureIsRefusedWithFixedTextNotTheExceptionsOwn() - { - var (rig, wire) = await Capable(); - var message = rig.PutOf("m1", KeyA, "one"); - message.Document!.Payload = [0xC1]; - var legacy = new Wire(); - - await rig.PutHandler(message, wire.Peer); - await rig.PutHandler(message, legacy.Peer); - await rig.DeleteHandler(new CultNetDocumentDeleteMessage { MessageId = "d1", SchemaId = "no.such.schema", RecordKey = "k" }, legacy.Peer); - - Assert.That(((CultNetErrorV1Message)wire.Sent.Single()).Error, Is.EqualTo(CultNetWriteReceiptSession.WriteFailedText)); - Assert.That(((CultNetErrorMessage)legacy.Sent[0]).Error, Is.EqualTo(CultNetWriteReceiptSession.WriteFailedText)); - } - - [Test] - public void TheLimitReachedRefusalIsTheSpecVectorByteForByte() - { - const string vector = "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93B6C696D69745F726561636865643A207468652073657373696F6E27732072656D656D62657265642D7772697465206C696D69742069732066756C6CAB726F7574696E6748696E74C0A4636F6465AD6C696D69745F72656163686564A764657461696C7384A56669656C64C0A576616C7565C0A461734F66C0A763757272656E74C0A96D6573736167654964A87075742D31303235"; - - var bytes = CultNetSchemaMessageSerialization.Serialize(CultNetErrorV1Message.LimitReached("put-1025")); - - Assert.That(Convert.ToHexString(bytes), Is.EqualTo(vector)); - } - - [Test] - public void TheSpecWireVectorsDecodeToTheirExpectedFields() - { - var admitted = (CultNetWriteAdmittedMessage)CultNetSchemaMessageSerialization.Deserialize(Convert.FromHexString( - "85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31")); - Assert.That((admitted.MessageId, admitted.Operation, admitted.SchemaId, admitted.RecordKey), - Is.EqualTo(("put-1", "put", "note.v0", "note:1"))); - - var refused = (CultNetErrorV1Message)CultNetSchemaMessageSerialization.Deserialize(Convert.FromHexString( - "86AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7631A56572726F72D93677726974655F6E6F745F617574686F72697A65643A2074686520636174616C6F6720726566757365642074686520646F63756D656E74AB726F7574696E6748696E74C0A4636F6465B477726974655F6E6F745F617574686F72697A6564A764657461696C7384A56669656C64A97265636F72644B6579A576616C7565A66E6F74653A32A461734F66C0A763757272656E74C0A96D6573736167654964A57075742D32")); - Assert.That(refused.Error, Is.EqualTo("write_not_authorized: the catalog refused the document")); - Assert.That(refused.Code, Is.EqualTo("write_not_authorized")); - Assert.That(refused.MessageId, Is.EqualTo("put-2")); - Assert.That((refused.Details!.Field, refused.Details.Value), Is.EqualTo(("recordKey", "note:2"))); - Assert.That(refused.Details.AsOf, Is.Null); - Assert.That(refused.Details.Current, Is.Null); - } - - [Test] - public async Task AFullDedupeTableRefusesNewWritesAndStillAnswersRememberedOnes() - { - var (rig, wire) = await Capable(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - { - await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); - } - - wire.Sent.Clear(); - await rig.Put(wire, "over", KeyA, "x"); - await rig.Put(wire, "w0", new CultRecordKey("tests:receipt:0"), "x"); - - var limit = AssertRefused(wire.Sent[0], "over", "limit_reached"); - Assert.That(limit.Error, Is.EqualTo("limit_reached: the session's remembered-write limit is full")); - AssertAdmitted(wire.Sent[1], "w0", "put", "tests:receipt:0"); - Assert.That(rig.TextOf(KeyA), Is.Null); - } - - [Test] - public async Task AWriteWithNoIdIsRefusedWithNoId() - { - var (rig, wire) = await Capable(); - - var message = rig.PutOf("placeholder", KeyA, "one"); - message.MessageId = string.Empty; - await rig.PutHandler(message, wire.Peer); - - var error = AssertRefused(wire.Sent.Single(), null, "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("messageId")); - Assert.That(rig.TextOf(KeyA), Is.Null); - } - - [Test] - public async Task AFrameThatDoesNotDecodeIsRefusedWithItsIdWhenItHoldsOne() - { - var (_, wire) = await Capable(); - var withId = MessagePack.MessagePackSerializer.Serialize(new Dictionary - { - ["schemaVersion"] = "cultnet.document_put_raw.v9", - ["messageId"] = "bad-1" - }); - - wire.Transport.AnswerUnreadableSchemaFrame(withId); - wire.Transport.AnswerUnreadableSchemaFrame(new byte[] { 0xC1 }); - - var error = AssertRefused(wire.Sent[0], "bad-1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("message")); - AssertRefused(wire.Sent[1], null, "message_invalid"); - } - - [Test] - public void ReadableMessageIdFindsOnlyANonEmptyStringIdInATopLevelMap() - { - string? IdOf(object value) => CultNetReadableMessageId.From(MessagePack.MessagePackSerializer.Serialize(value)); - - Assert.That(IdOf(new Dictionary { ["a"] = new[] { 1, 2 }, ["messageId"] = "x" }), Is.EqualTo("x")); - Assert.That(IdOf(new Dictionary { ["messageId"] = 5 }), Is.Null); - Assert.That(IdOf(new Dictionary { ["messageId"] = "" }), Is.Null); - Assert.That(IdOf(new Dictionary { ["nested"] = new Dictionary { ["messageId"] = "deep" } }), Is.Null); - Assert.That(IdOf("messageId"), Is.Null); - } - - [Test] - public void TheContentHashMatchesTheSpecVectors() - { - byte[] a = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x31]; - byte[] b = [0x81, 0xA2, 0x69, 0x64, 0xA1, 0x32]; - string Hex(byte[] hash) => Convert.ToHexString(hash).ToLowerInvariant(); - - Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:1", a)), Is.EqualTo("7051c10071123b6ade50fbd5075e3859cae4ea70ce358a24a0b0c6980090e95d")); - Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:1", b)), Is.EqualTo("58d3fc6b858d2605a68550c9d4811ccdca6b9db7a0c3bfebf304d279257f6f9f")); - Assert.That(Hex(CultNetWriteContentHash.Compute("put", "note.v0", "note:2", a)), Is.EqualTo("a248cd920e58d41bc6ccca9e108030b1d491239000047fef088122364a32b6f8")); - Assert.That(Hex(CultNetWriteContentHash.Compute("delete", "note.v0", "note:1", null)), Is.EqualTo("232b0f96651b28aa8ab7673a6106944a9f6a8d66852b6e21291a23ed084b8b37")); - } - - [Test] - public void TheNewFramesRoundTripOnTheWire() - { - var admitted = new CultNetWriteAdmittedMessage { MessageId = "m", Operation = "put", SchemaId = "s", RecordKey = "k" }; - var error = new CultNetErrorV1Message { Error = "e", Code = "message_invalid", MessageId = null }; - - var admittedBack = (CultNetWriteAdmittedMessage)CultNetSchemaMessageSerialization.Deserialize(CultNetSchemaMessageSerialization.Serialize(admitted)); - var errorBack = (CultNetErrorV1Message)CultNetSchemaMessageSerialization.Deserialize(CultNetSchemaMessageSerialization.Serialize(error)); - - Assert.That(admittedBack.MessageId, Is.EqualTo("m")); - Assert.That(admittedBack.RecordKey, Is.EqualTo("k")); - Assert.That(errorBack.MessageId, Is.Null); - Assert.That(errorBack.Code, Is.EqualTo("message_invalid")); - } - - private sealed class ScriptedForwarder : ICultNetShardWriteForwarder - { - public Func OnPut { get; set; } = _ => Task.CompletedTask; - public Func OnDelete { get; set; } = _ => Task.CompletedTask; - public int Forwarded { get; private set; } - - public Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message) - { - Forwarded++; - return OnPut(message); - } - - public Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message) - { - Forwarded++; - return OnDelete(message); - } - } - - [Test] - public async Task AForwardedWriteIsReceiptedUnderItsOriginalIdOnlyWhenThePrimaryAnswered() - { - var forwarder = new ScriptedForwarder(); - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - await rig.Delete(wire, "fwd-2", KeyA); - - AssertAdmitted(wire.Sent[0], "fwd-1", "put", KeyA.Value); - AssertAdmitted(wire.Sent[1], "fwd-2", "delete", KeyA.Value); - Assert.That(forwarder.Forwarded, Is.EqualTo(2)); - } - - [Test] - public async Task AForwardedWritesRefusalIsRelayedAsErrorV1UnderTheOriginalId() - { - var forwarder = new ScriptedForwarder - { - OnPut = _ => Task.FromException(new CultNetWriteRefusedException(new CultNetErrorMessage { Error = "primary says no", Code = "write_not_authorized" })), - OnDelete = _ => Task.FromException(new CultNetWriteRefusedException(CultNetErrorMessage.ForMessageInvalid("recordKey", "bad"))) - }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - await rig.Delete(wire, "fwd-2", KeyA); - - AssertRefused(wire.Sent[0], "fwd-1", "write_not_authorized"); - Assert.That(((CultNetErrorV1Message)wire.Sent[0]).Error, Is.EqualTo("write_not_authorized"), "a relayed refusal is in fixed text, the same one a resend replays"); - AssertRefused(wire.Sent[1], "fwd-2", "message_invalid"); - } - - [Test] - public async Task AForwardThePrimaryLeftSilentIsNeverForwardedAgainOnTheSession() - { - var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(new CultNetWriteUnconfirmedException("fwd-1")) }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - forwarder.OnPut = _ => Task.CompletedTask; - await rig.Put(wire, "fwd-1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty, "no receipt, no error: the write stays unconfirmed"); - Assert.That(forwarder.Forwarded, Is.EqualTo(1), "the primary may have applied it, so it is not sent again"); - - await rig.Put(wire, "fwd-1", KeyB, "other"); - AssertRefused(wire.Sent.Single(), "fwd-1", "message_invalid"); - } - - [Test] - public async Task AForwardRefusedByThePrimaryReplaysItsRefusalOnResend() - { - var forwarder = new ScriptedForwarder - { - OnPut = _ => Task.FromException(new CultNetWriteRefusedException(new CultNetErrorMessage { Error = "primary says no", Code = "write_not_authorized" })) - }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - await rig.Put(wire, "fwd-1", KeyA, "one"); - - Assert.That(wire.Sent, Has.Count.EqualTo(2)); - Assert.That(((CultNetErrorV1Message)wire.Sent[1]).Error, Is.EqualTo("write_not_authorized"), "a remembered refusal is replayed in fixed text, not the primary's own"); - Assert.That(forwarder.Forwarded, Is.EqualTo(1)); - } - - [Test] - public async Task AForwardThatWasNotSentMayBeRetriedUnderTheSameId() - { - var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(new CultNetWriteNotSentException("fwd-1", new TimeoutException("primary unreachable"))) }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - Assert.That(wire.Sent, Is.Empty, "no receipt, no error, no synthesized refusal"); - - forwarder.OnPut = _ => Task.CompletedTask; - await rig.Put(wire, "fwd-1", KeyA, "one"); - AssertAdmitted(wire.Sent.Single(), "fwd-1", "put", KeyA.Value); - Assert.That(forwarder.Forwarded, Is.EqualTo(2), "the unsent attempt left no claim behind"); - } - - // Once the frame may have gone out, whatever failed, the id is never forwarded again on the session. - private static IEnumerable PostSendFailures() - { - yield return new TestCaseData(new TimeoutException("no answer")).SetName("timeout"); - yield return new TestCaseData(new System.IO.IOException("connection reset")).SetName("reset"); - yield return new TestCaseData(new InvalidOperationException("client disconnected")).SetName("disconnected"); - yield return new TestCaseData(new CultNetWriteUnconfirmedException("fwd-1")).SetName("no answer in the window"); - yield return new TestCaseData(new Exception("anything else, ambiguous")).SetName("ambiguous"); - } - - [TestCaseSource(nameof(PostSendFailures))] - public async Task AForwardThatMayHaveBeenSentIsNeverForwardedAgainOnTheSession(Exception failure) - { - var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(failure) }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - forwarder.OnPut = _ => Task.CompletedTask; - await rig.Put(wire, "fwd-1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty, "unconfirmed: nothing answered, nothing synthesized"); - Assert.That(forwarder.Forwarded, Is.EqualTo(1), "the claim stays"); - } - - [Test] - public async Task TheRealForwarderRetriesAConnectFailureButNotAFailureAfterTheWriteWasSent() - { - var clients = new Queue(); - var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions - { - CreateClient = () => clients.Dequeue(), - ConnectTimeout = TimeSpan.FromMilliseconds(100), - ResultWindow = TimeSpan.FromMilliseconds(100) - }); - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - clients.Enqueue(new ForwardingClient { ConnectFails = true }); - await rig.Put(wire, "fwd-1", KeyA, "one"); - var sent = new ForwardingClient { AdmitWrites = true }; - clients.Enqueue(sent); - await rig.Put(wire, "fwd-1", KeyA, "one"); - AssertAdmitted(wire.Sent.Single(), "fwd-1", "put", KeyA.Value); - - wire.Sent.Clear(); - var reset = new ForwardingClient { WriteSendFails = true }; - clients.Enqueue(reset); - await rig.Put(wire, "fwd-2", KeyA, "two"); - clients.Enqueue(new ForwardingClient { AdmitWrites = true }); - await rig.Put(wire, "fwd-2", KeyA, "two"); - Assert.That(wire.Sent, Is.Empty, "the write send threw: it may have gone out"); - Assert.That(clients, Has.Count.EqualTo(1), "and the id was not forwarded again"); - } - - private sealed class ForwardingClient : ICultNetSchemaClient - { - private Action? _admitted; - public bool ConnectFails { get; set; } - public bool NeverConnects { get; set; } - public bool HelloSendFails { get; set; } - public bool WriteSendFails { get; set; } - public bool WriteNotSentFails { get; set; } - public bool AdmitWrites { get; set; } - public bool Connected { get; private set; } - public List Sent { get; } = new(); - - public void Connect(string host, int port) - { - if (ConnectFails) throw new System.Net.Sockets.SocketException(); - Connected = !NeverConnects; - } - - public void SendCultNet(T message) where T : ICultNetSchemaMessage - { - if (message is CultNetHelloMessage) - { - if (HelloSendFails) throw new System.IO.IOException("hello failed"); - return; - } - - if (WriteNotSentFails) throw new CultNetWriteNotSentException("x"); - if (WriteSendFails) throw new System.IO.IOException("send failed after an unknown number of bytes"); - Sent.Add(message); - if (AdmitWrites && message is CultNetDocumentPutRawMessage put) - _admitted?.Invoke(new CultNetWriteAdmittedMessage { MessageId = put.MessageId, Operation = "put", SchemaId = put.Document?.SchemaId, RecordKey = put.Document?.RecordKey }); - } - - public void OnCultNet(Action callback) where T : ICultNetSchemaMessage - { - if (callback is Action admitted) _admitted = admitted; - } - - public void Dispose() { } - } - - private static CultNetSchemaWriteForwarder RealForwarder(ForwardingClient client) => new(new CultNetSchemaWriteForwarderOptions - { - CreateClient = () => client, - ConnectTimeout = TimeSpan.FromMilliseconds(100), - ResultWindow = TimeSpan.FromMilliseconds(100) - }); - - private static readonly CultNetShardDescriptor ForwardShard = new("notes", "primary", 1, isPrimary: false, primaryEndpoints: ["cultnet://primary.example.test:4075"]); - - private static CultNetDocumentPutRawMessage ForwardPut() => new() { MessageId = "fwd-1", Document = new CultNetRawDocumentRecord { SchemaId = "s", RecordKey = "k", Payload = [1] } }; - - [TestCase("connect")] - [TestCase("never connects")] - [TestCase("hello")] - [TestCase("write not sent")] - public void TheForwarderNamesEveryFailureBeforeTheWriteWasSentAsNotSent(string stage) - { - var client = new ForwardingClient - { - ConnectFails = stage == "connect", - NeverConnects = stage == "never connects", - HelloSendFails = stage == "hello", - WriteNotSentFails = stage == "write not sent" - }; - - Assert.ThrowsAsync(() => RealForwarder(client).ForwardPutAsync(ForwardShard, ForwardPut())); - Assert.That(client.Sent, Is.Empty); - } - - [Test] - public void TheForwarderNamesNoPrimaryEndpointAsNotSent() - { - var shard = new CultNetShardDescriptor("notes", "primary", 1, isPrimary: false, primaryEndpoints: []); - Assert.ThrowsAsync(() => RealForwarder(new ForwardingClient()).ForwardPutAsync(shard, ForwardPut())); - } - - [Test] - public void ASendThatThrewWithoutProvingNothingWasWrittenIsNotNotSent() - { - var client = new ForwardingClient { WriteSendFails = true }; - var failure = Assert.CatchAsync(() => RealForwarder(client).ForwardPutAsync(ForwardShard, ForwardPut())); - Assert.That(failure, Is.Not.InstanceOf(), "ambiguous: unconfirmed"); - } - - [Test] - public void ASentWriteNobodyAnswersIsUnconfirmedNotNotSent() - { - var client = new ForwardingClient(); - Assert.ThrowsAsync(() => RealForwarder(client).ForwardPutAsync(ForwardShard, ForwardPut())); - Assert.That(client.Sent, Has.Count.EqualTo(1)); - } - - [Test] - public async Task TheRoutingRefusalAndTheUnownedSchemaRefusalCarryNoExceptionText() - { - var rig = Build(primary: false); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - await rig.Put(wire, "fwd-1", KeyA, "one"); - var routing = AssertRefused(wire.Sent.Single(), "fwd-1", null); - Assert.That(routing.Error, Is.EqualTo(CultNetDatabaseServer.RoutingRefusalText)); - - var secret = new CultNetUnownedSchemaException("secret.schema.v1", new CultRecordKey("secret:key")); - var unowned = CultNetErrorMessage.ForUnownedSchema(secret); - Assert.That(unowned.Error, Does.Not.Contain(secret.Message).And.Not.Contain("secret")); - Assert.That(unowned.Details!.Value, Is.EqualTo("secret.schema.v1"), "the structured detail still names the schema"); - } - - [Test] - public async Task APeerThatDidNotOptInIsToldNothingOfAnUnansweredForward() - { - var forwarder = new ScriptedForwarder { OnPut = _ => Task.FromException(new CultNetWriteUnconfirmedException("fwd-1")) }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - - Assert.That(wire.Sent, Is.Empty); - } - - [Test] - public async Task AReceiptConnectionNotForwardableIsRefusedWithTheRoutingErrorAndItsId() - { - var rig = Build(primary: false); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - await rig.Put(wire, "fwd-1", KeyA, "one"); - - var error = AssertRefused(wire.Sent.Single(), "fwd-1", null); - Assert.That(error.RoutingHint, Is.Not.Null); - } - - [Test] - public async Task AResendWhileTheFirstAttemptIsStillBeingAppliedIsNotAnsweredTwice() - { - var release = new TaskCompletionSource(); - var forwarder = new ScriptedForwarder { OnPut = _ => release.Task }; - var rig = Build(primary: false, forwarder: forwarder); - var wire = new Wire(); - await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - - var first = rig.Put(wire, "fwd-1", KeyA, "one"); - await rig.Put(wire, "fwd-1", KeyA, "one"); - Assert.That(wire.Sent, Is.Empty, "the resend waits for the first attempt's answer"); - release.SetResult(); - await first; - - AssertAdmitted(wire.Sent.Single(), "fwd-1", "put", KeyA.Value); - Assert.That(forwarder.Forwarded, Is.EqualTo(1), "and is not forwarded again"); - } - - [Test] - public void TheForwarderWaitsTheReceiptWindowByDefault() - { - Assert.That(new CultNetSchemaWriteForwarderOptions().ResultWindow, Is.EqualTo(CultNetWriteCompletion.ReceiptResultWindow)); - } - - // ---- Heimdall round two ---- - - private static string IdOfBytes(int bytes) => new string('i', bytes); - - [Test] - public async Task AnIdOver256BytesIsRefusedBeforeAnythingIsRemembered() - { - var (rig, wire) = await Capable(); - var tooLong = IdOfBytes(257); - - await rig.Put(wire, tooLong, KeyA, "one"); - await rig.Put(wire, tooLong, KeyA, "one"); - - Assert.That(wire.Sent, Has.Count.EqualTo(2)); - foreach (var sent in wire.Sent) - { - var error = AssertRefused(sent, tooLong, "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("messageId")); - Assert.That(error.Error, Is.EqualTo("message_invalid: messageId exceeds 256 bytes")); - } - - Assert.That(rig.TextOf(KeyA), Is.Null); - // Nothing was remembered: the whole table is still free for admitted writes. - wire.Sent.Clear(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); - Assert.That(wire.Sent.OfType().Count(), Is.EqualTo(CultNetWriteReceiptSession.MaxRememberedWrites)); - } - - [Test] - public async Task TheIdLimitCountsUtf8BytesNotCharacters() - { - var (rig, wire) = await Capable(); - var atLimit = IdOfBytes(256); - var multibyte = new string('\u00e9', 129); // 129 chars, 258 bytes - - await rig.Put(wire, atLimit, KeyA, "one"); - await rig.Put(wire, multibyte, KeyB, "two"); - - AssertAdmitted(wire.Sent[0], atLimit, "put", KeyA.Value); - AssertRefused(wire.Sent[1], multibyte, "message_invalid"); - } - - [Test] - public void ARememberedEntryHoldsOnlyFixedSizeDigestsNotTheStrings() - { - var flags = BindingFlags.NonPublic | BindingFlags.Instance; - var table = typeof(CultNetWriteReceiptSession).GetFields(flags) - .Where(f => f.FieldType.IsGenericType && f.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>)); - Assert.That(table, Is.Not.Empty); - foreach (var field in table) - { - var args = field.FieldType.GetGenericArguments(); - Assert.That(args[0].IsValueType, Is.True, $"{field.Name} is keyed by {args[0]}"); - foreach (var held in args[1].IsValueType || args[1] == typeof(string) ? new[] { args[1] } : args[1].GetFields(flags).Select(f => f.FieldType).Concat(args[1].GetProperties(flags | BindingFlags.Public).Select(p => p.PropertyType))) - { - Assert.That(held == typeof(string) || held == typeof(byte[]), Is.False, $"{field.Name} holds {held}"); - } - } - } - - [Test] - public async Task AnAdmittedResendIsAnsweredFromDigestsWithTheResendsOwnBody() - { - var (rig, wire) = await Capable(); - await rig.Put(wire, "m1", KeyA, "one"); - await rig.Put(wire, "m1", KeyA, "one"); - - AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); - Assert.That(rig.LogLength, Is.EqualTo(1)); - } - - [Test] - public async Task ARefusalIsReplayedForAResendAfterTheDigestChange() - { - var (rig, wire) = await Capable(); - rig.Authority.Denied.Add(KeyA.Value); - await rig.Put(wire, "m1", KeyA, "one"); - await rig.Put(wire, "m1", KeyA, "one"); - - AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); - AssertRefused(wire.Sent[1], "m1", "write_not_authorized"); - Assert.That(rig.Authority.Asked, Is.EqualTo(1)); - } - - [Test] - public async Task APutWithNoDocumentIsRefusedNamingTheDocumentWithFixedText([Values] bool capable) - { - var rig = Build(); - var wire = new Wire(); - if (capable) await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - var message = rig.PutOf("m1", KeyA, "one"); - message.Document = null!; - - await rig.PutHandler(message, wire.Peer); - - var sent = wire.Sent.Single(); - var error = capable ? AssertRefused(sent, "m1", "message_invalid").ToV0() : (CultNetErrorMessage)sent; - Assert.That(error.Code, Is.EqualTo("message_invalid")); - Assert.That(error.Details!.Field, Is.EqualTo("document")); - Assert.That(error.Error, Is.EqualTo("message_invalid: document is required")); - } - - [Test] - public async Task APutWithNoSchemaIdIsRefusedNamingTheSchemaIdWithFixedText() - { - var (rig, wire) = await Capable(); - var message = rig.PutOf("m1", KeyA, "one"); - message.Document.SchemaId = null!; - - await rig.PutHandler(message, wire.Peer); - - var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("schemaId")); - Assert.That(error.Error, Is.EqualTo("message_invalid: schemaId is required")); - } - - [Test] - public async Task ADeleteWithNoSchemaIdIsRefusedNamingTheSchemaIdWithFixedText([Values] bool capable) - { - var rig = Build(); - var wire = new Wire(); - if (capable) await rig.Hello(wire, BothFrames); - wire.Sent.Clear(); - var message = rig.DeleteOf("d1", KeyA); - message.SchemaId = null!; - - await rig.DeleteHandler(message, wire.Peer); - - var sent = wire.Sent.Single(); - var error = capable ? AssertRefused(sent, "d1", "message_invalid").ToV0() : (CultNetErrorMessage)sent; - Assert.That(error.Details!.Field, Is.EqualTo("schemaId")); - Assert.That(error.Error, Is.EqualTo("message_invalid: schemaId is required")); - } - - // An empty or blank schemaId or recordKey is refused like a frame that does not read (the Rust server's rule): message_invalid, - // details.field "message", the write's id, nothing applied. A null schemaId stays "schemaId is required". - [TestCase("", null)] - [TestCase(" ", null)] - [TestCase(null, "")] - [TestCase(null, " ")] - public async Task APutWithAnEmptyOrBlankSchemaIdOrRecordKeyIsRefusedAsUnreadableAndNotApplied(string? schemaId, string? recordKey) - { - var (rig, wire) = await Capable(); - var message = rig.PutOf("m1", KeyA, "one"); - if (schemaId != null) message.Document.SchemaId = schemaId; - if (recordKey != null) message.Document.RecordKey = recordKey; - wire.Sent.Clear(); - - await rig.PutHandler(message, wire.Peer); - - var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("message")); - Assert.That(rig.TextOf(KeyA), Is.Null); - } - - [TestCase("", "k")] - [TestCase(" ", "k")] - [TestCase("note.v0", "")] - public async Task ADeleteWithAnEmptyOrBlankSchemaIdOrRecordKeyIsRefusedAsUnreadable(string schemaId, string recordKey) - { - var (rig, wire) = await Capable(); - var message = rig.DeleteOf("d1", new CultRecordKey(recordKey)); - message.SchemaId = schemaId; - wire.Sent.Clear(); - - await rig.DeleteHandler(message, wire.Peer); - - var error = AssertRefused(wire.Sent.Single(), "d1", "message_invalid"); - Assert.That(error.Details!.Field, Is.EqualTo("message")); - } - - [Test] - public async Task ARefusalAtTheCapIsRememberedSoAResendReplaysItAndADifferentContentResendIsRefused() - { - var (rig, wire) = await Capable(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); - wire.Sent.Clear(); - - await rig.Put(wire, "over", KeyA, "x"); - await rig.Put(wire, "over", KeyA, "x"); - await rig.Put(wire, "over", KeyB, "other"); - - AssertRefused(wire.Sent[0], "over", "limit_reached"); - AssertRefused(wire.Sent[1], "over", "limit_reached"); - var changed = AssertRefused(wire.Sent[2], "over", "message_invalid"); - Assert.That(changed.Details!.Field, Is.EqualTo("messageId")); - Assert.That(rig.TextOf(KeyA), Is.Null); - } - - private static byte[] HashOf(string text) => CultNetWriteContentHash.Compute("put", "s", text, new byte[] { 1 }); - - [Test] - public void AnAbandonedClaimFreesNoSlotSoARefusedIdNeverBecomesAdmissible() - { - var session = new CultNetWriteReceiptSession(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - Assert.That(session.TryBegin($"w{index}", HashOf($"k{index}"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.New)); - Assert.That(session.TryBegin("over", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - - session.Abandoned("w0"); - - Assert.That(session.TryBegin("over", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - Assert.That(session.TryBegin("another", HashOf("a"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - // The abandoned id itself is judged afresh, in its own slot. - Assert.That(session.TryBegin("w0", HashOf("k0"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.New)); - } - - [Test] - public void TheRefusalSetIsBoundedAndNeverAdmitsAnId() - { - var session = new CultNetWriteReceiptSession(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - session.TryBegin($"w{index}", HashOf($"k{index}"), out _); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites + 5; index++) - Assert.That(session.TryBegin($"o{index}", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - - session.Abandoned("w1"); - - Assert.That(session.TryBegin("o1030", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - } - - [Test] - public void TheRefusalSetStopsRememberingAtItsBound() - { - var session = new CultNetWriteReceiptSession(); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) - session.TryBegin($"w{index}", HashOf($"k{index}"), out _); - for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedLimitRefusals; index++) - session.TryBegin($"o{index}", HashOf("o"), out _); - - // Past the bound a refusal is not remembered: its changed-content resend is simply refused at the limit again. - Assert.That(session.TryBegin("late", HashOf("a"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - Assert.That(session.TryBegin("late", HashOf("b"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); - // Inside the bound it is remembered. - Assert.That(session.TryBegin("o0", HashOf("changed"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.DifferentContent)); - } - - [Test] - public void AnAbandonedIdMayBeResentWithOtherContentBecauseItWasNeverSent() - { - var session = new CultNetWriteReceiptSession(); - session.TryBegin("m1", HashOf("a"), out _); - session.Abandoned("m1"); - - Assert.That(session.TryBegin("m1", HashOf("b"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.New)); - Assert.That(session.TryBegin("m1", HashOf("b"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Unanswered)); - } - - // ---- Fixed text in v0 errors: an exception's own words never reach the wire ---- - - private static Func HandlerOf(Rig rig, string field) => - (Func)typeof(CultNetDatabaseServer) - .GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(rig.DatabaseServer)!; - - [Test] - public async Task ASnapshotFaultIsAnsweredWithFixedTextNotTheExceptionMessage() - { - var rig = Build(); - var wire = new Wire(); - - await HandlerOf(rig, "_snapshotHandlerV1")( - new CultNetSnapshotRequestV1Message { MessageId = "s1", Selection = null! }, wire.Peer); - - var error = (CultNetErrorMessage)wire.Sent.Single(); - Assert.That(error.Error, Is.EqualTo("the snapshot request could not be answered")); - } - - [Test] - public async Task AShardLogFaultIsAnsweredWithFixedTextNotTheExceptionMessage() - { - var rig = Build(); - var wire = new Wire(); - - await HandlerOf(rig, "_shardLogHandler")( - new CultNetShardLogRequestMessage { MessageId = "l1", ShardId = string.Empty }, wire.Peer); - - var error = (CultNetErrorMessage)wire.Sent.Single(); - Assert.That(error.Error, Is.EqualTo("the shard log request could not be answered")); - } - - private static CultNetErrorMessage DeniedWith(string recordKey, string reason) => new() - { - Error = $"write_not_authorized: {reason}", - Code = "write_not_authorized", - Details = new CultNetErrorDetails { Field = "recordKey", Value = recordKey } - }; - - // write-receipt.md, What is remembered: a refusal is remembered as its code (and the field it names), never the strings of - // the write or of the authority's reason; the replay is built from fixed text and the resend's own fields. - [Test] - public void ARefusalIsReplayedFromFixedTextAndTheResendsOwnFieldsNotFromRememberedStrings() - { - var session = new CultNetWriteReceiptSession(); - var sent = new List(); - var longKey = new string('k', 50_000); - CultNetReceiptedWrite Write() => new(session, sent.Add, "put", "m1", "schema.v1", longKey, [1]); - - var first = Write(); - Assert.That(first.Begin(), Is.True); - first.Refused(DeniedWith(longKey, new string('r', 50_000))); - Assert.That(Write().Begin(), Is.False); - - var again = (CultNetErrorV1Message)sent[1]; - Assert.That(again.MessageId, Is.EqualTo("m1")); - Assert.That(again.Code, Is.EqualTo("write_not_authorized")); - Assert.That(again.Error, Is.EqualTo("write_not_authorized")); - Assert.That(again.Details!.Field, Is.EqualTo("recordKey")); - Assert.That(again.Details.Value, Is.EqualTo(longKey)); - var firstAnswer = (CultNetErrorV1Message)sent[0]; - Assert.That(firstAnswer.Error, Is.EqualTo(again.Error), "the replay is the refusal again"); - Assert.That(firstAnswer.Details!.Value, Is.EqualTo(again.Details.Value)); - } - - [Test] - public void ThousandsOfRefusalsOf50kCharacterKeysAreRememberedInBoundedMemory() - { - var session = new CultNetWriteReceiptSession(); - var before = GC.GetTotalMemory(true); - for (var i = 0; i < CultNetWriteReceiptSession.MaxRememberedWrites; i++) - { - var key = new string('k', 50_000) + i; - var write = new CultNetReceiptedWrite(session, _ => { }, "put", $"m{i}", "schema.v1", key, [1]); - Assert.That(write.Begin(), Is.True); - write.Refused(DeniedWith(key, key)); - } - - // Held as strings, the 1024 keys and reasons alone would be over 200 MB. - var growth = GC.GetTotalMemory(true) - before; - GC.KeepAlive(session); - Assert.That(growth, Is.LessThan(8L * 1024 * 1024)); - } - - // A remembered refusal is replayed with the details its code carries: the schema id of an unowned write, no details - // at all for the routing refusal. - [Test] - public void AReplayedRefusalCarriesTheDetailsOfItsCodeAndNoOthers() - { - var session = new CultNetWriteReceiptSession(); - var sent = new List(); - CultNetReceiptedWrite Write(string id) => new(session, sent.Add, "put", id, "schema.v1", "note:1", [1]); - - var unowned = Write("u1"); - Assert.That(unowned.Begin(), Is.True); - unowned.Refused(CultNetErrorMessage.ForUnownedSchema(new CultNetUnownedSchemaException("schema.v1", new CultRecordKey("note:1")))); - Assert.That(Write("u1").Begin(), Is.False); - - var routed = Write("g1"); - Assert.That(routed.Begin(), Is.True); - routed.Refused(new CultNetErrorMessage - { - Error = CultNetDatabaseServer.RoutingRefusalText, - RoutingHint = new CultNetShardRoutingHint { Reason = "not_primary" } - }); - Assert.That(Write("g1").Begin(), Is.False); - - var unownedAgain = (CultNetErrorV1Message)sent[1]; - Assert.That(unownedAgain.Code, Is.EqualTo("unowned_schema")); - Assert.That(unownedAgain.Details!.Field, Is.EqualTo("schemaId")); - Assert.That(unownedAgain.Details.Value, Is.EqualTo("schema.v1")); - Assert.That(unownedAgain.Error, Is.EqualTo(((CultNetErrorV1Message)sent[0]).Error)); - var routedAgain = (CultNetErrorV1Message)sent[3]; - Assert.That(routedAgain.Details, Is.Null); - Assert.That(routedAgain.RoutingHint!.Reason, Is.EqualTo("not_primary")); - Assert.That(routedAgain.Error, Is.EqualTo(CultNetDatabaseServer.RoutingRefusalText)); - } - - // A write that carries no record key or schema id has none to echo: the detail value is null, not an empty string. - [Test] - public void AReplayedRefusalOfAWriteWithoutTheNamedFieldCarriesANullValue() - { - var session = new CultNetWriteReceiptSession(); - var sent = new List(); - CultNetReceiptedWrite Write() => new(session, sent.Add, "delete", "m1", null, null, null); - var first = Write(); - Assert.That(first.Begin(), Is.True); - first.Refused(DeniedWith("ignored", "reason")); - Assert.That(Write().Begin(), Is.False); - - foreach (var message in sent) - { - var error = (CultNetErrorV1Message)message; - Assert.That(error.Details!.Field, Is.EqualTo("recordKey")); - Assert.That(error.Details.Value, Is.Null); - } - - var unowned = new CultNetReceiptedWrite(session, sent.Add, "delete", "m2", null, null, null); - Assert.That(unowned.Begin(), Is.True); - unowned.Refused(CultNetErrorMessage.ForUnownedSchema(new CultNetUnownedSchemaException("x", new CultRecordKey("k")))); - Assert.That(((CultNetErrorV1Message)sent[^1]).Details!.Field, Is.EqualTo("schemaId")); - Assert.That(((CultNetErrorV1Message)sent[^1]).Details!.Value, Is.Null); - } - } -} diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 606d5625..5a1f570f 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -5189,9 +5189,7 @@ namespace GameCult.Networking.Tests [Test] public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort() { - var fake = new CapturingSchemaClient(message => message is CultNetDocumentDeleteMessage delete - ? new CultNetWriteAdmittedMessage { MessageId = delete.MessageId, Operation = "delete", SchemaId = delete.SchemaId, RecordKey = delete.RecordKey } - : null); + var fake = new CapturingSchemaClient(_ => null); var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake @@ -5205,107 +5203,16 @@ namespace GameCult.Networking.Tests await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { - MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" }); - var hello = (CultNetHelloMessage)fake.SentMessages[0]; - Assert.That(hello.SupportedMessageVersions, Is.EquivalentTo(new[] { CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1 }), - "the hello opts the forwarding connection in before the write"); - var sent = (CultNetDocumentDeleteMessage)fake.SentMessages[1]; - Assert.That(sent.MessageId, Is.EqualTo("fwd-del"), "the write goes under its original id"); + var sent = (CultNetDocumentDeleteMessage)fake.SentMessages.Single(); Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test")); Assert.That(sent.ShardId, Is.EqualTo("notes")); 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 void CultNetSchemaWriteForwarder_SilenceIsUnconfirmedNotSuccess() - { - 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; - - var unconfirmed = Assert.ThrowsAsync(() => - forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); - - Assert.That(unconfirmed!.MessageId, Is.EqualTo("fwd-del")); - Assert.That(DateTime.UtcNow - started, Is.GreaterThanOrEqualTo(TimeSpan.FromMilliseconds(380))); - } - - [Test] - public void CultNetSchemaWriteForwarder_AnswersNamingAnotherWriteSettleNothing() - { - var fake = new CapturingSchemaClient(_ => null); - var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions - { - CreateClient = () => fake, - ResultWindow = TimeSpan.FromMilliseconds(300) - }); - var shard = new CultNetShardDescriptor( - "notes", "primary", 3, isPrimary: false, - primaryEndpoints: ["cultnet://primary.example.test:4075"]); - fake.OnSend = () => - { - fake.Deliver(new CultNetWriteAdmittedMessage { MessageId = "someone-else", Operation = "delete", SchemaId = "note.v0", RecordKey = "note:1" }); - fake.Deliver(new CultNetErrorV1Message { Error = "refused", Code = "write_not_authorized", MessageId = "someone-else" }); - }; - - Assert.ThrowsAsync(() => - forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); - } - - [Test] - public void CultNetSchemaWriteForwarder_RejectsWithTheErrorV1NamingItsWrite() - { - var fake = new CapturingSchemaClient(_ => null); - var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions { CreateClient = () => fake }); - var shard = new CultNetShardDescriptor( - "notes", "primary", 3, isPrimary: false, - primaryEndpoints: ["cultnet://primary.example.test:4075"]); - fake.OnSend = () => fake.Deliver(new CultNetErrorV1Message { Error = "refused", Code = "write_not_authorized", MessageId = "fwd-del" }); - - var refused = Assert.ThrowsAsync(() => - forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" })); - - Assert.That(refused!.Code, Is.EqualTo("write_not_authorized")); - } - [Test] public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets() { @@ -7551,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; } @@ -7575,7 +7474,6 @@ namespace GameCult.Networking.Tests where T : ICultNetSchemaMessage { SentMessages.Add(message); - OnSend?.Invoke(); var response = _respond(message); if (response == null) {