Compare commits
64 changed files with 270 additions and 11552 deletions
|
|
@ -115,7 +115,6 @@ copies), the C# `CultNetErrorMessage` factory, the Rust `CultNetErrorCode`, and
|
||||||
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
|
`unowned_schema` (a write, or a remote put or delete, for a schema and key no shard owns),
|
||||||
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
|
`write_not_authorized` (a peer's put or delete its server's remote write authority did not grant) and
|
||||||
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) and
|
`unsupported_schema_version` (a message whose `schemaVersion` the peer does not speak; `details` names `schemaVersion` and the offered value) 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).
|
`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.
|
A malformed put or delete is `message_invalid`, not a per-operation code: the peer knows which operation it sent.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
|
@ -1,89 +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.
|
|
||||||
|
|
||||||
**A `write_admitted` `messageId` is valid only if it contains at least one Unicode letter (`\p{L}`) or number (`\p{N}`) and no Default_Ignorable_Code_Point (DerivedCoreProperties).** The compare is raw: the id is echoed byte for byte, so a decoder never trims or normalizes it (`" a "` is valid; U+2800 is not, and neither is `a` followed by U+200B). Unlike the Required fields rule, this is not a White_Space test. The letters, numbers and Default_Ignorable code points are those of **Unicode 16.0** in every runtime, taken from tables embedded in each runtime and generated by `scripts/gen-unicode-messageid-tables.py` from `DerivedGeneralCategory.txt` (Lu Ll Lt Lm Lo Nd Nl No) and `DerivedCoreProperties.txt`; no runtime asks its platform's Unicode data, so a runtime's own Unicode version cannot change a result. Bumping the version is one coordinated change: regenerate the five tables and update the vectors that pin the version in the same commit. The rule is on the decoded string, which must be well-formed Unicode: a lone surrogate refuses. A runtime whose MessagePack reader rejects invalid UTF-8 refuses the frame earlier; byte-level strictness is a property of the reader and is not required of a decoder. A frame with more than one `messageId` key is malformed: a decoder that can see the duplicate (the C# and Rust decoders) refuses it; a decoder that collapses the map (Python, TypeScript, Kotlin) judges the value it ends up with, the last key, and refuses if that value is invalid, so no decoder echoes an invalid id (vectors `admittedMessageIdDuplicates` declare each policy and every runner asserts its own). A decoder refuses an invalid id the way it refuses an empty or ill-typed one; the rule is for `write_admitted` only and `error.v1` is unchanged.
|
|
||||||
|
|
||||||
**Where a received `messageId` decides, and what that relies on.** Beyond echoing it, a received `messageId` is used in exactly two places. On a server, the remembered-write key `(actor, messageId)` with the content hash, the per-session limit and the replay of rule 5 are taken from the id of the write frame the server itself parsed. On a client, the write tracker attributes a `write_admitted` or `error.v1` to a write by equality with the id of a write that client itself sent (a frame naming an id not in flight settles nothing), and attributes an id-less error by the count of unanswered writes, not by any id. Both rely on the receiver being the only parser of that frame: a decoder's `messageId` on a frame that carries more than one `messageId` key is not trusted for any authorization, dedupe, replay or ordering decision, and the map-collapsing runtimes (Python, TypeScript, Kotlin, and C#'s typed deserializer on write frames) cannot see the duplicate. If a frame is ever parsed by two runtimes in a chain (a relay, proxy or forwarder that re-reads it), or a consumer needs `messageId` for authorization, dedupe, replay or ordering beyond echo and equality with a self-sent id, all runtimes must first refuse a duplicate `messageId` key (a spec change plus byte-level duplicate detection in the runtimes that decode to a map) before that use lands. Today Rust refuses a repeated key on write frames and on `write_admitted` (serde's `duplicate field messageId`, and `refuse_duplicate_keys` for the raw-list frames such as `document_put_raw`), though its `readable_message_id` names the first key when it names an unreadable frame; C# does not on write frames (a duplicate-key write is deduped on its last id, while the id it names in a refusal for an unreadable frame is read from the first key), and the Python server (`cultmesh_py/server.py`) only echoes the id while the TypeScript and Kotlin code matches it by equality with a request it sent.
|
|
||||||
|
|
||||||
**`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 put or delete that is 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"`, the fixed unreadable text 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. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case.
|
|
||||||
- **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: the message could not be read` (a frame that does not decode, or a write that names nothing; the text is the same for both and never carries a decoder's own wording), `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. The server refuses a sequenced write whenever it can read the write's id: from the delivered frame, and from a superseded packet that carries its whole frame. A write sent as several fragments whose frame is superseded is dropped whole and may go unanswered; the client's unconfirmed outcome covers it. A repeat of a packet the server already took, or a packet too far ahead to be taken, draws no further answer (rule 1).
|
|
||||||
- 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; a write registered after the session ended settles this way when it is sent) 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. It ends when the write's age reaches it: an answer arriving at exactly that instant is late. 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 `blankFields` (server input cases for the Required fields rule: which document fields make a put unreadable, which are accepted), `admittedMessageId` (the `write_admitted` id rule above: code-point lists, each marked refuse or accept; a lone surrogate, and a frame that repeats the key, are carried as whole frames in `wireHex`, because a lone surrogate is not valid UTF-8 and only some runtimes' strings can hold it; `write-admitted-message-id-ranges.json` lists every range of the embedded tables, and each runtime probes the first and last point of each range and the points either side), `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.
|
|
||||||
|
|
||||||
Cross-runtime check: `packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts` (`npm run test:interop` in `packages/cultmesh-ts`) runs the TypeScript client (`WriteTracker`, the decoder, the one-shot writer) against the real Rust RUDP document server and the real `CultNetDatabaseServer` over loopback, one scenario set against both. Known gap it records as a todo: the Rust RUDP document server refuses every delete (`write_not_authorized: the server does not take deletes`), where this spec has an admitted delete receipted like a put.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
@ -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: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: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-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 |
|
| `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: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`) |
|
| `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/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) |
|
| `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 |
|
| C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None |
|
||||||
|
|
|
||||||
|
|
@ -474,14 +474,3 @@ powershell -ExecutionPolicy Bypass -File .\packages\cultmesh-kotlin\build.ps1
|
||||||
The build writes `artifacts/cultmesh-kotlin/cultmesh-kotlin.jar`.
|
The build writes `artifacts/cultmesh-kotlin/cultmesh-kotlin.jar`.
|
||||||
It also runs the built-in RUDP packet fixture and localhost UDP socket
|
It also runs the built-in RUDP packet fixture and localhost UDP socket
|
||||||
self-test.
|
self-test.
|
||||||
|
|
||||||
## Write Receipt
|
|
||||||
|
|
||||||
`WriteReceipt.kt` decodes `cultnet.write_admitted.v0` and `cultnet.error.v1` (and `error.v0`) strictly against their
|
|
||||||
closed schemas (`decodeWriteAdmitted`, `decodeErrorFrame`), computes the dedupe `writeContentHash`, tests a hello with
|
|
||||||
`helloAdvertisesWriteReceipts`, and settles sent writes as admitted, refused or unconfirmed in `WriteTracker`
|
|
||||||
(pure state machine, abstract clock; the caller owns transport and timers). Kotlin has no write client yet, so nothing
|
|
||||||
sends a put and waits on this. `WriteReceiptVectors.kt` replays
|
|
||||||
`contracts/cultnet/interop/write-receipt-vectors.json` (constants, contentHash, wire, schemaInvalid, scenarios; the
|
|
||||||
server-side `dedupe` and `blankFields` sections are not run); `build.ps1` runs it after the self-test, or run
|
|
||||||
`CultMeshKt write-receipt-vectors <path>`.
|
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,4 @@ $sources = Get-ChildItem $src -Recurse -Filter *.kt | Select-Object -ExpandPrope
|
||||||
if ($LASTEXITCODE -ne 0) { throw "kotlinc failed with exit code $LASTEXITCODE" }
|
if ($LASTEXITCODE -ne 0) { throw "kotlinc failed with exit code $LASTEXITCODE" }
|
||||||
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt
|
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt
|
||||||
if ($LASTEXITCODE -ne 0) { throw "cultmesh-kotlin self-test failed with exit code $LASTEXITCODE" }
|
if ($LASTEXITCODE -ne 0) { throw "cultmesh-kotlin self-test failed with exit code $LASTEXITCODE" }
|
||||||
$vectors = Join-Path $repoRoot "contracts\cultnet\interop\write-receipt-vectors.json"
|
|
||||||
& $java -cp "$jarPath;$kotlinStdlib" org.gamecult.cultmesh.CultMeshKt write-receipt-vectors $vectors
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "write-receipt vector replay failed with exit code $LASTEXITCODE" }
|
|
||||||
Write-Host "Built $jarPath"
|
Write-Host "Built $jarPath"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import java.net.Socket
|
||||||
import java.net.SocketTimeoutException
|
import java.net.SocketTimeoutException
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.charset.CodingErrorAction
|
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
|
@ -1101,9 +1100,9 @@ data class CultNetRawDocumentRecord(
|
||||||
"payloadEncoding" to payloadEncoding,
|
"payloadEncoding" to payloadEncoding,
|
||||||
"payload" to payload.copyOf(),
|
"payload" to payload.copyOf(),
|
||||||
)
|
)
|
||||||
if (!isBlankText(sourceRuntimeId)) wire["sourceRuntimeId"] = sourceRuntimeId
|
if (!sourceRuntimeId.isNullOrBlank()) wire["sourceRuntimeId"] = sourceRuntimeId
|
||||||
if (!isBlankText(sourceAgentId)) wire["sourceAgentId"] = sourceAgentId
|
if (!sourceAgentId.isNullOrBlank()) wire["sourceAgentId"] = sourceAgentId
|
||||||
if (!isBlankText(sourceRole)) wire["sourceRole"] = sourceRole
|
if (!sourceRole.isNullOrBlank()) wire["sourceRole"] = sourceRole
|
||||||
if (tags.isNotEmpty()) wire["tags"] = tags
|
if (tags.isNotEmpty()) wire["tags"] = tags
|
||||||
return wire
|
return wire
|
||||||
}
|
}
|
||||||
|
|
@ -2365,9 +2364,6 @@ fun parseCultNetMessage(payload: ByteArray): CultNetMessage {
|
||||||
if (schemaVersion !is String || schemaVersion.isBlank()) {
|
if (schemaVersion !is String || schemaVersion.isBlank()) {
|
||||||
throw IOException("CultNet schema-v0 messages must declare schemaVersion")
|
throw IOException("CultNet schema-v0 messages must declare schemaVersion")
|
||||||
}
|
}
|
||||||
// A write_admitted id is echoed byte for byte, so that frame alone is read again with invalid UTF-8 refused; document payload
|
|
||||||
// strings elsewhere keep decoding as before.
|
|
||||||
if (schemaVersion == WRITE_ADMITTED_VERSION) MessagePackReader(payload, strictUtf8 = true).readAny()
|
|
||||||
val body = linkedMapOf<String, Any?>()
|
val body = linkedMapOf<String, Any?>()
|
||||||
for ((key, value) in decoded) {
|
for ((key, value) in decoded) {
|
||||||
if (key !is String) throw IOException("CultNet schema-v0 message keys must be strings")
|
if (key !is String) throw IOException("CultNet schema-v0 message keys must be strings")
|
||||||
|
|
@ -3417,7 +3413,6 @@ fun main(args: Array<String>) {
|
||||||
rudpClientBindsLoopbackOnlyForALoopbackEndpoint()
|
rudpClientBindsLoopbackOnlyForALoopbackEndpoint()
|
||||||
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
|
rudpSocketTransportHandshakesAndCarriesReliableOrderedSchemaFrames()
|
||||||
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
|
rudpSocketTransportCarriesFragmentedReliableOrderedSchemaFrames()
|
||||||
rawDocumentRecordDropsOnlyWhiteSpaceSourceIds()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3429,7 +3424,6 @@ fun main(args: Array<String>) {
|
||||||
"rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options)
|
"rudp-dial-message-once" -> rudpDialSchemaMessageOnce(options)
|
||||||
"serve" -> interopServe(options)
|
"serve" -> interopServe(options)
|
||||||
"dial" -> interopDial(options)
|
"dial" -> interopDial(options)
|
||||||
"write-receipt-vectors" -> if (!replayWriteReceiptVectors(args.getOrNull(1) ?: error("usage: write-receipt-vectors <vectors.json>"))) kotlin.system.exitProcess(1)
|
|
||||||
else -> error("Unknown mode ${args[0]}")
|
else -> error("Unknown mode ${args[0]}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6155,8 +6149,7 @@ private fun requireSchemaResponse(message: CultNetMessage?, expectedSchemaVersio
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
/** [strictUtf8] makes a str whose bytes are not UTF-8 throw instead of becoming U+FFFD; only the write_admitted frame asks for it. */
|
class MessagePackReader(payload: ByteArray) {
|
||||||
class MessagePackReader(payload: ByteArray, private val strictUtf8: Boolean = false) {
|
|
||||||
private val input = DataInputStream(ByteArrayInputStream(payload))
|
private val input = DataInputStream(ByteArrayInputStream(payload))
|
||||||
private var pushed = -1
|
private var pushed = -1
|
||||||
|
|
||||||
|
|
@ -6311,10 +6304,7 @@ class MessagePackReader(payload: ByteArray, private val strictUtf8: Boolean = fa
|
||||||
code == 0xdb -> input.readInt()
|
code == 0xdb -> input.readInt()
|
||||||
else -> throw IOException("expected string")
|
else -> throw IOException("expected string")
|
||||||
}
|
}
|
||||||
val bytes = readPayload(length)
|
return String(readPayload(length), StandardCharsets.UTF_8)
|
||||||
if (!strictUtf8) return String(bytes, StandardCharsets.UTF_8)
|
|
||||||
return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT)
|
|
||||||
.decode(ByteBuffer.wrap(bytes)).toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun readBinaryAfterCode(code: Int): ByteArray {
|
private fun readBinaryAfterCode(code: Int): ByteArray {
|
||||||
|
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
|
||||||
|
|
||||||
package org.gamecult.cultmesh
|
|
||||||
|
|
||||||
// (low, high) pairs, flattened and sorted.
|
|
||||||
internal val LETTER_OR_NUMBER: IntArray = intArrayOf(
|
|
||||||
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
|
|
||||||
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
|
|
||||||
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
|
|
||||||
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
|
|
||||||
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
|
|
||||||
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
|
|
||||||
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
|
|
||||||
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
|
|
||||||
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
|
|
||||||
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
|
|
||||||
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
|
|
||||||
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
|
|
||||||
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
|
|
||||||
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
|
|
||||||
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
|
|
||||||
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
|
|
||||||
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
|
|
||||||
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
|
|
||||||
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
|
|
||||||
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
|
|
||||||
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
|
|
||||||
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A,
|
|
||||||
0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081,
|
|
||||||
0x108E, 0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248,
|
|
||||||
0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0,
|
|
||||||
0x12B2, 0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315,
|
|
||||||
0x1318, 0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F,
|
|
||||||
0x1681, 0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
|
|
||||||
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819,
|
|
||||||
0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D,
|
|
||||||
0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89,
|
|
||||||
0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5,
|
|
||||||
0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC,
|
|
||||||
0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45,
|
|
||||||
0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
|
|
||||||
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC,
|
|
||||||
0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102,
|
|
||||||
0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128,
|
|
||||||
0x212A, 0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B,
|
|
||||||
0x24EA, 0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25,
|
|
||||||
0x2D27, 0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
|
|
||||||
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F,
|
|
||||||
0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA,
|
|
||||||
0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229,
|
|
||||||
0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD,
|
|
||||||
0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788,
|
|
||||||
0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A,
|
|
||||||
0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB,
|
|
||||||
0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4,
|
|
||||||
0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A,
|
|
||||||
0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD,
|
|
||||||
0xAAE0, 0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E,
|
|
||||||
0xAB30, 0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB,
|
|
||||||
0xF900, 0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
|
|
||||||
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F,
|
|
||||||
0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A,
|
|
||||||
0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D,
|
|
||||||
0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107,
|
|
||||||
0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300,
|
|
||||||
0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1,
|
|
||||||
0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530,
|
|
||||||
0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3,
|
|
||||||
0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760,
|
|
||||||
0x10767, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A,
|
|
||||||
0x10835, 0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7,
|
|
||||||
0x108AF, 0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
|
|
||||||
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60,
|
|
||||||
0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40,
|
|
||||||
0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0,
|
|
||||||
0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80,
|
|
||||||
0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70,
|
|
||||||
0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075,
|
|
||||||
0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144,
|
|
||||||
0x11144, 0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0,
|
|
||||||
0x111DA, 0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280,
|
|
||||||
0x11286, 0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0,
|
|
||||||
0x112F9, 0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
|
|
||||||
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E,
|
|
||||||
0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447,
|
|
||||||
0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0,
|
|
||||||
0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680,
|
|
||||||
0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740,
|
|
||||||
0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915,
|
|
||||||
0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA,
|
|
||||||
0x119D0, 0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50,
|
|
||||||
0x11A50, 0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00,
|
|
||||||
0x11C08, 0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08,
|
|
||||||
0x11D09, 0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
|
|
||||||
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12,
|
|
||||||
0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480,
|
|
||||||
0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100,
|
|
||||||
0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0,
|
|
||||||
0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63,
|
|
||||||
0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50,
|
|
||||||
0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF,
|
|
||||||
0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150,
|
|
||||||
0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80,
|
|
||||||
0x1BC88, 0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400,
|
|
||||||
0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
|
|
||||||
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516,
|
|
||||||
0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552,
|
|
||||||
0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736,
|
|
||||||
0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE,
|
|
||||||
0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140,
|
|
||||||
0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0,
|
|
||||||
0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0,
|
|
||||||
0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71,
|
|
||||||
0x1ECAB, 0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05,
|
|
||||||
0x1EE1F, 0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39,
|
|
||||||
0x1EE39, 0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
|
|
||||||
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D,
|
|
||||||
0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74,
|
|
||||||
0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5,
|
|
||||||
0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740,
|
|
||||||
0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350,
|
|
||||||
0x323AF,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal val DEFAULT_IGNORABLE: IntArray = intArrayOf(
|
|
||||||
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
|
|
||||||
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
|
|
||||||
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
|
|
||||||
)
|
|
||||||
|
|
@ -1,286 +0,0 @@
|
||||||
package org.gamecult.cultmesh
|
|
||||||
|
|
||||||
// The write receipt (contracts/cultnet/interop/write-receipt.md): the two frames, their strict decode, the dedupe
|
|
||||||
// content hash, the hello capability test and the client settle rules. Mirrors packages/cultnet-ts (contracts.ts,
|
|
||||||
// write-tracker.ts) and packages/cultnet-rs (write_content_hash). The replay of write-receipt-vectors.json lives in
|
|
||||||
// WriteReceiptVectors.kt. Server rules (dedupe, limits, blank fields) are not a decoder's business and are not here.
|
|
||||||
|
|
||||||
import java.io.IOException
|
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
const val WRITE_ADMITTED_VERSION = "cultnet.write_admitted.v0"
|
|
||||||
const val ERROR_V1_VERSION = "cultnet.error.v1"
|
|
||||||
const val ERROR_V0_VERSION = "cultnet.error.v0"
|
|
||||||
|
|
||||||
/** A connection is receipt-capable only when its hello lists both frames. */
|
|
||||||
val CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS: List<String> = listOf(WRITE_ADMITTED_VERSION, ERROR_V1_VERSION)
|
|
||||||
|
|
||||||
const val RECEIPT_RESULT_WINDOW_MS = 5000L
|
|
||||||
const val LEGACY_RESULT_WINDOW_MS = 500L
|
|
||||||
|
|
||||||
fun resultWindowMs(serverAdvertisesReceipts: Boolean): Long =
|
|
||||||
if (serverAdvertisesReceipts) RECEIPT_RESULT_WINDOW_MS else LEGACY_RESULT_WINDOW_MS
|
|
||||||
|
|
||||||
/** The closed refusal-code set of error.v0; error.v1 adds `limit_reached`. */
|
|
||||||
val CULTNET_ERROR_V0_CODES: Set<String> = setOf(
|
|
||||||
"selection_invalid", "cursor_stale", "cursor_invalid", "reference_outside_target", "variant_unsupported",
|
|
||||||
"unowned_schema", "write_not_authorized", "unsupported_schema_version", "message_invalid",
|
|
||||||
)
|
|
||||||
val CULTNET_ERROR_V1_CODES: Set<String> = CULTNET_ERROR_V0_CODES + "limit_reached"
|
|
||||||
|
|
||||||
fun helloAdvertisesWriteReceipts(hello: CultNetMessage): Boolean {
|
|
||||||
val versions = hello.body["supportedMessageVersions"] as? List<*> ?: return false
|
|
||||||
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.all { it in versions }
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CultNetWriteAdmitted(val messageId: String, val operation: String, val schemaId: String, val recordKey: String) {
|
|
||||||
fun toMessage() = CultNetMessage(
|
|
||||||
WRITE_ADMITTED_VERSION,
|
|
||||||
linkedMapOf("messageId" to messageId, "operation" to operation, "schemaId" to schemaId, "recordKey" to recordKey),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
data class CultNetErrorDetails(val field: String?, val value: String?, val asOf: Long?, val current: Long?)
|
|
||||||
|
|
||||||
/** error.v0 (`messageId` always null, `version` 0) or error.v1 (`version` 1). */
|
|
||||||
data class CultNetErrorFrame(
|
|
||||||
val version: Int,
|
|
||||||
val error: String,
|
|
||||||
val routingHint: Map<String, Any?>?,
|
|
||||||
val code: String?,
|
|
||||||
val details: CultNetErrorDetails?,
|
|
||||||
val messageId: String?,
|
|
||||||
) {
|
|
||||||
fun toMessage(): CultNetMessage {
|
|
||||||
val body = linkedMapOf<String, Any?>(
|
|
||||||
"error" to error,
|
|
||||||
"routingHint" to routingHint,
|
|
||||||
"code" to code,
|
|
||||||
"details" to details?.let {
|
|
||||||
linkedMapOf("field" to it.field, "value" to it.value, "asOf" to it.asOf, "current" to it.current)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (version == 1) body["messageId"] = messageId
|
|
||||||
return CultNetMessage(if (version == 1) ERROR_V1_VERSION else ERROR_V0_VERSION, body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strict decode of a write_admitted.v0 message against its closed schema: exactly five keys, a non-empty string each,
|
|
||||||
* operation put|delete.
|
|
||||||
*/
|
|
||||||
fun decodeWriteAdmitted(message: CultNetMessage): CultNetWriteAdmitted {
|
|
||||||
if (message.schemaVersion != WRITE_ADMITTED_VERSION) throw IOException("not a $WRITE_ADMITTED_VERSION message")
|
|
||||||
closedKeys(message, setOf("messageId", "operation", "schemaId", "recordKey"))
|
|
||||||
val operation = nonEmpty(message, "operation")
|
|
||||||
if (operation != "put" && operation != "delete") throw IOException("operation must be put or delete")
|
|
||||||
return CultNetWriteAdmitted(admittedMessageId(message), operation, nonEmpty(message, "schemaId"), nonEmpty(message, "recordKey"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// write-receipt.md: a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no Default_Ignorable code point anywhere, as of
|
|
||||||
// Unicode 16.0, compared raw (never trimmed). The tables are embedded (UnicodeMessageIdTables.kt, generated by
|
|
||||||
// scripts/gen-unicode-messageid-tables.py), not the JDK's, so the running JVM's Unicode version changes no result; the other
|
|
||||||
// four runtimes embed the same generated tables.
|
|
||||||
private fun inRanges(ranges: IntArray, point: Int): Boolean {
|
|
||||||
var low = 0
|
|
||||||
var high = ranges.size / 2 - 1
|
|
||||||
while (low <= high) {
|
|
||||||
val mid = (low + high) ushr 1
|
|
||||||
when {
|
|
||||||
point < ranges[2 * mid] -> high = mid - 1
|
|
||||||
point > ranges[2 * mid + 1] -> low = mid + 1
|
|
||||||
else -> return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun admittedMessageId(message: CultNetMessage): String {
|
|
||||||
val id = nonEmpty(message, "messageId")
|
|
||||||
var hasLetterOrNumber = false
|
|
||||||
id.codePoints().forEach { cp ->
|
|
||||||
if (inRanges(DEFAULT_IGNORABLE, cp)) throw IOException("messageId holds a Default_Ignorable code point")
|
|
||||||
if (inRanges(LETTER_OR_NUMBER, cp)) hasLetterOrNumber = true
|
|
||||||
}
|
|
||||||
if (!hasLetterOrNumber) throw IOException("messageId must contain a letter or number")
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Strict decode of error.v0 (no messageId, no limit_reached) or error.v1 (messageId required, string or nil, never empty). */
|
|
||||||
fun decodeErrorFrame(message: CultNetMessage): CultNetErrorFrame {
|
|
||||||
val version = when (message.schemaVersion) {
|
|
||||||
ERROR_V1_VERSION -> 1
|
|
||||||
ERROR_V0_VERSION -> 0
|
|
||||||
else -> throw IOException("not an error message")
|
|
||||||
}
|
|
||||||
closedKeys(message, setOf("error", "routingHint", "code", "details") + if (version == 1) setOf("messageId") else emptySet())
|
|
||||||
val error = nonEmpty(message, "error")
|
|
||||||
val codeValue = message.body["code"]
|
|
||||||
if (codeValue != null && (codeValue !is String || codeValue !in (if (version == 1) CULTNET_ERROR_V1_CODES else CULTNET_ERROR_V0_CODES))) {
|
|
||||||
throw IOException("unknown error code")
|
|
||||||
}
|
|
||||||
var messageId: String? = null
|
|
||||||
if (version == 1) {
|
|
||||||
val id = message.body["messageId"]
|
|
||||||
if (id != null && (id !is String || id.isEmpty())) throw IOException("messageId must be a non-empty string or nil")
|
|
||||||
messageId = id as String?
|
|
||||||
}
|
|
||||||
return CultNetErrorFrame(version, error, routingHintOf(message.body["routingHint"]), codeValue as String?, detailsOf(message.body["details"]), messageId)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun closedKeys(message: CultNetMessage, required: Set<String>) {
|
|
||||||
if (!message.body.keys.containsAll(required)) throw IOException("${message.schemaVersion} is missing ${required - message.body.keys}")
|
|
||||||
val unknown = message.body.keys - required
|
|
||||||
if (unknown.isNotEmpty()) throw IOException("${message.schemaVersion} carries unknown keys $unknown")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nonEmpty(message: CultNetMessage, key: String): String {
|
|
||||||
val value = message.body[key]
|
|
||||||
if (value !is String || value.isEmpty()) throw IOException("$key must be a non-empty string")
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun routingHintOf(value: Any?): Map<String, Any?>? {
|
|
||||||
if (value == null) return null
|
|
||||||
if (value !is Map<*, *> || value.keys != setOf("shard", "reason")) throw IOException("routingHint must be nil or {shard, reason}")
|
|
||||||
if (value["shard"] != null && value["shard"] !is Map<*, *>) throw IOException("routingHint.shard must be an object or nil")
|
|
||||||
if (value["reason"] !is String) throw IOException("routingHint.reason must be a string")
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
return value as Map<String, Any?>
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun detailsOf(value: Any?): CultNetErrorDetails? {
|
|
||||||
if (value == null) return null
|
|
||||||
if (value !is Map<*, *> || value.keys != setOf("field", "value", "asOf", "current")) {
|
|
||||||
throw IOException("details must be nil or {field, value, asOf, current}")
|
|
||||||
}
|
|
||||||
fun text(key: String): String? = value[key].let { if (it == null || it is String) it as String? else throw IOException("details.$key must be a string or nil") }
|
|
||||||
fun cursor(key: String): Long? = value[key].let {
|
|
||||||
if (it == null) null else if (it is Long && it >= 0) it else throw IOException("details.$key must be a non-negative integer or nil")
|
|
||||||
}
|
|
||||||
return CultNetErrorDetails(text("field"), text("value"), cursor("asOf"), cursor("current"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** sha256 of the MessagePack array [operation, schemaId, recordKey, payload]: payload a bin for a put, nil for a delete. Lowercase hex. */
|
|
||||||
fun writeContentHash(operation: String, schemaId: String, recordKey: String, payload: ByteArray?): String {
|
|
||||||
val writer = MessagePackWriter().array(4).string(operation).string(schemaId).string(recordKey)
|
|
||||||
if (payload == null) writer.nil() else writer.binary(payload)
|
|
||||||
return MessageDigest.getInstance("SHA-256").digest(writer.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Why nothing settled a write. */
|
|
||||||
enum class WriteUnconfirmedReason(val wire: String) { WINDOW_ELAPSED("window_elapsed"), SESSION_ENDED("session_ended"), LOCAL_SEND_FAILED("local_send_failed") }
|
|
||||||
|
|
||||||
/** What a late answer is recorded as on a write that already settled unconfirmed. */
|
|
||||||
enum class WriteRecorded(val wire: String) { ADMITTED_LATE("admitted_late"), REFUSED_LATE("refused_late") }
|
|
||||||
|
|
||||||
/** What the caller was told. Settled once; never changed by a later frame. */
|
|
||||||
sealed class WriteSettlement {
|
|
||||||
object Admitted : WriteSettlement()
|
|
||||||
data class Refused(val code: String?, val error: String, val response: CultNetErrorFrame) : WriteSettlement()
|
|
||||||
data class Unconfirmed(val reason: WriteUnconfirmedReason) : WriteSettlement()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The client half of the receipt rules: a pure state machine with an abstract clock, one decision, how each sent write
|
|
||||||
* settles. Transport, timers and decoding belong to the caller; `frame` takes a message and decodes it strictly.
|
|
||||||
*/
|
|
||||||
class WriteTracker(private val windowMs: Long, private val onSettle: (String, WriteSettlement) -> Unit = { _, _ -> }) {
|
|
||||||
private class Entry(val sentAtMs: Long) {
|
|
||||||
var settlement: WriteSettlement? = null
|
|
||||||
var recorded: WriteRecorded? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private val writes = linkedMapOf<String, Entry>()
|
|
||||||
private var ended = false
|
|
||||||
|
|
||||||
/** Registers a write sent at [atMs]. Re-sending a known id is the same write and does not restart its window. */
|
|
||||||
fun send(messageId: String, atMs: Long) {
|
|
||||||
tick(atMs)
|
|
||||||
if (messageId in writes) return
|
|
||||||
val entry = Entry(atMs)
|
|
||||||
writes[messageId] = entry
|
|
||||||
// On a session that already ended nothing can answer this write.
|
|
||||||
if (ended) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Feeds one received message. Only write_admitted, error.v1 and error.v0 answer; anything else is ignored. */
|
|
||||||
fun frame(message: CultNetMessage, atMs: Long) {
|
|
||||||
tick(atMs)
|
|
||||||
when (message.schemaVersion) {
|
|
||||||
WRITE_ADMITTED_VERSION -> {
|
|
||||||
answer(decodeWriteAdmitted(message).messageId, WriteSettlement.Admitted, WriteRecorded.ADMITTED_LATE)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ERROR_V1_VERSION, ERROR_V0_VERSION -> {
|
|
||||||
val error = decodeErrorFrame(message)
|
|
||||||
val refusal = WriteSettlement.Refused(error.code, error.error, error)
|
|
||||||
if (error.messageId != null) {
|
|
||||||
answer(error.messageId, refusal, WriteRecorded.REFUSED_LATE)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// An error that names no write is attributable only when exactly one write is unanswered.
|
|
||||||
val unanswered = writes.filterValues { it.settlement == null }
|
|
||||||
if (unanswered.size == 1) settle(unanswered.keys.first(), unanswered.values.first(), refusal)
|
|
||||||
}
|
|
||||||
else -> return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every unanswered write whose window, counted from its own send, has run settles unconfirmed. */
|
|
||||||
fun tick(atMs: Long) {
|
|
||||||
for ((id, entry) in writes) {
|
|
||||||
if (entry.settlement == null && atMs - entry.sentAtMs >= windowMs) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.WINDOW_ELAPSED))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The session ended: every unanswered write settles unconfirmed, and so does any write sent afterwards. */
|
|
||||||
fun sessionEnd(atMs: Long) {
|
|
||||||
tick(atMs)
|
|
||||||
ended = true
|
|
||||||
for ((id, entry) in writes) if (entry.settlement == null) settle(id, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.SESSION_ENDED))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** This client's own send of [messageId] failed: that write settles unconfirmed; the others are untouched. */
|
|
||||||
fun sendFailed(messageId: String, atMs: Long) {
|
|
||||||
val entry = writes[messageId]
|
|
||||||
if (entry != null && entry.settlement == null) settle(messageId, entry, WriteSettlement.Unconfirmed(WriteUnconfirmedReason.LOCAL_SEND_FAILED))
|
|
||||||
tick(atMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The settlement told to the caller, or null while unanswered or unknown. */
|
|
||||||
fun settlement(messageId: String): WriteSettlement? = writes[messageId]?.settlement
|
|
||||||
|
|
||||||
/** The late answer recorded on an unconfirmed write. */
|
|
||||||
fun recorded(messageId: String): WriteRecorded? = writes[messageId]?.recorded
|
|
||||||
|
|
||||||
private fun answer(messageId: String, settlement: WriteSettlement, late: WriteRecorded) {
|
|
||||||
val entry = writes[messageId] ?: return // names a write that is not in flight: settles nothing
|
|
||||||
val current = entry.settlement
|
|
||||||
if (current == null) settle(messageId, entry, settlement)
|
|
||||||
else if (current is WriteSettlement.Unconfirmed && entry.recorded == null) entry.recorded = late
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun settle(messageId: String, entry: Entry, settlement: WriteSettlement) {
|
|
||||||
entry.settlement = settlement
|
|
||||||
onSettle(messageId, settlement)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Unicode White_Space (PropList), the one definition of "blank" in write-receipt.md rule 5; Kotlin's own isBlank takes U+001C to U+001F. */
|
|
||||||
private val WHITE_SPACE_CODE_POINTS: Set<Int> = (0x0009..0x000D).toSet() + 0x0020 + 0x0085 + 0x00A0 + 0x1680 +
|
|
||||||
(0x2000..0x200A).toSet() + 0x2028 + 0x2029 + 0x202F + 0x205F + 0x3000
|
|
||||||
|
|
||||||
/** True for null, empty, or only White_Space code points. */
|
|
||||||
fun isBlankText(text: String?): Boolean = text == null || text.codePoints().allMatch { it in WHITE_SPACE_CODE_POINTS }
|
|
||||||
|
|
||||||
/** Self-test: blank is White_Space, not Kotlin's isBlank (rule 5): U+001C to U+001F name something, U+00A0 and U+0085 do not. */
|
|
||||||
fun rawDocumentRecordDropsOnlyWhiteSpaceSourceIds() {
|
|
||||||
fun wireKeys(id: String) = CultNetRawDocumentRecord("note.v0", "note:1", "2026-11-08T00:00:00Z", payload = byteArrayOf(1), sourceRuntimeId = id, sourceAgentId = id, sourceRole = id).toWireMap().keys
|
|
||||||
for (blank in listOf("", "\u0085", "\u00a0\u2003", "\u3000", "\t\r\n ")) {
|
|
||||||
check(isBlankText(blank) && "sourceRuntimeId" !in wireKeys(blank)) { "blank ${blank.map { it.code }} must be dropped" }
|
|
||||||
}
|
|
||||||
for (named in listOf("\u001c", "\u001f", "\u200b", "\ufeff", "\u180e", "a")) {
|
|
||||||
check(!isBlankText(named) && "sourceRuntimeId" in wireKeys(named) && "sourceRole" in wireKeys(named)) { "${named.map { it.code }} names something and must be kept" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
package org.gamecult.cultmesh
|
|
||||||
|
|
||||||
// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: constants, contentHash, every
|
|
||||||
// `wire` frame (decode to the vector's fields, re-encode to its bytes), every `schemaInvalid` frame (must be refused),
|
|
||||||
// and every `scenarios` event replay through WriteTracker. `dedupe` and `blankFields` are server inputs and are not
|
|
||||||
// run here. Mode: `java -cp ... org.gamecult.cultmesh.CultMeshKt write-receipt-vectors <vectors.json>`.
|
|
||||||
|
|
||||||
import java.io.File
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
// The event actions scenarios.semantics names; anything else is refused, not skipped.
|
|
||||||
private val EVENT_KEYS = listOf("send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck")
|
|
||||||
|
|
||||||
fun replayWriteReceiptVectors(path: String): Boolean {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val vectors = JsonReader(File(path).readText(Charsets.UTF_8)).readDocument() as Map<String, Any?>
|
|
||||||
val results = linkedMapOf<String, List<String>>()
|
|
||||||
fun check(name: String, run: () -> List<String>) {
|
|
||||||
results[name] = try { run() } catch (e: Throwable) { listOf("threw: $e") }
|
|
||||||
}
|
|
||||||
|
|
||||||
val constants = obj(vectors["constants"])
|
|
||||||
check("constants") {
|
|
||||||
listOfNotNull(
|
|
||||||
"RECEIPT window".takeIf { constants["RECEIPT_RESULT_WINDOW_MS"] != RECEIPT_RESULT_WINDOW_MS },
|
|
||||||
"LEGACY window".takeIf { constants["LEGACY_RESULT_WINDOW_MS"] != LEGACY_RESULT_WINDOW_MS },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (case in list(obj(vectors["contentHash"])["cases"])) {
|
|
||||||
val c = obj(case)
|
|
||||||
check("contentHash/${c["name"]}") {
|
|
||||||
val got = writeContentHash(c["operation"] as String, c["schemaId"] as String, c["recordKey"] as String, (c["payloadHex"] as String?)?.let(::unhex))
|
|
||||||
if (got == c["sha256"]) emptyList() else listOf("$got != ${c["sha256"]}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (case in list(vectors["wire"])) {
|
|
||||||
val c = obj(case)
|
|
||||||
check("wire/${c["name"]}") {
|
|
||||||
val bytes = unhex(c["messagePackHex"] as String)
|
|
||||||
val problems = mutableListOf<String>()
|
|
||||||
val message = decodeReceiptFrame(parseCultNetMessage(bytes))
|
|
||||||
if (message.toWireMap() != c["message"]) problems += "decoded ${message.toWireMap()} != ${c["message"]}"
|
|
||||||
val again = message.toBytes().joinToString("") { "%02X".format(it) }
|
|
||||||
if (again != (c["messagePackHex"] as String).uppercase()) problems += "re-encoded bytes $again != captured ${c["messagePackHex"]}"
|
|
||||||
problems
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// admittedMessageId: a write_admitted messageId needs a letter or number and no Default_Ignorable code point, compared
|
|
||||||
// raw. The frame is built with this runtime's own encoder; a case with `wireHex` (the lone surrogate, invalid UTF-8)
|
|
||||||
// supplies the whole frame.
|
|
||||||
for (case in list(obj(vectors["admittedMessageId"])["cases"])) {
|
|
||||||
val c = obj(case)
|
|
||||||
check("admittedMessageId/${c["name"]}") {
|
|
||||||
val id = list(c["codePoints"]).joinToString("") { String(Character.toChars((it as String).toInt(16))) }.repeat(((c["repeat"] as Long?) ?: 1L).toInt())
|
|
||||||
val bytes = (c["wireHex"] as String?)?.let(::unhex) ?: MessagePackWriter().value(linkedMapOf(
|
|
||||||
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
|
|
||||||
)).toByteArray()
|
|
||||||
val decoded = try { decodeReceiptFrame(parseCultNetMessage(bytes)) } catch (_: IOException) { null }
|
|
||||||
when {
|
|
||||||
c["expect"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted an id that must be refused (${c["why"]})")
|
|
||||||
decoded == null -> listOf("refused an id that must be accepted (${c["why"]})")
|
|
||||||
decoded.toWireMap()["messageId"] != id -> listOf("id not echoed raw: ${decoded.toWireMap()["messageId"]}")
|
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every
|
|
||||||
// range, so deleting or narrowing any range fails here.
|
|
||||||
check("unicodeTableRanges") {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
val ranges = JsonReader(File(File(path).parentFile, "write-admitted-message-id-ranges.json").readText(Charsets.UTF_8)).readDocument() as Map<String, Any?>
|
|
||||||
fun pairs(key: String) = list(ranges[key]).map { list(it).map { n -> (n as Long).toInt() } }
|
|
||||||
val letters = pairs("letterOrNumber")
|
|
||||||
val ignorable = pairs("defaultIgnorable")
|
|
||||||
val problems = mutableListOf<String>()
|
|
||||||
if (letters.size <= 700 || ignorable.size != 17) problems += "ranges file holds ${letters.size} and ${ignorable.size} ranges"
|
|
||||||
fun accepted(id: String): Boolean = try {
|
|
||||||
decodeReceiptFrame(parseCultNetMessage(MessagePackWriter().value(linkedMapOf(
|
|
||||||
"schemaVersion" to WRITE_ADMITTED_VERSION, "messageId" to id, "operation" to "put", "schemaId" to "note.v0", "recordKey" to "note:1",
|
|
||||||
)).toByteArray()))
|
|
||||||
true
|
|
||||||
} catch (_: IOException) { false }
|
|
||||||
for ((low, high) in letters) for ((point, want) in listOf(low to true, high to true, low - 1 to false, high + 1 to false)) {
|
|
||||||
if (accepted(String(Character.toChars(point))) != want) problems += "U+${point.toString(16)} alone should be ${if (want) "accepted" else "refused"}"
|
|
||||||
}
|
|
||||||
for ((low, high) in ignorable) for ((point, want) in listOf(low to false, high to false, low - 1 to true, high + 1 to true)) {
|
|
||||||
if (accepted("a" + String(Character.toChars(point))) != want) problems += "a + U+${point.toString(16)} should be ${if (want) "accepted" else "refused"}"
|
|
||||||
}
|
|
||||||
problems
|
|
||||||
}
|
|
||||||
|
|
||||||
// A document payload string with a lone surrogate (ED A0 80) is not a write_admitted id: the shared MessagePack reader keeps
|
|
||||||
// reading it. Strictness is scoped to the write_admitted frame, never to the reader.
|
|
||||||
check("documentPayloadStringWithLoneSurrogateStillDecodes") {
|
|
||||||
val text = MessagePackReader(unhex("A3EDA080")).readString()
|
|
||||||
if (text.isNotEmpty()) emptyList() else listOf("decoded to an empty string")
|
|
||||||
}
|
|
||||||
|
|
||||||
// admittedMessageIdDuplicates: Kotlin decodes the frame to a map, so it cannot see a repeated key: it judges the last one.
|
|
||||||
for (case in list(obj(vectors["admittedMessageIdDuplicates"])["cases"])) {
|
|
||||||
val c = obj(case)
|
|
||||||
check("admittedMessageIdDuplicates/${c["name"]}") {
|
|
||||||
val decoded = try { decodeReceiptFrame(parseCultNetMessage(unhex(c["wireHex"] as String))) } catch (_: IOException) { null }
|
|
||||||
when {
|
|
||||||
c["lastKeyWins"] == "refuse" -> if (decoded == null) emptyList() else listOf("accepted a frame whose last messageId key is invalid")
|
|
||||||
decoded == null -> listOf("refused a frame whose last messageId key is valid")
|
|
||||||
decoded.toWireMap()["messageId"] != c["lastKeyWinsEcho"] -> listOf("echoed ${decoded.toWireMap()["messageId"]}, wanted ${c["lastKeyWinsEcho"]}")
|
|
||||||
else -> emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (case in list(vectors["schemaInvalid"])) {
|
|
||||||
val c = obj(case)
|
|
||||||
check("schemaInvalid/${c["name"]}") {
|
|
||||||
try {
|
|
||||||
decodeReceiptFrame(parseCultNetMessage(unhex(c["messagePackHex"] as String)))
|
|
||||||
listOf("accepted a frame that must be refused (${c["why"]})")
|
|
||||||
} catch (_: IOException) {
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not a vector: `limit_reached` is in error.v1's enum only (write-receipt.md), as the TS and Rust suites also pin.
|
|
||||||
check("error_v0_refuses_the_v1_only_limit_reached_code") {
|
|
||||||
val body = linkedMapOf<String, Any?>("error" to "x", "routingHint" to null, "code" to "limit_reached", "details" to null)
|
|
||||||
val v1 = decodeErrorFrame(CultNetMessage(ERROR_V1_VERSION, body + ("messageId" to null))).code
|
|
||||||
try {
|
|
||||||
decodeErrorFrame(CultNetMessage(ERROR_V0_VERSION, body))
|
|
||||||
listOf("error.v0 accepted limit_reached")
|
|
||||||
} catch (_: IOException) {
|
|
||||||
if (v1 == "limit_reached") emptyList() else listOf("error.v1 lost limit_reached")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (case in list(obj(vectors["scenarios"])["cases"])) {
|
|
||||||
val scenario = obj(case)
|
|
||||||
check("scenario/${scenario["name"]}") {
|
|
||||||
val problems = mutableListOf<String>()
|
|
||||||
// The fake server states its capability the only way a real one does: in its hello.
|
|
||||||
val hello = cultNetHello(
|
|
||||||
"fake-server", "fake",
|
|
||||||
supportedMessageVersions = if (scenario["server"] == "receipt") CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS else emptyList(),
|
|
||||||
)
|
|
||||||
val windowMs = resultWindowMs(helloAdvertisesWriteReceipts(hello))
|
|
||||||
if (windowMs != constants[scenario["window"] as String]) problems += "window $windowMs != ${scenario["window"]}"
|
|
||||||
val told = linkedMapOf<String, WriteSettlement>()
|
|
||||||
val tracker = WriteTracker(windowMs) { id, settlement ->
|
|
||||||
if (id in told) problems += "$id settled twice"
|
|
||||||
told[id] = settlement
|
|
||||||
}
|
|
||||||
for (event in list(scenario["events"]).map(::obj).sortedBy { it["atMs"] as Long }) {
|
|
||||||
val at = event["atMs"] as Long
|
|
||||||
val actions = event.keys - "atMs"
|
|
||||||
if (actions.size != 1 || actions.first() !in EVENT_KEYS) { problems += "event at $at has keys $actions, wanted atMs and exactly one of $EVENT_KEYS"; continue }
|
|
||||||
when {
|
|
||||||
"send" in event -> tracker.send(event["send"] as String, at)
|
|
||||||
// A frame reaches the client as bytes: the fake server encodes it, the client decodes it.
|
|
||||||
"frame" in event -> tracker.frame(parseCultNetMessage(MessagePackWriter().value(event["frame"]).toByteArray()), at)
|
|
||||||
"tick" in event -> tracker.tick(at)
|
|
||||||
"sessionEnd" in event -> tracker.sessionEnd(at)
|
|
||||||
"sendFailed" in event -> tracker.sendFailed(event["sendFailed"] as String, at)
|
|
||||||
// transportAck: delivery is not an answer; the tracker has no such input and must not need one.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val expect = obj(scenario["expect"])
|
|
||||||
for ((id, wantAny) in expect) {
|
|
||||||
val want = obj(wantAny)
|
|
||||||
val got = told[id]
|
|
||||||
if (got == null) { problems += "$id: never settled, wanted ${want["outcome"]}"; continue }
|
|
||||||
val (outcome, code, reason) = when (got) {
|
|
||||||
is WriteSettlement.Admitted -> Triple("admitted", null, null)
|
|
||||||
is WriteSettlement.Refused -> Triple("refused", got.code, null)
|
|
||||||
is WriteSettlement.Unconfirmed -> Triple("unconfirmed", null, got.reason.wire)
|
|
||||||
}
|
|
||||||
if (outcome != want["outcome"]) problems += "$id: told $outcome, wanted ${want["outcome"]}"
|
|
||||||
if ("code" in want && code != want["code"]) problems += "$id: code $code, wanted ${want["code"]}"
|
|
||||||
if ("reason" in want && reason != want["reason"]) problems += "$id: reason $reason, wanted ${want["reason"]}"
|
|
||||||
if (tracker.recorded(id)?.wire != want["recorded"]) problems += "$id: recorded ${tracker.recorded(id)?.wire}, wanted ${want["recorded"]}"
|
|
||||||
}
|
|
||||||
for (id in told.keys) if (id !in expect) problems += "$id: settled but the scenario expects nothing for it"
|
|
||||||
problems
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A runner that read fewer vectors than the file declares has silently skipped some.
|
|
||||||
check("counts") {
|
|
||||||
val declared = obj(vectors["counts"])
|
|
||||||
fun ran(prefix: String) = results.keys.count { it.startsWith(prefix) }
|
|
||||||
val loaded = mapOf(
|
|
||||||
"wire" to list(vectors["wire"]).size, "schemaInvalid" to list(vectors["schemaInvalid"]).size,
|
|
||||||
"contentHashCases" to list(obj(vectors["contentHash"])["cases"]).size, "scenarioCases" to list(obj(vectors["scenarios"])["cases"]).size,
|
|
||||||
"admittedMessageIdCases" to list(obj(vectors["admittedMessageId"])["cases"]).size,
|
|
||||||
)
|
|
||||||
val executed = mapOf("wire" to ran("wire/"), "schemaInvalid" to ran("schemaInvalid/"), "contentHashCases" to ran("contentHash/"), "scenarioCases" to ran("scenario/"), "admittedMessageIdCases" to ran("admittedMessageId/"))
|
|
||||||
loaded.keys.filter { loaded[it] != (declared[it] as Long).toInt() || executed[it] != loaded[it] }
|
|
||||||
.map { "$it: declared ${declared[it]}, loaded ${loaded[it]}, ran ${executed[it]}" }
|
|
||||||
}
|
|
||||||
|
|
||||||
for ((name, problems) in results) {
|
|
||||||
println(if (problems.isEmpty()) "PASS $name" else "FAIL $name")
|
|
||||||
problems.forEach { println(" $it") }
|
|
||||||
}
|
|
||||||
val failed = results.values.count { it.isNotEmpty() }
|
|
||||||
println("${results.size - failed}/${results.size} passed")
|
|
||||||
return failed == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Strict decode of whichever receipt-family frame this is, returned as the message its typed form encodes; anything else is refused. */
|
|
||||||
private fun decodeReceiptFrame(message: CultNetMessage): CultNetMessage = when (message.schemaVersion) {
|
|
||||||
WRITE_ADMITTED_VERSION -> decodeWriteAdmitted(message).toMessage()
|
|
||||||
ERROR_V1_VERSION, ERROR_V0_VERSION -> decodeErrorFrame(message).toMessage()
|
|
||||||
else -> throw IOException("not a receipt frame: ${message.schemaVersion}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
private fun obj(value: Any?): Map<String, Any?> = value as Map<String, Any?>
|
|
||||||
private fun list(value: Any?): List<Any?> = value as List<Any?>
|
|
||||||
|
|
||||||
private fun unhex(text: String): ByteArray = ByteArray(text.length / 2) { text.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
|
||||||
|
|
||||||
/** A small JSON reader for the vector file: objects, arrays, strings, integers, true/false/null. */
|
|
||||||
private class JsonReader(private val text: String) {
|
|
||||||
private var at = 0
|
|
||||||
|
|
||||||
fun readDocument(): Any? = readValue().also { skipSpace(); if (at != text.length) throw IOException("trailing JSON") }
|
|
||||||
|
|
||||||
private fun skipSpace() { while (at < text.length && text[at] in " \t\r\n") at++ }
|
|
||||||
|
|
||||||
private fun readValue(): Any? {
|
|
||||||
skipSpace()
|
|
||||||
return when (val c = text[at]) {
|
|
||||||
'{' -> {
|
|
||||||
at++
|
|
||||||
val map = linkedMapOf<String, Any?>()
|
|
||||||
skipSpace()
|
|
||||||
if (text[at] == '}') { at++; return map }
|
|
||||||
do {
|
|
||||||
skipSpace()
|
|
||||||
val key = readString()
|
|
||||||
skipSpace(); expect(':')
|
|
||||||
map[key] = readValue()
|
|
||||||
skipSpace()
|
|
||||||
} while (closes('}'))
|
|
||||||
map
|
|
||||||
}
|
|
||||||
'[' -> {
|
|
||||||
at++
|
|
||||||
val items = mutableListOf<Any?>()
|
|
||||||
skipSpace()
|
|
||||||
if (text[at] == ']') { at++; return items }
|
|
||||||
do {
|
|
||||||
items += readValue()
|
|
||||||
skipSpace()
|
|
||||||
} while (closes(']'))
|
|
||||||
items
|
|
||||||
}
|
|
||||||
'"' -> readString()
|
|
||||||
't' -> literal("true", true)
|
|
||||||
'f' -> literal("false", false)
|
|
||||||
'n' -> literal("null", null)
|
|
||||||
else -> {
|
|
||||||
if (c != '-' && !c.isDigit()) throw IOException("unexpected '$c' at $at")
|
|
||||||
val start = at++
|
|
||||||
while (at < text.length && text[at].isDigit()) at++
|
|
||||||
text.substring(start, at).toLong()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** After an element: false when [close] ends the container, true after a comma. */
|
|
||||||
private fun closes(close: Char): Boolean {
|
|
||||||
val c = text[at++]
|
|
||||||
if (c == close) return false
|
|
||||||
if (c != ',') throw IOException("expected , or $close at ${at - 1}")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun literal(word: String, value: Any?): Any? {
|
|
||||||
if (!text.startsWith(word, at)) throw IOException("bad literal at $at")
|
|
||||||
at += word.length
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun expect(c: Char) { if (text[at++] != c) throw IOException("expected $c at ${at - 1}") }
|
|
||||||
|
|
||||||
private fun readString(): String {
|
|
||||||
expect('"')
|
|
||||||
val out = StringBuilder()
|
|
||||||
while (true) {
|
|
||||||
val c = text[at++]
|
|
||||||
when (c) {
|
|
||||||
'"' -> return out.toString()
|
|
||||||
'\\' -> when (val e = text[at++]) {
|
|
||||||
'n' -> out.append('\n'); 't' -> out.append('\t'); 'r' -> out.append('\r')
|
|
||||||
'b' -> out.append('\b'); 'f' -> out.append('\u000c')
|
|
||||||
'u' -> { out.append(text.substring(at, at + 4).toInt(16).toChar()); at += 4 }
|
|
||||||
'"', '\\', '/' -> out.append(e)
|
|
||||||
else -> throw IOException("bad escape \\$e")
|
|
||||||
}
|
|
||||||
else -> out.append(c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
//! The real Rust RUDP document server on an ephemeral loopback port, for the cross-runtime write-receipt interop
|
|
||||||
//! harness (packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts). It is the production server with a
|
|
||||||
//! recording sink and nothing else: stdout carries one JSON line per fact the harness asserts on, and the server runs
|
|
||||||
//! until stdin closes.
|
|
||||||
//!
|
|
||||||
//! {"event":"listening","port":N}
|
|
||||||
//! {"event":"applied","op":"put","messageId":"...","schemaId":"...","recordKey":"..."}
|
|
||||||
//!
|
|
||||||
//! Record keys that start with `refuse:` are refused by the sink (authorize and commit), which is how the harness
|
|
||||||
//! asks for a refused write.
|
|
||||||
use anyhow::Result;
|
|
||||||
use cultmesh_rs::{
|
|
||||||
CultMeshRudpDocumentServer, CultMeshRudpRawDocumentReceipt, CultMeshRudpRawDocumentSink,
|
|
||||||
CultMeshRudpSnapshotQuery, CultMeshSystemClock,
|
|
||||||
};
|
|
||||||
use cultnet_rs::CultNetRawDocumentRecord;
|
|
||||||
use serde_json::json;
|
|
||||||
use std::io::Read;
|
|
||||||
use std::net::UdpSocket;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
const REFUSED_PREFIX: &str = "refuse:";
|
|
||||||
|
|
||||||
struct RecordingSink;
|
|
||||||
|
|
||||||
impl CultMeshRudpRawDocumentSink for RecordingSink {
|
|
||||||
fn authorize_raw_document(&mut self, receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> {
|
|
||||||
if receipt.document.record_key.starts_with(REFUSED_PREFIX) {
|
|
||||||
anyhow::bail!("not authorized");
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn accept_raw_document(&mut self, receipt: CultMeshRudpRawDocumentReceipt) -> Result<()> {
|
|
||||||
if receipt.document.record_key.starts_with(REFUSED_PREFIX) {
|
|
||||||
anyhow::bail!("not authorized");
|
|
||||||
}
|
|
||||||
println!(
|
|
||||||
"{}",
|
|
||||||
json!({
|
|
||||||
"event": "applied",
|
|
||||||
"op": "put",
|
|
||||||
"messageId": receipt.message_id,
|
|
||||||
"schemaId": receipt.document.schema_id,
|
|
||||||
"recordKey": receipt.document.record_key,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
|
||||||
let source = |_: &CultMeshRudpSnapshotQuery| -> Result<Vec<CultNetRawDocumentRecord>> { Ok(Vec::new()) };
|
|
||||||
let mut server = CultMeshRudpDocumentServer::new(
|
|
||||||
UdpSocket::bind("127.0.0.1:0")?,
|
|
||||||
RecordingSink,
|
|
||||||
source,
|
|
||||||
CultMeshSystemClock::default(),
|
|
||||||
Default::default(),
|
|
||||||
)?;
|
|
||||||
println!("{}", json!({ "event": "listening", "port": server.local_addr()?.port() }));
|
|
||||||
let stop = Arc::new(AtomicBool::new(false));
|
|
||||||
let watcher = Arc::clone(&stop);
|
|
||||||
thread::spawn(move || {
|
|
||||||
let mut sink = Vec::new();
|
|
||||||
let _ = std::io::stdin().read_to_end(&mut sink);
|
|
||||||
watcher.store(true, Ordering::SeqCst);
|
|
||||||
});
|
|
||||||
while !stop.load(Ordering::SeqCst) {
|
|
||||||
server.poll_once()?;
|
|
||||||
thread::sleep(Duration::from_millis(1));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -5,13 +5,8 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
|
||||||
|
|
||||||
## Unreleased
|
## 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
|
### 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`.
|
- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`.
|
||||||
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
|
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
|
||||||
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
|
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@
|
||||||
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||||
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
"prepack": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json",
|
||||||
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
|
"typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --pretty false",
|
||||||
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js",
|
"test": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --expose-gc --test dist-test/test/*.test.js"
|
||||||
"test:interop": "npm --prefix ../.. run build --workspace @gamecult/cultcache-ts --workspace @gamecult/cultnet-ts && node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node ../../node_modules/typescript/bin/tsc -p tsconfig.test.json && node --test --test-force-exit dist-test/test/interop/write-receipt-interop.test.js"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cultmesh",
|
"cultmesh",
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,6 @@ import {
|
||||||
CultNetRudpSocketTransportConnection,
|
CultNetRudpSocketTransportConnection,
|
||||||
rudpClientBindHost,
|
rudpClientBindHost,
|
||||||
CultNetSchemaCatalog,
|
CultNetSchemaCatalog,
|
||||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
|
||||||
WriteTracker,
|
|
||||||
helloAdvertisesWriteReceipts,
|
|
||||||
resultWindowMs,
|
|
||||||
cultNetMessageInvalidError,
|
cultNetMessageInvalidError,
|
||||||
cultNetTextError,
|
cultNetTextError,
|
||||||
cultNetWriteNotAuthorizedError,
|
cultNetWriteNotAuthorizedError,
|
||||||
|
|
@ -48,8 +44,6 @@ import {
|
||||||
type CultNetSchemaCatalogOptions,
|
type CultNetSchemaCatalogOptions,
|
||||||
type CultNetSnapshotResponseRawMessage,
|
type CultNetSnapshotResponseRawMessage,
|
||||||
type CultNetWireContract,
|
type CultNetWireContract,
|
||||||
type WriteSettlement,
|
|
||||||
type WriteUnconfirmedReason,
|
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "@gamecult/cultnet-ts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -4195,8 +4189,8 @@ export interface CultMeshRudpDocumentServer {
|
||||||
*/
|
*/
|
||||||
export class CultMeshRudpWriteRefusedError extends Error {
|
export class CultMeshRudpWriteRefusedError extends Error {
|
||||||
readonly code: string | undefined;
|
readonly code: string | undefined;
|
||||||
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>;
|
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>;
|
||||||
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>) {
|
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>) {
|
||||||
super(response.error);
|
super(response.error);
|
||||||
this.name = "CultMeshRudpWriteRefusedError";
|
this.name = "CultMeshRudpWriteRefusedError";
|
||||||
this.code = response.code ?? undefined;
|
this.code = response.code ?? undefined;
|
||||||
|
|
@ -4204,49 +4198,14 @@ export class CultMeshRudpWriteRefusedError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
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
|
* How long after the put's transport ack a refusal may still arrive. A reliable-transport ack is not
|
||||||
* admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the
|
* admission; the outcome is the peer's result frame. A server that answers a put only on refusal sends no
|
||||||
* write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never
|
* success frame, so the put is reported published once this window passes with no refusal; a success reply
|
||||||
* published, whatever the server advertised: only a `write_admitted` is admission. The send itself is also
|
* ends the wait early. Default 500 ms.
|
||||||
* bounded by the window (`flushTimeoutMs` is the upper bound); a send not acknowledged by then is
|
|
||||||
* unconfirmed as `local_send_failed`.
|
|
||||||
*/
|
*/
|
||||||
resultWindowMs?: number;
|
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;
|
messageId?: string;
|
||||||
sourceRuntimeId?: string;
|
sourceRuntimeId?: string;
|
||||||
sourceAgentId?: string;
|
sourceAgentId?: string;
|
||||||
|
|
@ -6216,88 +6175,44 @@ export class CultMesh {
|
||||||
recordKey: string,
|
recordKey: string,
|
||||||
value: CultCacheDocumentValue<TDefinition>,
|
value: CultCacheDocumentValue<TDefinition>,
|
||||||
options: CultMeshRudpDocumentPublishOptions = {},
|
options: CultMeshRudpDocumentPublishOptions = {},
|
||||||
): Promise<"admitted" | "sent"> {
|
): Promise<void> {
|
||||||
requireNonEmpty(runtimeId, "runtimeId");
|
requireNonEmpty(runtimeId, "runtimeId");
|
||||||
requireNonEmpty(recordKey, "recordKey");
|
requireNonEmpty(recordKey, "recordKey");
|
||||||
if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined.");
|
|
||||||
const registry = new CultNetDocumentRegistry([binding]);
|
const registry = new CultNetDocumentRegistry([binding]);
|
||||||
const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`;
|
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);
|
const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
|
||||||
try {
|
try {
|
||||||
if (options.sendOnly) {
|
// A transport ack is not admission: the put completes on its result frame. A refusal rejects with the
|
||||||
try {
|
// typed error whether it arrives before the ack, after it, or inside the result window.
|
||||||
peer.send(put);
|
let refusal: CultMeshRudpWriteRefusedError | undefined;
|
||||||
await peer.flush(options.flushTimeoutMs ?? 30_000);
|
let succeeded = false;
|
||||||
} 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 wake: (() => void) | undefined;
|
||||||
let serverHello: (() => void) | undefined;
|
|
||||||
peer.on("message", (message: CultNetMessage) => {
|
peer.on("message", (message: CultNetMessage) => {
|
||||||
if (message.schemaVersion === "cultnet.hello.v0") {
|
if (message.schemaVersion === "cultnet.error.v0") refusal ??= new CultMeshRudpWriteRefusedError(message);
|
||||||
receiptCapable = helloAdvertisesWriteReceipts(message);
|
else if ("messageId" in message && message.messageId === messageId) succeeded = true;
|
||||||
serverHello?.();
|
else return;
|
||||||
} else {
|
wake?.();
|
||||||
tracker?.frame(message, Date.now());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
peer.on("close", () => tracker?.sessionEnd(Date.now()));
|
peer.send(registry.createRawDocumentPutMessage(
|
||||||
if (options.writeReceipts) {
|
binding,
|
||||||
const answered = new Promise<void>((resolve) => { serverHello = resolve; });
|
messageId,
|
||||||
peer.sendHello({
|
recordKey,
|
||||||
schemaVersion: "cultnet.hello.v0", runtimeId, runtimeKind: "cultmesh-ts",
|
value,
|
||||||
supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS],
|
{
|
||||||
});
|
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId,
|
||||||
let helloTimer: NodeJS.Timeout | undefined;
|
sourceAgentId: options.sourceAgentId,
|
||||||
await Promise.race([answered, new Promise<void>((resolve) => { helloTimer = setTimeout(resolve, options.helloWaitMs ?? 500); })]);
|
sourceRole: options.sourceRole,
|
||||||
clearTimeout(helloTimer);
|
tags: options.tags,
|
||||||
}
|
},
|
||||||
const writeTracker = new WriteTracker({
|
));
|
||||||
windowMs: options.resultWindowMs ?? resultWindowMs(receiptCapable),
|
await peer.flush(options.flushTimeoutMs ?? 30_000);
|
||||||
onSettle: (_id, result) => { settlement = result; wake?.(); },
|
if (!refusal && !succeeded) {
|
||||||
});
|
|
||||||
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<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
const timer = setTimeout(resolve, remaining);
|
const timer = setTimeout(resolve, options.resultWindowMs ?? 500);
|
||||||
wake = () => { clearTimeout(timer); resolve(); };
|
wake = () => { clearTimeout(timer); resolve(); };
|
||||||
});
|
});
|
||||||
writeTracker.tick(Date.now());
|
|
||||||
}
|
}
|
||||||
if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response);
|
if (refusal) throw refusal;
|
||||||
if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason);
|
|
||||||
return "admitted";
|
|
||||||
} finally {
|
} finally {
|
||||||
peer.close();
|
peer.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
encodeRudpPacket,
|
encodeRudpPacket,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "@gamecult/cultnet-ts";
|
||||||
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||||
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
|
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError } from "../src/index";
|
||||||
|
|
||||||
const isTheRefusal = (error: unknown): boolean =>
|
const isTheRefusal = (error: unknown): boolean =>
|
||||||
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized";
|
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized";
|
||||||
|
|
@ -100,7 +100,7 @@ for (const order of orders) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("a put the server applied but did not answer is unconfirmed, never published", async () => {
|
test("publishRudpDocumentOnce still publishes a put the server admitted", async () => {
|
||||||
const connectionId = nextConnection++;
|
const connectionId = nextConnection++;
|
||||||
const puts: string[] = [];
|
const puts: string[] = [];
|
||||||
const server = CultMesh.createRudpDocumentServer("completion-admit", connectionId, {
|
const server = CultMesh.createRudpDocumentServer("completion-admit", connectionId, {
|
||||||
|
|
@ -111,10 +111,7 @@ test("a put the server applied but did not answer is unconfirmed, never publishe
|
||||||
});
|
});
|
||||||
await server.start();
|
await server.start();
|
||||||
try {
|
try {
|
||||||
await assert.rejects(
|
await CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 });
|
||||||
CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 }),
|
assert.deepEqual(puts, ["tests:completion:a"]);
|
||||||
(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(); }
|
} finally { server.close(); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import {
|
||||||
type CultNetMessage,
|
type CultNetMessage,
|
||||||
type CultNetSnapshotResponseRawMessage,
|
type CultNetSnapshotResponseRawMessage,
|
||||||
} from "@gamecult/cultnet-ts";
|
} from "@gamecult/cultnet-ts";
|
||||||
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
|
import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError } from "../src/index";
|
||||||
|
|
||||||
// A record's payload as the bytes a peer would put on the wire.
|
// A record's payload as the bytes a peer would put on the wire.
|
||||||
const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value));
|
const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value));
|
||||||
|
|
@ -2968,30 +2968,25 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async ()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await server.start();
|
await server.start();
|
||||||
// This server answers a put with nothing, so the writer cannot know the put landed: unconfirmed, though it did.
|
await CultMesh.publishRudpDocumentOnce(
|
||||||
await assert.rejects(
|
"cultmesh-ts-muninn",
|
||||||
CultMesh.publishRudpDocumentOnce(
|
connectionId,
|
||||||
"cultmesh-ts-muninn",
|
`rudp://127.0.0.1:${server.bind.port}`,
|
||||||
connectionId,
|
defineCultNetDocumentBinding({ definition: noteDocument }),
|
||||||
`rudp://127.0.0.1:${server.bind.port}`,
|
"note:odin",
|
||||||
defineCultNetDocumentBinding({ definition: noteDocument }),
|
{
|
||||||
"note:odin",
|
noteId: "note:odin",
|
||||||
{
|
body: "pay respects once",
|
||||||
noteId: "note:odin",
|
},
|
||||||
body: "pay respects once",
|
{
|
||||||
},
|
resendDelayMs: 25,
|
||||||
{
|
resendPollMs: 5,
|
||||||
resendDelayMs: 25,
|
maxFragmentBytes: 1024,
|
||||||
resendPollMs: 5,
|
maxPendingReliablePackets: 16,
|
||||||
maxFragmentBytes: 1024,
|
connectTimeoutMs: 1_000,
|
||||||
maxPendingReliablePackets: 16,
|
flushTimeoutMs: 25,
|
||||||
connectTimeoutMs: 1_000,
|
sourceRole: "test-provider",
|
||||||
flushTimeoutMs: 25,
|
},
|
||||||
resultWindowMs: 100,
|
|
||||||
sourceRole: "test-provider",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => received !== undefined, "published RUDP document");
|
await waitFor(() => received !== undefined, "published RUDP document");
|
||||||
|
|
|
||||||
|
|
@ -1,434 +0,0 @@
|
||||||
// The TypeScript write-receipt client (cultnet-ts WriteTracker and decoder, cultmesh-ts one-shot writer) against the two real
|
|
||||||
// receipt servers over loopback, as child processes on ephemeral ports:
|
|
||||||
//
|
|
||||||
// rust packages/cultmesh-rs/examples/receipt_interop_server.rs, the RUDP document server, reached over RUDP.
|
|
||||||
// csharp tests/GameCult.Networking.InteropPeer `receipt-serve`, CultNetDatabaseServer on Server (LiteNetLib). TypeScript has no
|
|
||||||
// LiteNetLib client, so a second child, `receipt-relay`, carries bytes between the test and the server (4-byte
|
|
||||||
// big-endian length, then the CultNet frame: CultNetPeer's own stream framing). It decodes and judges nothing.
|
|
||||||
//
|
|
||||||
// Every scenario runs against both targets; its outcome is read from the WriteTracker (admitted, refused, unconfirmed), and the
|
|
||||||
// server's own stdout facts say what was committed. Contract: contracts/cultnet/interop/write-receipt.md and its vectors.
|
|
||||||
//
|
|
||||||
// Needs cargo and dotnet; run it with `npm run test:interop` in packages/cultmesh-ts (CARGO and DOTNET override the commands;
|
|
||||||
// RECEIPT_INTEROP_PREBUILT=1 skips the two builds).
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { execFile, spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join, resolve } from "node:path";
|
|
||||||
import { Duplex } from "node:stream";
|
|
||||||
import { after, before, beforeEach, describe, test } from "node:test";
|
|
||||||
import { promisify } from "node:util";
|
|
||||||
import { encode } from "@msgpack/msgpack";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
|
||||||
import {
|
|
||||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
|
||||||
CultNetPeer,
|
|
||||||
WriteTracker,
|
|
||||||
defineCultNetDocumentBinding,
|
|
||||||
helloAdvertisesWriteReceipts,
|
|
||||||
resultWindowMs,
|
|
||||||
type CultNetErrorV1Message,
|
|
||||||
type CultNetHelloMessage,
|
|
||||||
type CultNetMessage,
|
|
||||||
type WriteSettlement,
|
|
||||||
} from "@gamecult/cultnet-ts";
|
|
||||||
import { CultMesh, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../../src/index";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
|
||||||
// dist-test/test/interop -> the repository root
|
|
||||||
const repoRoot = resolve(__dirname, "..", "..", "..", "..", "..");
|
|
||||||
const cargo = process.env.CARGO ?? "cargo";
|
|
||||||
const dotnet = process.env.DOTNET ?? "dotnet";
|
|
||||||
const rustManifestDir = join(repoRoot, "packages", "cultmesh-rs");
|
|
||||||
const csharpProject = join(repoRoot, "tests", "GameCult.Networking.InteropPeer", "GameCult.Networking.InteropPeer.csproj");
|
|
||||||
const csharpDll = join(repoRoot, "bin", "GameCult.Networking.InteropPeer", "Debug", "net10.0", "GameCult.Networking.InteropPeer.dll");
|
|
||||||
// Set when the server binaries were built beforehand (the builds themselves cannot start under a tight address-space cap).
|
|
||||||
const prebuilt = process.env.RECEIPT_INTEROP_PREBUILT === "1";
|
|
||||||
const vectors = JSON.parse(readFileSync(join(repoRoot, "contracts", "cultnet", "interop", "write-receipt-vectors.json"), "utf8"));
|
|
||||||
|
|
||||||
const SCHEMA_ID = "receipt.interop_note.v1";
|
|
||||||
const UNREADABLE = "message_invalid: the message could not be read";
|
|
||||||
const sleep = (ms: number) => new Promise<void>((done) => setTimeout(done, ms));
|
|
||||||
|
|
||||||
async function waitFor(predicate: () => boolean, what: string, ms = 5_000): Promise<void> {
|
|
||||||
const startedAt = Date.now();
|
|
||||||
while (!predicate()) {
|
|
||||||
if (Date.now() - startedAt > ms) throw new Error(`timed out waiting for ${what}`);
|
|
||||||
await sleep(5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the servers -------------------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface Fact { event: string; recordKey?: string; messageId?: string; [key: string]: unknown }
|
|
||||||
|
|
||||||
/** A running server child and the JSON facts it prints on stdout. */
|
|
||||||
class ServerProcess {
|
|
||||||
readonly facts: Fact[] = [];
|
|
||||||
stderr = "";
|
|
||||||
port = 0;
|
|
||||||
constructor(readonly child: ChildProcess) {
|
|
||||||
let pending = "";
|
|
||||||
child.stdout!.on("data", (chunk: Buffer) => {
|
|
||||||
pending += chunk.toString("utf8");
|
|
||||||
for (let at = pending.indexOf("\n"); at >= 0; at = pending.indexOf("\n")) {
|
|
||||||
const line = pending.slice(0, at).trim();
|
|
||||||
pending = pending.slice(at + 1);
|
|
||||||
if (line) this.facts.push(JSON.parse(line) as Fact);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
child.stderr!.on("data", (chunk: Buffer) => { this.stderr += chunk.toString("utf8"); });
|
|
||||||
}
|
|
||||||
/** How many times `event` was printed for `recordKey`. */
|
|
||||||
count(event: string, recordKey: string): number {
|
|
||||||
return this.facts.filter((fact) => fact.event === event && fact.recordKey === recordKey).length;
|
|
||||||
}
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
this.child.stdin!.end();
|
|
||||||
await new Promise<void>((done) => {
|
|
||||||
const killer = setTimeout(() => this.child.kill("SIGKILL"), 5_000);
|
|
||||||
this.child.once("exit", () => { clearTimeout(killer); done(); });
|
|
||||||
if (this.child.exitCode !== null) done();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function launch(command: string, args: string[]): Promise<ServerProcess> {
|
|
||||||
const server = new ServerProcess(spawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }));
|
|
||||||
await waitFor(() => server.facts.some((fact) => fact.event === "listening") || server.child.exitCode !== null, "the server to listen", 60_000);
|
|
||||||
const listening = server.facts.find((fact) => fact.event === "listening");
|
|
||||||
if (!listening) throw new Error(`the server exited before listening:\n${server.stderr}`);
|
|
||||||
server.port = listening.port as number;
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One connection to a server: the peer that speaks CultNet, and how to close it. */
|
|
||||||
interface Connection { peer: CultNetPeer; close(): void }
|
|
||||||
|
|
||||||
interface Target {
|
|
||||||
name: string;
|
|
||||||
/** Shard fields the server's database requires on a write, if any. */
|
|
||||||
shard?: { shardId: string; shardEpoch: number };
|
|
||||||
/** A document payload this target's store reads. */
|
|
||||||
payload(text: string): Uint8Array;
|
|
||||||
start(): Promise<ServerProcess>;
|
|
||||||
connect(server: ServerProcess): Promise<Connection>;
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextConnectionId = 0x20206000;
|
|
||||||
|
|
||||||
const rust: Target = {
|
|
||||||
name: "rust",
|
|
||||||
payload: (text) => encode({ text }),
|
|
||||||
async start() {
|
|
||||||
if (!prebuilt) await execFileAsync(cargo, ["build", "--example", "receipt_interop_server", "--message-format", "short"], { cwd: rustManifestDir });
|
|
||||||
const metadata = spawnSync(cargo, ["metadata", "--format-version", "1", "--no-deps"], { cwd: rustManifestDir, encoding: "utf8" });
|
|
||||||
if (metadata.status !== 0) throw new Error(`cargo metadata failed: ${metadata.stderr}`);
|
|
||||||
const exe = join(JSON.parse(metadata.stdout).target_directory, "debug", "examples", `receipt_interop_server${process.platform === "win32" ? ".exe" : ""}`);
|
|
||||||
return launch(exe, []);
|
|
||||||
},
|
|
||||||
async connect(server) {
|
|
||||||
const peer = await CultMesh.createRudpPeer("receipt-interop", nextConnectionId++, { host: "127.0.0.1", port: server.port, uri: `rudp://127.0.0.1:${server.port}` });
|
|
||||||
return { peer, close: () => peer.close() };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// The C# server admits 30 connection requests per IP per minute (Server.MaxConnectionAttemptsPerMinute) and answers the rest with
|
|
||||||
// no hello. Every scenario wants a fresh session, so before each C# test the harness waits until the last minute holds at most
|
|
||||||
// CONNECTIONS_PER_MINUTE connections, leaving room for the two a test may open. The server's limit stays as it is.
|
|
||||||
const CONNECTIONS_PER_MINUTE = 24;
|
|
||||||
const connectedAt: number[] = [];
|
|
||||||
async function awaitConnectionBudget(): Promise<void> {
|
|
||||||
for (;;) {
|
|
||||||
const now = Date.now();
|
|
||||||
while (connectedAt.length > 0 && now - connectedAt[0] >= 61_000) connectedAt.shift();
|
|
||||||
if (connectedAt.length <= CONNECTIONS_PER_MINUTE) return;
|
|
||||||
await sleep(connectedAt[0] + 61_000 - now);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const csharp: Target = {
|
|
||||||
name: "csharp",
|
|
||||||
shard: { shardId: "receipt-interop-shard", shardEpoch: 1 },
|
|
||||||
payload: (text) => encode([text]),
|
|
||||||
async start() {
|
|
||||||
if (!prebuilt) await execFileAsync(dotnet, ["build", csharpProject, "-nologo", "-v", "q"], { cwd: repoRoot, maxBuffer: 64 * 1024 * 1024 });
|
|
||||||
return launch(dotnet, [csharpDll, "receipt-serve"]);
|
|
||||||
},
|
|
||||||
async connect(server) {
|
|
||||||
connectedAt.push(Date.now());
|
|
||||||
const relay = spawn(dotnet, [csharpDll, "receipt-relay", "--port", String(server.port)], { stdio: ["pipe", "pipe", "ignore"] });
|
|
||||||
const peer = new CultNetPeer(Duplex.from({ readable: relay.stdout!, writable: relay.stdin! }), { wireContract: "cultnet.schema.v0" });
|
|
||||||
return { peer, close: () => { relay.stdin!.end(); relay.kill(); } };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- a client session: one connection, the frames it saw, one WriteTracker per write -----------------------------------------
|
|
||||||
|
|
||||||
class Session {
|
|
||||||
readonly frames: CultNetMessage[] = [];
|
|
||||||
readonly invalid: Error[] = [];
|
|
||||||
private readonly trackers = new Set<WriteTracker>();
|
|
||||||
private serverReceipts = false;
|
|
||||||
private constructor(readonly target: Target, private readonly connection: Connection) {
|
|
||||||
connection.peer.on("message", (message: CultNetMessage) => {
|
|
||||||
if (message.schemaVersion === "cultnet.hello.v0") this.serverReceipts = helloAdvertisesWriteReceipts(message);
|
|
||||||
this.frames.push(message);
|
|
||||||
for (const tracker of this.trackers) tracker.frame(message, Date.now());
|
|
||||||
});
|
|
||||||
connection.peer.on("invalidMessage", (error: Error) => this.invalid.push(error));
|
|
||||||
connection.peer.on("close", () => { for (const tracker of this.trackers) tracker.sessionEnd(Date.now()); });
|
|
||||||
}
|
|
||||||
|
|
||||||
static async open(target: Target, server: ServerProcess): Promise<Session> {
|
|
||||||
return new Session(target, await target.connect(server));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Declares the receipt capability and waits for the server's own hello. */
|
|
||||||
async hello(versions: readonly string[] = CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS): Promise<CultNetHelloMessage> {
|
|
||||||
this.connection.peer.sendHello({ schemaVersion: "cultnet.hello.v0", runtimeId: "receipt-interop", runtimeKind: "cultmesh-ts", supportedMessageVersions: [...versions] });
|
|
||||||
await waitFor(() => this.frames.some((frame) => frame.schemaVersion === "cultnet.hello.v0"), "the server's hello");
|
|
||||||
return this.frames.find((frame) => frame.schemaVersion === "cultnet.hello.v0") as CultNetHelloMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
put(messageId: string, recordKey: string, text: string, document: Record<string, unknown> = {}): CultNetMessage {
|
|
||||||
return this.shard({
|
|
||||||
schemaVersion: "cultnet.document_put_raw.v0",
|
|
||||||
messageId,
|
|
||||||
document: {
|
|
||||||
schemaId: SCHEMA_ID, recordKey, storedAt: new Date().toISOString(), payloadEncoding: "messagepack",
|
|
||||||
payload: this.target.payload(text), ...document,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(messageId: string, recordKey: string): CultNetMessage {
|
|
||||||
return this.shard({ schemaVersion: "cultnet.document_delete.v0", messageId, schemaId: SCHEMA_ID, recordKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
private shard(message: unknown): CultNetMessage {
|
|
||||||
return { ...(message as object), ...this.target.shard } as CultNetMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends one write and settles it with its own tracker, windowed by what the server advertised (5000 ms against a receipt
|
|
||||||
* server, 500 ms otherwise). Only the tracker decides the outcome.
|
|
||||||
*/
|
|
||||||
async write(message: CultNetMessage): Promise<WriteSettlement> {
|
|
||||||
const messageId = (message as { messageId: string }).messageId;
|
|
||||||
const tracker = new WriteTracker({ windowMs: resultWindowMs(this.serverReceipts) });
|
|
||||||
this.trackers.add(tracker);
|
|
||||||
tracker.send(messageId, Date.now());
|
|
||||||
this.connection.peer.send(message);
|
|
||||||
for (;;) {
|
|
||||||
tracker.tick(Date.now());
|
|
||||||
const state = tracker.state(messageId)!;
|
|
||||||
if (state.outcome !== "pending") return state;
|
|
||||||
await sleep(Math.min(20, tracker.nextDeadlineMs(Date.now()) ?? 20));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Frames of `schemaVersion` that name `messageId`. */
|
|
||||||
answers(messageId: string, schemaVersion: string): CultNetMessage[] {
|
|
||||||
return this.frames.filter((frame) => frame.schemaVersion === schemaVersion && (frame as { messageId?: string }).messageId === messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
close(): void { this.connection.close(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function refusal(settlement: WriteSettlement): CultNetErrorV1Message {
|
|
||||||
assert.equal(settlement.outcome, "refused", JSON.stringify(settlement));
|
|
||||||
return (settlement as Extract<WriteSettlement, { outcome: "refused" }>).response as CultNetErrorV1Message;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the scenarios, run against each server -----------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
for (const target of [rust, csharp]) {
|
|
||||||
describe(`write receipt, TypeScript client against the real ${target.name} server`, () => {
|
|
||||||
let server: ServerProcess;
|
|
||||||
let session: Session | undefined;
|
|
||||||
before(async () => { server = await target.start(); }, { timeout: 180_000 });
|
|
||||||
after(async () => { session?.close(); await server?.stop(); });
|
|
||||||
beforeEach(async () => { if (target === csharp) await awaitConnectionBudget(); }, { timeout: 90_000 });
|
|
||||||
|
|
||||||
/** A fresh receipt session (hello sent, the server's hello read) for a scenario. */
|
|
||||||
async function receiptSession(): Promise<Session> {
|
|
||||||
session?.close();
|
|
||||||
session = await Session.open(target, server);
|
|
||||||
const hello = await session.hello();
|
|
||||||
assert.ok(helloAdvertisesWriteReceipts(hello), "the server's hello lists both receipt frames");
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
test("hello listing both frames: the server answers with its own hello listing both, then a put is receipted after it", { timeout: 30_000 }, async () => {
|
|
||||||
const s = await receiptSession();
|
|
||||||
assert.equal(s.frames[0].schemaVersion, "cultnet.hello.v0");
|
|
||||||
const settlement = await s.write(s.put("hello-1", "hello:a", "one"));
|
|
||||||
assert.deepEqual(settlement, { outcome: "admitted" });
|
|
||||||
assert.deepEqual(s.frames.map((frame) => frame.schemaVersion), ["cultnet.hello.v0", "cultnet.write_admitted.v0"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a hello listing only one of the two frames does not opt in: no receipt, and the put is applied", { timeout: 30_000 }, async () => {
|
|
||||||
for (const listed of CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS) {
|
|
||||||
session?.close();
|
|
||||||
session = await Session.open(target, server);
|
|
||||||
session.hello([listed]).catch(() => undefined); // a server may stay silent to a hello that does not opt in
|
|
||||||
await sleep(300);
|
|
||||||
const settlement = await session.write(session.put(`half-${listed}`, `half:${listed}`, "one"));
|
|
||||||
assert.deepEqual(settlement, { outcome: "unconfirmed", reason: "window_elapsed" }, `listing only ${listed}`);
|
|
||||||
assert.ok(!session.frames.some((frame) => frame.schemaVersion === "cultnet.write_admitted.v0" || frame.schemaVersion === "cultnet.error.v1"));
|
|
||||||
await waitFor(() => server.count("applied", `half:${listed}`) === 1, "the put to be applied");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("put admitted: the receipt echoes the write and the store has it once", { timeout: 30_000 }, async () => {
|
|
||||||
const s = await receiptSession();
|
|
||||||
const settlement = await s.write(s.put("put-1", "put:a", "one"));
|
|
||||||
assert.deepEqual(settlement, { outcome: "admitted" });
|
|
||||||
assert.deepEqual(s.answers("put-1", "cultnet.write_admitted.v0"), [
|
|
||||||
{ schemaVersion: "cultnet.write_admitted.v0", messageId: "put-1", operation: "put", schemaId: SCHEMA_ID, recordKey: "put:a" },
|
|
||||||
]);
|
|
||||||
await waitFor(() => server.count("applied", "put:a") === 1, "the put to be applied");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The Rust RUDP document server has no delete: its sink only accepts documents, and every delete on a receipt session is
|
|
||||||
// answered with a refusal (rudp_document_server.rs, deletes_on_a_receipt_session_are_refused_one_by_one). The spec
|
|
||||||
// (Server rules 1 to 3) has a delete admitted and receipted like a put.
|
|
||||||
test("delete admitted: the receipt says operation delete and the store drops the record", {
|
|
||||||
timeout: 30_000,
|
|
||||||
todo: target.name === "rust" ? "the Rust RUDP document server refuses every delete; there is no delete port in CultMeshRudpRawDocumentSink" : false,
|
|
||||||
}, async () => {
|
|
||||||
const s = await receiptSession();
|
|
||||||
assert.deepEqual(await s.write(s.put("del-put", "del:a", "one")), { outcome: "admitted" });
|
|
||||||
assert.deepEqual(await s.write(s.delete("del-1", "del:a")), { outcome: "admitted" });
|
|
||||||
assert.deepEqual(s.answers("del-1", "cultnet.write_admitted.v0"), [
|
|
||||||
{ schemaVersion: "cultnet.write_admitted.v0", messageId: "del-1", operation: "delete", schemaId: SCHEMA_ID, recordKey: "del:a" },
|
|
||||||
]);
|
|
||||||
await waitFor(() => server.count("removed", "del:a") === 1, "the delete to be applied");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refused write: an error.v1 carrying the write's id, and nothing is applied", { timeout: 30_000 }, async () => {
|
|
||||||
const s = await receiptSession();
|
|
||||||
const response = refusal(await s.write(s.put("deny-1", "refuse:a", "one")));
|
|
||||||
assert.equal(response.schemaVersion, "cultnet.error.v1");
|
|
||||||
assert.equal(response.messageId, "deny-1");
|
|
||||||
assert.equal(response.code, "write_not_authorized");
|
|
||||||
// The session is kept: the next write on it is admitted.
|
|
||||||
assert.deepEqual(await s.write(s.put("deny-2", "deny:b", "one")), { outcome: "admitted" });
|
|
||||||
assert.equal(server.count("applied", "refuse:a"), 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resend of the same id and content on the same session: re-receipted, applied once", { timeout: 30_000 }, async () => {
|
|
||||||
const s = await receiptSession();
|
|
||||||
assert.deepEqual(await s.write(s.put("resend-1", "resend:a", "one")), { outcome: "admitted" });
|
|
||||||
assert.deepEqual(await s.write(s.put("resend-1", "resend:a", "one")), { outcome: "admitted" });
|
|
||||||
const receipts = s.answers("resend-1", "cultnet.write_admitted.v0");
|
|
||||||
assert.equal(receipts.length, 2);
|
|
||||||
assert.deepEqual(receipts[0], receipts[1]);
|
|
||||||
await sleep(200); // a second application would be printed by now
|
|
||||||
assert.equal(server.count("applied", "resend:a"), 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("same id with different content: message_invalid naming the id, never a receipt, the first write untouched", { timeout: 30_000 }, async () => {
|
|
||||||
const s = await receiptSession();
|
|
||||||
assert.deepEqual(await s.write(s.put("clash-1", "clash:a", "one")), { outcome: "admitted" });
|
|
||||||
const response = refusal(await s.write(s.put("clash-1", "clash:a", "other")));
|
|
||||||
assert.equal(response.code, "message_invalid");
|
|
||||||
assert.equal(response.messageId, "clash-1");
|
|
||||||
assert.equal(response.details?.field, "messageId");
|
|
||||||
assert.equal(s.answers("clash-1", "cultnet.write_admitted.v0").length, 1, "no receipt for the changed write");
|
|
||||||
await sleep(200);
|
|
||||||
assert.equal(server.count("applied", "clash:a"), 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Each case of the vectors' blankFields section: a document that names nothing is refused as an unreadable frame, with the
|
|
||||||
// write's id and the fixed text, and never applied; a document that is merely missing optional fields is admitted.
|
|
||||||
for (const blank of vectors.blankFields.cases as { name: string; set: Record<string, unknown>; outcome: "admitted" | "refused" }[]) {
|
|
||||||
test(`blank-field vector ${blank.name}: ${blank.outcome}`, { timeout: 30_000 }, async () => {
|
|
||||||
const s = await receiptSession();
|
|
||||||
const { payloadHex, ...fields } = blank.set;
|
|
||||||
// A case that sets recordKey replaces the harness key, so the store is read under the key the server actually saw.
|
|
||||||
const recordKey = typeof fields.recordKey === "string" ? fields.recordKey : `blank:${blank.name}`;
|
|
||||||
const message = s.put(`blank-${blank.name}`, recordKey, "one", fields);
|
|
||||||
const sent = (message as unknown as { document: Record<string, unknown> }).document;
|
|
||||||
for (const [key, value] of Object.entries(fields)) if (value === null) delete sent[key]; // nil means absent on the wire here
|
|
||||||
if (payloadHex === "") sent.payload = new Uint8Array();
|
|
||||||
const settlement = await s.write(message);
|
|
||||||
if (blank.outcome === "admitted") {
|
|
||||||
assert.deepEqual(settlement, { outcome: "admitted" });
|
|
||||||
await waitFor(() => server.count("applied", recordKey) === 1, "the put to be applied");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const response = refusal(settlement);
|
|
||||||
assert.equal(response.code, "message_invalid");
|
|
||||||
assert.equal(response.error, UNREADABLE);
|
|
||||||
assert.equal(response.details?.field, "message");
|
|
||||||
assert.equal(response.messageId, `blank-${blank.name}`);
|
|
||||||
await sleep(200);
|
|
||||||
assert.equal(server.count("applied", recordKey), 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a delete that names nothing is refused as unreadable with its id", { timeout: 30_000 }, async () => {
|
|
||||||
const s = await receiptSession();
|
|
||||||
const message = s.delete("blank-del", "blank:del");
|
|
||||||
(message as unknown as { schemaId: string }).schemaId = "";
|
|
||||||
const response = refusal(await s.write(message));
|
|
||||||
assert.equal(response.code, "message_invalid");
|
|
||||||
assert.equal(response.error, UNREADABLE);
|
|
||||||
assert.equal(response.details?.field, "message");
|
|
||||||
assert.equal(response.messageId, "blank-del");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("legacy client (no hello): no receipt frames, the write is applied, and its refusal is the old error.v0", { timeout: 30_000 }, async () => {
|
|
||||||
session?.close();
|
|
||||||
session = await Session.open(target, server);
|
|
||||||
assert.deepEqual(await session.write(session.put("legacy-1", "legacy:a", "one")), { outcome: "unconfirmed", reason: "window_elapsed" });
|
|
||||||
await waitFor(() => server.count("applied", "legacy:a") === 1, "the put to be applied");
|
|
||||||
const refused = (await session.write(session.put("legacy-2", "refuse:legacy", "one"))) as Extract<WriteSettlement, { outcome: "refused" }>;
|
|
||||||
assert.equal(refused.outcome, "refused");
|
|
||||||
assert.equal(refused.response.schemaVersion, "cultnet.error.v0");
|
|
||||||
assert.deepEqual(session.frames.map((frame) => frame.schemaVersion), ["cultnet.error.v0"]);
|
|
||||||
assert.equal(session.invalid.length, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- the one-shot writer, which is the real cultmesh-ts client path, against the Rust RUDP server ----------------------------
|
|
||||||
// (The C# server speaks LiteNetLib, not RUDP: no RUDP client path reaches it.)
|
|
||||||
|
|
||||||
describe("write receipt, cultmesh-ts publishRudpDocumentOnce against the real rust server", () => {
|
|
||||||
const note = defineDocumentType({ type: "receipt.interop_note", schemaId: SCHEMA_ID, schema: z.object({ text: z.string() }), name: "text" });
|
|
||||||
const binding = defineCultNetDocumentBinding({ definition: note });
|
|
||||||
let server: ServerProcess;
|
|
||||||
before(async () => { server = await rust.start(); }, { timeout: 180_000 });
|
|
||||||
after(async () => { await server?.stop(); });
|
|
||||||
const endpoint = () => ({ host: "127.0.0.1", port: server.port, uri: `rudp://127.0.0.1:${server.port}` });
|
|
||||||
|
|
||||||
test("writeReceipts: the put resolves admitted on the receipt, after the server applied it", { timeout: 30_000 }, async () => {
|
|
||||||
const outcome = await CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "once:a", { text: "one" }, { writeReceipts: true, messageId: "once-1" });
|
|
||||||
assert.equal(outcome, "admitted");
|
|
||||||
await waitFor(() => server.count("applied", "once:a") === 1, "the put to be applied");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("writeReceipts: a refused put rejects with the server's error.v1 naming the put", { timeout: 30_000 }, async () => {
|
|
||||||
await assert.rejects(
|
|
||||||
CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "refuse:once", { text: "one" }, { writeReceipts: true, messageId: "once-2" }),
|
|
||||||
(error: unknown) => {
|
|
||||||
assert.ok(error instanceof CultMeshRudpWriteRefusedError);
|
|
||||||
assert.equal(error.response.schemaVersion, "cultnet.error.v1");
|
|
||||||
assert.equal((error.response as CultNetErrorV1Message).messageId, "once-2");
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("without writeReceipts the server stays silent: the put is unconfirmed, not admitted, though the server applied it", { timeout: 30_000 }, async () => {
|
|
||||||
await assert.rejects(
|
|
||||||
CultMesh.publishRudpDocumentOnce("receipt-interop", nextConnectionId++, endpoint(), binding, "once:legacy", { text: "one" }, { messageId: "once-3" }),
|
|
||||||
(error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed",
|
|
||||||
);
|
|
||||||
await waitFor(() => server.count("applied", "once:legacy") === 1, "the put to be applied");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,243 +0,0 @@
|
||||||
// Client side of contracts/cultnet/interop/write-receipt.md in the one-shot writer: with `writeReceipts` the put
|
|
||||||
// completes only on a write_admitted carrying its id; a refusal, a silent window and a legacy server each end as the
|
|
||||||
// spec says. Each test serves the client a fake RUDP peer 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 { z } from "zod";
|
|
||||||
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
|
||||||
import {
|
|
||||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
|
||||||
CultNetRudpSession,
|
|
||||||
decodeRudpPacket,
|
|
||||||
defineCultNetDocumentBinding,
|
|
||||||
encodeCultNetMessageForWire,
|
|
||||||
encodeRudpPacket,
|
|
||||||
type CultNetMessage,
|
|
||||||
} from "@gamecult/cultnet-ts";
|
|
||||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
|
||||||
import { CultMesh, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
|
|
||||||
|
|
||||||
const note = defineDocumentType({
|
|
||||||
type: "cultmesh.receipt_note",
|
|
||||||
schemaId: "cultmesh.receipt_note.v0",
|
|
||||||
schema: z.object({ noteId: z.string(), text: z.string() }),
|
|
||||||
name: "noteId",
|
|
||||||
});
|
|
||||||
const binding = defineCultNetDocumentBinding({ definition: note });
|
|
||||||
let nextConnection = 0x10206000;
|
|
||||||
|
|
||||||
interface Script {
|
|
||||||
/** Does the server's hello list both receipt frames? */
|
|
||||||
advertise: boolean;
|
|
||||||
/** What the server answers a put with, `delayMs` after it arrives. */
|
|
||||||
answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye" | "echo";
|
|
||||||
delayMs?: number;
|
|
||||||
/** Acknowledge the reliable packets it receives? Default true. */
|
|
||||||
ack?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fakeServer(connectionId: number, script: Script) {
|
|
||||||
const socket: Socket = createSocket("udp4");
|
|
||||||
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
|
|
||||||
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
|
|
||||||
const timers = new Set<NodeJS.Timeout>();
|
|
||||||
const seen: string[] = [];
|
|
||||||
let closed = false;
|
|
||||||
const send = (packet: Parameters<typeof encodeRudpPacket>[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<string, unknown>) =>
|
|
||||||
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(); }
|
|
||||||
});
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
## Unreleased (breaking; version 0.x, so the next minor)
|
## Unreleased (breaking; version 0.x, so the next minor)
|
||||||
|
|
||||||
- Decodes the write receipt frames of `contracts/cultnet/interop/write-receipt.md`: `parse_message` validates `cultnet.write_admitted.v0` and `cultnet.error.v1` against their closed schemas (unknown keys, empty or non-string ids, an unknown operation or code are refused; `error.v1` requires `messageId`, string or null; `limit_reached` is `error.v1` only). Added `CULTNET_ERROR_V1_CODES`, `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS`, `write_content_hash` and `hello_advertises_write_receipts`; `hello` takes `supported_message_versions`. `tests/test_write_receipt.py` replays the vectors' `contentHash`, `wire` and `schemaInvalid` sections. Not implemented here: the client write tracker (`scenarios`) and the server rules (`dedupe`, `blankFields`); this package has no receipt server and no write client.
|
|
||||||
- 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 `message_invalid` to `CULTNET_ERROR_CODES` and `message_invalid_error(field, reason)`, byte-identical to the C# `ForMessageInvalid`. `cultmesh-py` refuses a malformed put or delete with it instead of the out-of-schema `malformed_document_put` and `malformed_document_delete`, which are removed.
|
||||||
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
|
- Added `CultNetRudpSocketTransportServer` (`cultnet_py.rudp_server`): one UDP socket serving many RUDP peers, one session per
|
||||||
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by
|
remote endpoint, ported from the C# `CultNetRudpSocketTransportServer` and held to its bytes by
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@ from .cultmesh_contracts import (
|
||||||
from .framing import decode_frame, encode_frame, read_frame, write_frame
|
from .framing import decode_frame, encode_frame, read_frame, write_frame
|
||||||
from .messages import (
|
from .messages import (
|
||||||
CULTNET_ERROR_CODES,
|
CULTNET_ERROR_CODES,
|
||||||
CULTNET_ERROR_V1_CODES,
|
|
||||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
|
||||||
CultNetMessage,
|
CultNetMessage,
|
||||||
CultNetWitnessArtifactBundle,
|
CultNetWitnessArtifactBundle,
|
||||||
compute_simulation_claim_hash,
|
compute_simulation_claim_hash,
|
||||||
|
|
@ -53,9 +51,7 @@ from .messages import (
|
||||||
error_message,
|
error_message,
|
||||||
message_invalid_error,
|
message_invalid_error,
|
||||||
unsupported_schema_version_error,
|
unsupported_schema_version_error,
|
||||||
write_content_hash,
|
|
||||||
hello,
|
hello,
|
||||||
hello_advertises_write_receipts,
|
|
||||||
login,
|
login,
|
||||||
login_success,
|
login_success,
|
||||||
parse_message,
|
parse_message,
|
||||||
|
|
@ -180,8 +176,6 @@ __all__ = [
|
||||||
"verse_catalog_request",
|
"verse_catalog_request",
|
||||||
"verse_from_wire",
|
"verse_from_wire",
|
||||||
"CULTNET_ERROR_CODES",
|
"CULTNET_ERROR_CODES",
|
||||||
"CULTNET_ERROR_V1_CODES",
|
|
||||||
"CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS",
|
|
||||||
"CultNetMessage",
|
"CultNetMessage",
|
||||||
"CultNetWitnessArtifactBundle",
|
"CultNetWitnessArtifactBundle",
|
||||||
"CultNetDatabaseSubscription",
|
"CultNetDatabaseSubscription",
|
||||||
|
|
@ -272,8 +266,6 @@ __all__ = [
|
||||||
"message_invalid_error",
|
"message_invalid_error",
|
||||||
"unsupported_schema_version_error",
|
"unsupported_schema_version_error",
|
||||||
"hello",
|
"hello",
|
||||||
"hello_advertises_write_receipts",
|
|
||||||
"write_content_hash",
|
|
||||||
"INTEROP_WIRE_CONTRACT",
|
"INTEROP_WIRE_CONTRACT",
|
||||||
"login",
|
"login",
|
||||||
"login_success",
|
"login_success",
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ from typing import Any
|
||||||
|
|
||||||
import msgpack
|
import msgpack
|
||||||
|
|
||||||
from .unicode_messageid_tables import DEFAULT_IGNORABLE, LETTER_OR_NUMBER
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CultNetMessage:
|
class CultNetMessage:
|
||||||
|
|
@ -31,11 +29,7 @@ def parse_message(payload: bytes) -> CultNetMessage:
|
||||||
body = dict(decoded)
|
body = dict(decoded)
|
||||||
del body["schemaVersion"]
|
del body["schemaVersion"]
|
||||||
if schema_version == "cultnet.error.v0":
|
if schema_version == "cultnet.error.v0":
|
||||||
_validate_error_body(body, "cultnet.error.v0", CULTNET_ERROR_CODES)
|
_validate_error_body(body)
|
||||||
elif schema_version == "cultnet.error.v1":
|
|
||||||
_validate_error_body(body, "cultnet.error.v1", CULTNET_ERROR_V1_CODES)
|
|
||||||
elif schema_version == "cultnet.write_admitted.v0":
|
|
||||||
_validate_write_admitted_body(body)
|
|
||||||
return CultNetMessage(schema_version=schema_version, body=body)
|
return CultNetMessage(schema_version=schema_version, body=body)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,13 +47,6 @@ CULTNET_ERROR_CODES: tuple[str, ...] = (
|
||||||
"message_invalid",
|
"message_invalid",
|
||||||
)
|
)
|
||||||
|
|
||||||
# cultnet.error.v1 (contracts/cultnet/interop/cultnet.error.v1.schema.json): v0's codes plus limit_reached.
|
|
||||||
CULTNET_ERROR_V1_CODES: tuple[str, ...] = (*CULTNET_ERROR_CODES, "limit_reached")
|
|
||||||
|
|
||||||
# The two frames a receipt-capable hello lists in supportedMessageVersions; a peer lists both or neither
|
|
||||||
# (contracts/cultnet/interop/write-receipt.md, "Capability: opting in").
|
|
||||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS: tuple[str, ...] = ("cultnet.write_admitted.v0", "cultnet.error.v1")
|
|
||||||
|
|
||||||
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
|
_ERROR_DETAILS_KEYS = ("field", "value", "asOf", "current")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -67,80 +54,16 @@ def _is_uint(value: Any) -> bool:
|
||||||
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||||
|
|
||||||
|
|
||||||
def _is_name(value: Any) -> bool:
|
def _validate_error_body(body: dict[str, Any]) -> None:
|
||||||
return isinstance(value, str) and value != ""
|
"""Refuse a cultnet.error.v0 body that is not the full-key shape the schema requires."""
|
||||||
|
expected = ("error", "routingHint", "code", "details")
|
||||||
|
|
||||||
# write-receipt.md: a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no Default_Ignorable code point anywhere, as of
|
|
||||||
# Unicode 16.0, compared raw (never stripped). The tables are embedded (unicode_messageid_tables.py, generated by
|
|
||||||
# scripts/gen-unicode-messageid-tables.py), not unicodedata's, so the running Python's Unicode version changes no result;
|
|
||||||
# the other four runtimes embed the same generated tables.
|
|
||||||
def _in_ranges(ranges: tuple[int, ...], point: int) -> bool:
|
|
||||||
"""Whether [point] lies in the flattened, sorted (low, high) pairs."""
|
|
||||||
low, high = 0, len(ranges) // 2 - 1
|
|
||||||
while low <= high:
|
|
||||||
mid = (low + high) // 2
|
|
||||||
if point < ranges[2 * mid]:
|
|
||||||
high = mid - 1
|
|
||||||
elif point > ranges[2 * mid + 1]:
|
|
||||||
low = mid + 1
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_admitted_message_id(value: Any) -> bool:
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return False
|
|
||||||
has_letter_or_number = False
|
|
||||||
for ch in value:
|
|
||||||
point = ord(ch)
|
|
||||||
if _in_ranges(DEFAULT_IGNORABLE, point):
|
|
||||||
return False
|
|
||||||
has_letter_or_number = has_letter_or_number or _in_ranges(LETTER_OR_NUMBER, point)
|
|
||||||
return has_letter_or_number
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_write_admitted_body(body: dict[str, Any]) -> None:
|
|
||||||
"""Refuse a cultnet.write_admitted.v0 body that is not exactly the five keys of its closed schema."""
|
|
||||||
expected = ("messageId", "operation", "schemaId", "recordKey")
|
|
||||||
if set(body) != set(expected):
|
if set(body) != set(expected):
|
||||||
raise ValueError(f"cultnet.write_admitted.v0 must carry exactly {list(expected)}, got {sorted(body)}")
|
raise ValueError(f"cultnet.error.v0 must carry exactly {list(expected)}, got {sorted(body)}")
|
||||||
for key in ("messageId", "schemaId", "recordKey"):
|
|
||||||
if not _is_name(body[key]):
|
|
||||||
raise ValueError(f"cultnet.write_admitted.v0 {key} must be a non-empty string")
|
|
||||||
if not _is_admitted_message_id(body["messageId"]):
|
|
||||||
raise ValueError("cultnet.write_admitted.v0 messageId must contain a letter or number and no Default_Ignorable code point")
|
|
||||||
if body["operation"] not in ("put", "delete"):
|
|
||||||
raise ValueError("cultnet.write_admitted.v0 operation must be put or delete")
|
|
||||||
|
|
||||||
|
|
||||||
def write_content_hash(operation: str, schema_id: str, record_key: str, payload: bytes | None) -> bytes:
|
|
||||||
"""SHA-256 of the MessagePack array [operation, schemaId, recordKey, payload] (write-receipt.md, rule 5).
|
|
||||||
|
|
||||||
payload is the raw document payload, packed as a MessagePack bin for a put and nil for a delete.
|
|
||||||
"""
|
|
||||||
return hashlib.sha256(msgpack.packb([operation, schema_id, record_key, payload], use_bin_type=True)).digest()
|
|
||||||
|
|
||||||
|
|
||||||
def hello_advertises_write_receipts(hello_message: CultNetMessage) -> bool:
|
|
||||||
"""True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable."""
|
|
||||||
listed = hello_message.body.get("supportedMessageVersions")
|
|
||||||
return isinstance(listed, list) and all(version in listed for version in CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_error_body(body: dict[str, Any], version: str, codes: tuple[str, ...]) -> None:
|
|
||||||
"""Refuse a cultnet.error body that is not the full-key shape its closed schema requires."""
|
|
||||||
expected = ("error", "routingHint", "code", "details") + (("messageId",) if version == "cultnet.error.v1" else ())
|
|
||||||
if set(body) != set(expected):
|
|
||||||
raise ValueError(f"{version} must carry exactly {list(expected)}, got {sorted(body)}")
|
|
||||||
if not isinstance(body["error"], str) or not body["error"]:
|
if not isinstance(body["error"], str) or not body["error"]:
|
||||||
raise ValueError(f"{version} error must be a non-empty string")
|
raise ValueError("cultnet.error.v0 error must be a non-empty string")
|
||||||
code = body["code"]
|
code = body["code"]
|
||||||
if code is not None and (not isinstance(code, str) or code not in codes):
|
if code is not None and (not isinstance(code, str) or code not in CULTNET_ERROR_CODES):
|
||||||
raise ValueError(f"{version} code {code!r} is not a recognized refusal code")
|
raise ValueError(f"cultnet.error.v0 code {code!r} is not a recognized refusal code")
|
||||||
if "messageId" in body and body["messageId"] is not None and not _is_name(body["messageId"]):
|
|
||||||
raise ValueError(f"{version} messageId must be a non-empty string or null")
|
|
||||||
hint = body["routingHint"]
|
hint = body["routingHint"]
|
||||||
if hint is not None:
|
if hint is not None:
|
||||||
if (
|
if (
|
||||||
|
|
@ -149,17 +72,17 @@ def _validate_error_body(body: dict[str, Any], version: str, codes: tuple[str, .
|
||||||
or not isinstance(hint["reason"], str)
|
or not isinstance(hint["reason"], str)
|
||||||
or not (hint["shard"] is None or isinstance(hint["shard"], dict))
|
or not (hint["shard"] is None or isinstance(hint["shard"], dict))
|
||||||
):
|
):
|
||||||
raise ValueError(f"{version} routingHint must be null or {{shard, reason}}")
|
raise ValueError("cultnet.error.v0 routingHint must be null or {shard, reason}")
|
||||||
details = body["details"]
|
details = body["details"]
|
||||||
if details is not None:
|
if details is not None:
|
||||||
if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS):
|
if not isinstance(details, dict) or set(details) != set(_ERROR_DETAILS_KEYS):
|
||||||
raise ValueError(f"{version} details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}")
|
raise ValueError(f"cultnet.error.v0 details must be null or carry exactly {list(_ERROR_DETAILS_KEYS)}")
|
||||||
for key in ("field", "value"):
|
for key in ("field", "value"):
|
||||||
if details[key] is not None and not isinstance(details[key], str):
|
if details[key] is not None and not isinstance(details[key], str):
|
||||||
raise ValueError(f"{version} details.{key} must be a string or null")
|
raise ValueError(f"cultnet.error.v0 details.{key} must be a string or null")
|
||||||
for key in ("asOf", "current"):
|
for key in ("asOf", "current"):
|
||||||
if details[key] is not None and not _is_uint(details[key]):
|
if details[key] is not None and not _is_uint(details[key]):
|
||||||
raise ValueError(f"{version} details.{key} must be a non-negative integer or null")
|
raise ValueError(f"cultnet.error.v0 details.{key} must be a non-negative integer or null")
|
||||||
|
|
||||||
|
|
||||||
def hello(
|
def hello(
|
||||||
|
|
@ -169,18 +92,17 @@ def hello(
|
||||||
supported_schema_versions: list[str] | None = None,
|
supported_schema_versions: list[str] | None = None,
|
||||||
supported_mutation_contracts: list[dict[str, Any]] | None = None,
|
supported_mutation_contracts: list[dict[str, Any]] | None = None,
|
||||||
transport_profiles: list[dict[str, Any]] | None = None,
|
transport_profiles: list[dict[str, Any]] | None = None,
|
||||||
supported_message_versions: list[str] | None = None,
|
|
||||||
) -> CultNetMessage:
|
) -> CultNetMessage:
|
||||||
body: dict[str, Any] = {
|
return CultNetMessage(
|
||||||
"runtimeId": runtime_id,
|
"cultnet.hello.v0",
|
||||||
"supportedWireContracts": supported_wire_contracts or ["cultnet.schema.v0"],
|
{
|
||||||
"supportedSchemaVersions": supported_schema_versions or [],
|
"runtimeId": runtime_id,
|
||||||
"supportedMutationContracts": supported_mutation_contracts or [],
|
"supportedWireContracts": supported_wire_contracts or ["cultnet.schema.v0"],
|
||||||
"transportProfiles": transport_profiles or [],
|
"supportedSchemaVersions": supported_schema_versions or [],
|
||||||
}
|
"supportedMutationContracts": supported_mutation_contracts or [],
|
||||||
if supported_message_versions is not None:
|
"transportProfiles": transport_profiles or [],
|
||||||
body["supportedMessageVersions"] = supported_message_versions
|
},
|
||||||
return CultNetMessage("cultnet.hello.v0", body)
|
)
|
||||||
|
|
||||||
|
|
||||||
def login(
|
def login(
|
||||||
|
|
@ -250,7 +172,7 @@ def error_message(
|
||||||
}
|
}
|
||||||
if details is not None and set(details) - set(_ERROR_DETAILS_KEYS):
|
if details is not None and set(details) - set(_ERROR_DETAILS_KEYS):
|
||||||
raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}")
|
raise ValueError(f"cultnet.error.v0 details has unknown keys {sorted(set(details) - set(_ERROR_DETAILS_KEYS))}")
|
||||||
_validate_error_body(body, "cultnet.error.v0", CULTNET_ERROR_CODES)
|
_validate_error_body(body)
|
||||||
return CultNetMessage("cultnet.error.v0", body)
|
return CultNetMessage("cultnet.error.v0", body)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
# generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
|
||||||
|
|
||||||
# (low, high) pairs, flattened and sorted.
|
|
||||||
LETTER_OR_NUMBER: tuple[int, ...] = (
|
|
||||||
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
|
|
||||||
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
|
|
||||||
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
|
|
||||||
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
|
|
||||||
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
|
|
||||||
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
|
|
||||||
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
|
|
||||||
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
|
|
||||||
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
|
|
||||||
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
|
|
||||||
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
|
|
||||||
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
|
|
||||||
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
|
|
||||||
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
|
|
||||||
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
|
|
||||||
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
|
|
||||||
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
|
|
||||||
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
|
|
||||||
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
|
|
||||||
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
|
|
||||||
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
|
|
||||||
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A,
|
|
||||||
0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081,
|
|
||||||
0x108E, 0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248,
|
|
||||||
0x124A, 0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0,
|
|
||||||
0x12B2, 0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315,
|
|
||||||
0x1318, 0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F,
|
|
||||||
0x1681, 0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
|
|
||||||
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819,
|
|
||||||
0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D,
|
|
||||||
0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89,
|
|
||||||
0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5,
|
|
||||||
0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC,
|
|
||||||
0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45,
|
|
||||||
0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4,
|
|
||||||
0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC,
|
|
||||||
0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102,
|
|
||||||
0x2107, 0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128,
|
|
||||||
0x212A, 0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B,
|
|
||||||
0x24EA, 0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25,
|
|
||||||
0x2D27, 0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
|
|
||||||
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F,
|
|
||||||
0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA,
|
|
||||||
0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229,
|
|
||||||
0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD,
|
|
||||||
0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788,
|
|
||||||
0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A,
|
|
||||||
0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB,
|
|
||||||
0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4,
|
|
||||||
0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A,
|
|
||||||
0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD,
|
|
||||||
0xAAE0, 0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E,
|
|
||||||
0xAB30, 0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB,
|
|
||||||
0xF900, 0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
|
|
||||||
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F,
|
|
||||||
0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A,
|
|
||||||
0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D,
|
|
||||||
0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107,
|
|
||||||
0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300,
|
|
||||||
0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1,
|
|
||||||
0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530,
|
|
||||||
0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3,
|
|
||||||
0x105B1, 0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760,
|
|
||||||
0x10767, 0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A,
|
|
||||||
0x10835, 0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7,
|
|
||||||
0x108AF, 0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
|
|
||||||
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60,
|
|
||||||
0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40,
|
|
||||||
0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0,
|
|
||||||
0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80,
|
|
||||||
0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70,
|
|
||||||
0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075,
|
|
||||||
0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144,
|
|
||||||
0x11144, 0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0,
|
|
||||||
0x111DA, 0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280,
|
|
||||||
0x11286, 0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0,
|
|
||||||
0x112F9, 0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
|
|
||||||
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E,
|
|
||||||
0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447,
|
|
||||||
0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0,
|
|
||||||
0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680,
|
|
||||||
0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740,
|
|
||||||
0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915,
|
|
||||||
0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA,
|
|
||||||
0x119D0, 0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50,
|
|
||||||
0x11A50, 0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00,
|
|
||||||
0x11C08, 0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08,
|
|
||||||
0x11D09, 0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
|
|
||||||
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12,
|
|
||||||
0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480,
|
|
||||||
0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100,
|
|
||||||
0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0,
|
|
||||||
0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63,
|
|
||||||
0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50,
|
|
||||||
0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF,
|
|
||||||
0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150,
|
|
||||||
0x1B152, 0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80,
|
|
||||||
0x1BC88, 0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400,
|
|
||||||
0x1D454, 0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
|
|
||||||
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516,
|
|
||||||
0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552,
|
|
||||||
0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736,
|
|
||||||
0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE,
|
|
||||||
0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140,
|
|
||||||
0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0,
|
|
||||||
0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0,
|
|
||||||
0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71,
|
|
||||||
0x1ECAB, 0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05,
|
|
||||||
0x1EE1F, 0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39,
|
|
||||||
0x1EE39, 0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
|
|
||||||
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D,
|
|
||||||
0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74,
|
|
||||||
0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5,
|
|
||||||
0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740,
|
|
||||||
0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350,
|
|
||||||
0x323AF,
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_IGNORABLE: tuple[int, ...] = (
|
|
||||||
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
|
|
||||||
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
|
|
||||||
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
|
|
||||||
)
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import msgpack
|
|
||||||
|
|
||||||
from cultnet_py import (
|
|
||||||
CULTNET_ERROR_CODES,
|
|
||||||
CULTNET_ERROR_V1_CODES,
|
|
||||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
|
||||||
error_message,
|
|
||||||
hello,
|
|
||||||
hello_advertises_write_receipts,
|
|
||||||
parse_message,
|
|
||||||
write_content_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Decoder-side conformance with contracts/cultnet/interop/write-receipt-vectors.json: `contentHash`, `wire` and
|
|
||||||
# `schemaInvalid`. `dedupe` and `blankFields` are server obligations and `scenarios` is the client write tracker; `counts` is the file's own tally;
|
|
||||||
# this runtime has neither a receipt server nor a write client, so it replays none of them.
|
|
||||||
_INTEROP = Path(__file__).resolve().parents[3] / "contracts/cultnet/interop"
|
|
||||||
_VECTORS = json.loads((_INTEROP / "write-receipt-vectors.json").read_text(encoding="utf-8"))
|
|
||||||
_ERROR_V1_SCHEMA = json.loads((_INTEROP / "cultnet.error.v1.schema.json").read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def _pack(wire: dict[str, Any]) -> bytes:
|
|
||||||
return msgpack.packb(wire, use_bin_type=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _admitted(**override: Any) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schemaVersion": "cultnet.write_admitted.v0",
|
|
||||||
"messageId": "put-1",
|
|
||||||
"operation": "put",
|
|
||||||
"schemaId": "note.v0",
|
|
||||||
"recordKey": "note:1",
|
|
||||||
**override,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _refused(**override: Any) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"schemaVersion": "cultnet.error.v1",
|
|
||||||
"error": "write_not_authorized: no",
|
|
||||||
"routingHint": None,
|
|
||||||
"code": "write_not_authorized",
|
|
||||||
"details": None,
|
|
||||||
"messageId": "put-1",
|
|
||||||
**override,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WriteReceiptVectorTests(unittest.TestCase):
|
|
||||||
def test_vector_sections_are_the_ones_this_file_accounts_for(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
set(_VECTORS) - {"provenance", "specification"},
|
|
||||||
{"constants", "contentHash", "dedupe", "wire", "blankFields", "schemaInvalid", "scenarios", "counts", "admittedMessageId", "admittedMessageIdDuplicates"},
|
|
||||||
)
|
|
||||||
self.assertEqual(len(_VECTORS["contentHash"]["cases"]), 4)
|
|
||||||
self.assertEqual(len(_VECTORS["wire"]), 7)
|
|
||||||
self.assertEqual(len(_VECTORS["schemaInvalid"]), 7)
|
|
||||||
# The file declares how many of each it holds; a loader that drops entries disagrees with it.
|
|
||||||
counts = _VECTORS["counts"]
|
|
||||||
self.assertEqual(counts["wire"], len(_VECTORS["wire"]))
|
|
||||||
self.assertEqual(counts["schemaInvalid"], len(_VECTORS["schemaInvalid"]))
|
|
||||||
self.assertEqual(counts["contentHashCases"], len(_VECTORS["contentHash"]["cases"]))
|
|
||||||
self.assertEqual(counts["scenarioCases"], len(_VECTORS["scenarios"]["cases"]))
|
|
||||||
self.assertEqual(counts["admittedMessageIdCases"], len(_VECTORS["admittedMessageId"]["cases"]))
|
|
||||||
|
|
||||||
def test_admitted_message_id_vectors_are_refused_or_accepted_raw(self) -> None:
|
|
||||||
# `admittedMessageId`: a write_admitted messageId needs a letter or number and no Default_Ignorable code point,
|
|
||||||
# compared raw (never trimmed). A lone surrogate is not valid UTF-8, so its vector carries the whole frame.
|
|
||||||
cases = _VECTORS["admittedMessageId"]["cases"]
|
|
||||||
failures = []
|
|
||||||
for case in cases:
|
|
||||||
raw = (
|
|
||||||
bytes.fromhex(case["wireHex"])
|
|
||||||
if "wireHex" in case
|
|
||||||
else _pack(_admitted(messageId="".join(chr(int(cp, 16)) for cp in case["codePoints"]) * case.get("repeat", 1)))
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
message = parse_message(raw)
|
|
||||||
except Exception: # noqa: BLE001 - any refusal counts
|
|
||||||
refused = True
|
|
||||||
else:
|
|
||||||
refused = False
|
|
||||||
wanted = "".join(chr(int(cp, 16)) for cp in case["codePoints"]) * case.get("repeat", 1)
|
|
||||||
if message.body["messageId"] != wanted:
|
|
||||||
failures.append(f"{case['name']}: id not echoed raw")
|
|
||||||
if refused != (case["expect"] == "refuse"):
|
|
||||||
failures.append(f"{case['name']}: expected {case['expect']}")
|
|
||||||
self.assertEqual(failures, [])
|
|
||||||
|
|
||||||
def test_a_repeated_message_id_key_follows_the_last_key_wins_policy(self) -> None:
|
|
||||||
# Python decodes the frame to a dict, so it cannot see a duplicate: it judges the last key (admittedMessageIdDuplicates).
|
|
||||||
section = _VECTORS["admittedMessageIdDuplicates"]
|
|
||||||
self.assertEqual(_VECTORS["counts"]["admittedMessageIdDuplicateCases"], len(section["cases"]))
|
|
||||||
for case in section["cases"]:
|
|
||||||
raw = bytes.fromhex(case["wireHex"])
|
|
||||||
if case["lastKeyWins"] == "refuse":
|
|
||||||
with self.assertRaises(ValueError, msg=case["name"]):
|
|
||||||
parse_message(raw)
|
|
||||||
else:
|
|
||||||
self.assertEqual(parse_message(raw).body["messageId"], case["lastKeyWinsEcho"], case["name"])
|
|
||||||
|
|
||||||
def test_every_range_of_the_unicode_tables_is_pinned_at_both_ends(self) -> None:
|
|
||||||
# write-admitted-message-id-ranges.json is the generated, independent copy: for each letter-or-number range its first and
|
|
||||||
# last point are accepted and the points either side refused; for each Default_Ignorable range "a" beside its first and
|
|
||||||
# last point is refused and beside the points either side accepted. Deleting or narrowing any embedded range fails here.
|
|
||||||
ranges = json.loads((_INTEROP / "write-admitted-message-id-ranges.json").read_text(encoding="utf-8"))
|
|
||||||
self.assertGreater(len(ranges["letterOrNumber"]), 700)
|
|
||||||
self.assertEqual(len(ranges["defaultIgnorable"]), 17)
|
|
||||||
|
|
||||||
def accepted(text: str) -> bool:
|
|
||||||
try:
|
|
||||||
parse_message(_pack(_admitted(messageId=text)))
|
|
||||||
return True
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
failures = []
|
|
||||||
for low, high in ranges["letterOrNumber"]:
|
|
||||||
for point, want in ((low, True), (high, True), (low - 1, False), (high + 1, False)):
|
|
||||||
if accepted(chr(point)) != want:
|
|
||||||
failures.append(f"U+{point:04X} alone should be {'accepted' if want else 'refused'}")
|
|
||||||
for low, high in ranges["defaultIgnorable"]:
|
|
||||||
for point, want in ((low, False), (high, False), (low - 1, True), (high + 1, True)):
|
|
||||||
if accepted("a" + chr(point)) != want:
|
|
||||||
failures.append(f"a + U+{point:04X} should be {'accepted' if want else 'refused'}")
|
|
||||||
self.assertEqual(failures, [])
|
|
||||||
|
|
||||||
def test_content_hash_matches_every_vector(self) -> None:
|
|
||||||
for case in _VECTORS["contentHash"]["cases"]:
|
|
||||||
payload = None if case["payloadHex"] is None else bytes.fromhex(case["payloadHex"])
|
|
||||||
digest = write_content_hash(case["operation"], case["schemaId"], case["recordKey"], payload)
|
|
||||||
self.assertEqual(digest.hex(), case["sha256"], case["name"])
|
|
||||||
|
|
||||||
def test_wire_vectors_decode_to_their_fields_in_order_and_reencode_byte_identical(self) -> None:
|
|
||||||
for vector in _VECTORS["wire"]:
|
|
||||||
raw = bytes.fromhex(vector["messagePackHex"])
|
|
||||||
message = parse_message(raw)
|
|
||||||
self.assertEqual(message.to_wire(), vector["message"], vector["name"])
|
|
||||||
self.assertEqual(list(message.to_wire()), list(vector["message"]), vector["name"])
|
|
||||||
self.assertEqual(message.to_bytes(), raw, vector["name"])
|
|
||||||
|
|
||||||
def test_schema_invalid_vectors_are_refused(self) -> None:
|
|
||||||
for vector in _VECTORS["schemaInvalid"]:
|
|
||||||
with self.assertRaises(ValueError, msg=vector["name"]):
|
|
||||||
parse_message(bytes.fromhex(vector["messagePackHex"]))
|
|
||||||
|
|
||||||
def test_a_payload_that_is_not_bytes_changes_the_hash(self) -> None:
|
|
||||||
# The payload is a bin, not an str and not a re-parsed document.
|
|
||||||
as_bin = write_content_hash("put", "note.v0", "note:1", b"\x81\xa2id\xa11")
|
|
||||||
self.assertNotEqual(as_bin, write_content_hash("put", "note.v0", "note:1", None))
|
|
||||||
self.assertEqual(len(as_bin), 32)
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorV1Tests(unittest.TestCase):
|
|
||||||
def test_code_set_equals_the_schema_enum_and_v0_is_unchanged(self) -> None:
|
|
||||||
schema_codes = [c for c in _ERROR_V1_SCHEMA["properties"]["code"]["enum"] if c is not None]
|
|
||||||
self.assertEqual(sorted(CULTNET_ERROR_V1_CODES), sorted(schema_codes))
|
|
||||||
self.assertNotIn("limit_reached", CULTNET_ERROR_CODES)
|
|
||||||
self.assertEqual(CULTNET_ERROR_V1_CODES[-1], "limit_reached")
|
|
||||||
|
|
||||||
def test_every_v1_code_decodes_and_an_unknown_one_does_not(self) -> None:
|
|
||||||
for code in CULTNET_ERROR_V1_CODES:
|
|
||||||
self.assertEqual(parse_message(_pack(_refused(code=code))).body["code"], code)
|
|
||||||
parse_message(_pack(_refused(code=None)))
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
parse_message(_pack(_refused(code="invented")))
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
parse_message(_pack(_refused(code=7)))
|
|
||||||
|
|
||||||
def test_limit_reached_is_v1_only(self) -> None:
|
|
||||||
v0 = {k: v for k, v in _refused(code="limit_reached", schemaVersion="cultnet.error.v0").items() if k != "messageId"}
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
parse_message(_pack(v0))
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
error_message("x", code="limit_reached")
|
|
||||||
|
|
||||||
def test_message_id_is_required_and_is_a_nonempty_string_or_null(self) -> None:
|
|
||||||
self.assertEqual(parse_message(_pack(_refused(messageId="w-é 1"))).body["messageId"], "w-é 1")
|
|
||||||
self.assertIsNone(parse_message(_pack(_refused(messageId=None))).body["messageId"])
|
|
||||||
omitted = {k: v for k, v in _refused().items() if k != "messageId"}
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
parse_message(_pack(omitted))
|
|
||||||
for bad in ("", 5, b"put-1", ["put-1"], True):
|
|
||||||
with self.assertRaises(ValueError, msg=repr(bad)):
|
|
||||||
parse_message(_pack(_refused(messageId=bad)))
|
|
||||||
|
|
||||||
def test_v0_stays_closed_to_message_id(self) -> None:
|
|
||||||
v0 = _refused(schemaVersion="cultnet.error.v0", code="message_invalid")
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
parse_message(_pack(v0))
|
|
||||||
del v0["messageId"]
|
|
||||||
self.assertEqual(parse_message(_pack(v0)).schema_version, "cultnet.error.v0")
|
|
||||||
|
|
||||||
def test_v1_is_closed_to_unknown_keys_and_keeps_the_shape_rules_of_v0(self) -> None:
|
|
||||||
for bad in (
|
|
||||||
_refused(extra=1),
|
|
||||||
_refused(error=""),
|
|
||||||
_refused(error=3),
|
|
||||||
_refused(routingHint={"shard": None}),
|
|
||||||
_refused(routingHint={"shard": 1, "reason": "r"}),
|
|
||||||
_refused(routingHint={"shard": None, "reason": 1}),
|
|
||||||
_refused(details={"field": None, "value": None, "asOf": None}),
|
|
||||||
_refused(details={"field": 1, "value": None, "asOf": None, "current": None}),
|
|
||||||
_refused(details={"field": None, "value": None, "asOf": -1, "current": None}),
|
|
||||||
_refused(details={"field": None, "value": None, "asOf": None, "current": True}),
|
|
||||||
):
|
|
||||||
with self.assertRaises(ValueError, msg=str(bad)):
|
|
||||||
parse_message(_pack(bad))
|
|
||||||
for good in (
|
|
||||||
_refused(routingHint={"shard": None, "reason": "r"}),
|
|
||||||
_refused(details={"field": "messageId", "value": "x", "asOf": 1, "current": 2}),
|
|
||||||
):
|
|
||||||
parse_message(_pack(good))
|
|
||||||
|
|
||||||
def test_the_refusal_reason_names_its_version(self) -> None:
|
|
||||||
with self.assertRaisesRegex(ValueError, "cultnet.error.v1 code"):
|
|
||||||
parse_message(_pack(_refused(code="invented")))
|
|
||||||
with self.assertRaisesRegex(ValueError, r"routingHint must be null or \{shard, reason\}"):
|
|
||||||
parse_message(_pack(_refused(routingHint={"shard": None})))
|
|
||||||
|
|
||||||
|
|
||||||
class RefusalTextTests(unittest.TestCase):
|
|
||||||
"""A refusal names its frame version and the rule it broke; a caller reads this text in a log."""
|
|
||||||
|
|
||||||
def assertRefused(self, wire: dict[str, Any], pattern: str) -> None:
|
|
||||||
with self.assertRaisesRegex(ValueError, pattern):
|
|
||||||
parse_message(_pack(wire))
|
|
||||||
|
|
||||||
def test_error_frames_name_their_own_version(self) -> None:
|
|
||||||
v0 = {k: v for k, v in _refused(schemaVersion="cultnet.error.v0").items() if k != "messageId"}
|
|
||||||
self.assertRefused({**v0, "code": "nope"}, r"^cultnet\.error\.v0 code")
|
|
||||||
self.assertRefused({**v0, "extra": 1}, r"^cultnet\.error\.v0 must carry exactly")
|
|
||||||
self.assertRefused(_refused(extra=1), r"^cultnet\.error\.v1 must carry exactly")
|
|
||||||
self.assertRefused(_refused(error=""), r"^cultnet\.error\.v1 error must be")
|
|
||||||
self.assertRefused(_refused(messageId=""), r"^cultnet\.error\.v1 messageId must be")
|
|
||||||
self.assertRefused(_refused(details={"field": None}), r"^cultnet\.error\.v1 details must be null or carry exactly")
|
|
||||||
self.assertRefused(
|
|
||||||
_refused(details={"field": 1, "value": None, "asOf": None, "current": None}), r"^cultnet\.error\.v1 details\.field must be"
|
|
||||||
)
|
|
||||||
self.assertRefused(
|
|
||||||
_refused(details={"field": None, "value": None, "asOf": -1, "current": None}), r"^cultnet\.error\.v1 details\.asOf must be"
|
|
||||||
)
|
|
||||||
with self.assertRaisesRegex(ValueError, r"^cultnet\.error\.v0 code"):
|
|
||||||
error_message("x", code="bogus")
|
|
||||||
|
|
||||||
def test_write_admitted_names_the_broken_rule(self) -> None:
|
|
||||||
self.assertRefused(_admitted(extra=1), r"^cultnet\.write_admitted\.v0 must carry exactly")
|
|
||||||
self.assertRefused(_admitted(recordKey=""), r"^cultnet\.write_admitted\.v0 recordKey must be a non-empty string")
|
|
||||||
self.assertRefused(_admitted(operation="upsert"), r"^cultnet\.write_admitted\.v0 operation must be put or delete$")
|
|
||||||
|
|
||||||
|
|
||||||
class WriteAdmittedTests(unittest.TestCase):
|
|
||||||
def test_both_operations_decode(self) -> None:
|
|
||||||
for operation in ("put", "delete"):
|
|
||||||
self.assertEqual(parse_message(_pack(_admitted(operation=operation))).body["operation"], operation)
|
|
||||||
|
|
||||||
def test_every_key_is_required_and_no_other_is_allowed(self) -> None:
|
|
||||||
for key in ("messageId", "operation", "schemaId", "recordKey"):
|
|
||||||
with self.assertRaises(ValueError, msg=key):
|
|
||||||
parse_message(_pack({k: v for k, v in _admitted().items() if k != key}))
|
|
||||||
for extra in ("error", "code", "details"):
|
|
||||||
with self.assertRaises(ValueError, msg=extra):
|
|
||||||
parse_message(_pack(_admitted(**{extra: "x"})))
|
|
||||||
|
|
||||||
def test_each_string_is_nonempty_and_a_string(self) -> None:
|
|
||||||
for key in ("messageId", "schemaId", "recordKey"):
|
|
||||||
for bad in ("", None, 3, b"x"):
|
|
||||||
with self.assertRaises(ValueError, msg=f"{key}={bad!r}"):
|
|
||||||
parse_message(_pack(_admitted(**{key: bad})))
|
|
||||||
|
|
||||||
def test_operation_is_put_or_delete(self) -> None:
|
|
||||||
for bad in ("upsert", "PUT", "", None, 1):
|
|
||||||
with self.assertRaises(ValueError, msg=repr(bad)):
|
|
||||||
parse_message(_pack(_admitted(operation=bad)))
|
|
||||||
|
|
||||||
def test_message_id_is_echoed_byte_for_byte(self) -> None:
|
|
||||||
self.assertEqual(parse_message(_pack(_admitted(messageId=" w é 1 "))).body["messageId"], " w é 1 ")
|
|
||||||
|
|
||||||
|
|
||||||
class HelloCapabilityTests(unittest.TestCase):
|
|
||||||
def test_capability_needs_both_frames(self) -> None:
|
|
||||||
both = list(CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS)
|
|
||||||
self.assertEqual(both, ["cultnet.write_admitted.v0", "cultnet.error.v1"])
|
|
||||||
self.assertTrue(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=both)))
|
|
||||||
self.assertTrue(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=[*both, "x"])))
|
|
||||||
self.assertFalse(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=both[:1])))
|
|
||||||
self.assertFalse(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=both[1:])))
|
|
||||||
self.assertFalse(hello_advertises_write_receipts(hello(runtime_id="r", supported_message_versions=[])))
|
|
||||||
self.assertFalse(hello_advertises_write_receipts(hello(runtime_id="r")))
|
|
||||||
|
|
||||||
def test_a_non_list_listing_is_not_a_capability(self) -> None:
|
|
||||||
wire = hello(runtime_id="r").to_wire()
|
|
||||||
wire["supportedMessageVersions"] = "cultnet.write_admitted.v0 cultnet.error.v1"
|
|
||||||
self.assertFalse(hello_advertises_write_receipts(parse_message(_pack(wire))))
|
|
||||||
|
|
||||||
def test_hello_writes_the_listing_only_when_given_and_round_trips(self) -> None:
|
|
||||||
self.assertNotIn("supportedMessageVersions", hello(runtime_id="r").body)
|
|
||||||
listed = hello(runtime_id="r", supported_message_versions=list(CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS))
|
|
||||||
self.assertEqual(parse_message(listed.to_bytes()), listed)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
@ -249,9 +249,6 @@ pub enum CultNetErrorCode {
|
||||||
WriteNotAuthorized,
|
WriteNotAuthorized,
|
||||||
UnsupportedSchemaVersion,
|
UnsupportedSchemaVersion,
|
||||||
MessageInvalid,
|
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 {
|
impl CultNetErrorCode {
|
||||||
|
|
@ -266,7 +263,6 @@ impl CultNetErrorCode {
|
||||||
Self::WriteNotAuthorized => "write_not_authorized",
|
Self::WriteNotAuthorized => "write_not_authorized",
|
||||||
Self::UnsupportedSchemaVersion => "unsupported_schema_version",
|
Self::UnsupportedSchemaVersion => "unsupported_schema_version",
|
||||||
Self::MessageInvalid => "message_invalid",
|
Self::MessageInvalid => "message_invalid",
|
||||||
Self::LimitReached => "limit_reached",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,7 +277,6 @@ impl CultNetErrorCode {
|
||||||
"write_not_authorized" => Ok(Self::WriteNotAuthorized),
|
"write_not_authorized" => Ok(Self::WriteNotAuthorized),
|
||||||
"unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion),
|
"unsupported_schema_version" => Ok(Self::UnsupportedSchemaVersion),
|
||||||
"message_invalid" => Ok(Self::MessageInvalid),
|
"message_invalid" => Ok(Self::MessageInvalid),
|
||||||
"limit_reached" => Ok(Self::LimitReached),
|
|
||||||
_ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")),
|
_ => Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -364,26 +359,6 @@ pub enum CultNetMessage {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
details: Option<CultNetErrorDetails>,
|
details: Option<CultNetErrorDetails>,
|
||||||
},
|
},
|
||||||
/// `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<CultNetErrorCode>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
details: Option<CultNetErrorDetails>,
|
|
||||||
message_id: Option<String>,
|
|
||||||
},
|
|
||||||
/// `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")]
|
#[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")]
|
||||||
SampleChangeName { name: String },
|
SampleChangeName { name: String },
|
||||||
#[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")]
|
#[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")]
|
||||||
|
|
@ -568,9 +543,7 @@ pub fn encode_cultnet_message_for_wire(
|
||||||
| CultNetMessage::SnapshotResponseRawV1 { .. }
|
| CultNetMessage::SnapshotResponseRawV1 { .. }
|
||||||
| CultNetMessage::ContentChunkRequest { .. }
|
| CultNetMessage::ContentChunkRequest { .. }
|
||||||
| CultNetMessage::ContentChunkResponse { .. }
|
| CultNetMessage::ContentChunkResponse { .. }
|
||||||
| CultNetMessage::Error { .. }
|
| CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message),
|
||||||
| CultNetMessage::ErrorV1 { .. }
|
|
||||||
| CultNetMessage::WriteAdmitted { .. } => encode_raw_cultnet_schema_message(message),
|
|
||||||
_ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec(
|
_ => Ok(cultcache_rs::decode_payload(&rmp_serde::to_vec(
|
||||||
&serde_json::to_value(message)?,
|
&serde_json::to_value(message)?,
|
||||||
)?)?),
|
)?)?),
|
||||||
|
|
@ -600,8 +573,7 @@ pub fn decode_cultnet_message_from_slice(
|
||||||
| "cultnet.snapshot_response_raw.v0"
|
| "cultnet.snapshot_response_raw.v0"
|
||||||
| "cultmesh.content_chunk_request.v1"
|
| "cultmesh.content_chunk_request.v1"
|
||||||
| "cultmesh.content_chunk_response.v1"
|
| "cultmesh.content_chunk_response.v1"
|
||||||
| "cultnet.error.v0"
|
| "cultnet.error.v0",
|
||||||
| "cultnet.error.v1",
|
|
||||||
) => parse_raw_cultnet_schema_message(&wire_value)
|
) => parse_raw_cultnet_schema_message(&wire_value)
|
||||||
.with_context(|| received_version_label(&wire_value))?,
|
.with_context(|| received_version_label(&wire_value))?,
|
||||||
_ => cultcache_rs::decode_payload::<CultNetMessage>(bytes)
|
_ => cultcache_rs::decode_payload::<CultNetMessage>(bytes)
|
||||||
|
|
@ -673,20 +645,6 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
|
||||||
require_non_empty(session, "session")?;
|
require_non_empty(session, "session")?;
|
||||||
}
|
}
|
||||||
CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?,
|
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_admitted_message_id(message_id)?;
|
|
||||||
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::SampleChangeName { name } => require_non_empty(name, "name")?,
|
||||||
CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?,
|
CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?,
|
||||||
CultNetMessage::DocumentDelete {
|
CultNetMessage::DocumentDelete {
|
||||||
|
|
@ -934,41 +892,6 @@ fn require_non_empty(value: &str, field: &str) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// write-receipt.md: a write_admitted messageId is valid iff it holds a Unicode letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) and no
|
|
||||||
// Default_Ignorable_Code_Point, as of Unicode 16.0, compared raw (never trimmed). The tables are embedded
|
|
||||||
// (unicode_messageid_tables.rs, generated by scripts/gen-unicode-messageid-tables.py), not std's, so no platform's Unicode
|
|
||||||
// version changes a result; the other four runtimes embed the same generated tables. Invalid UTF-8 never reaches this check:
|
|
||||||
// the MessagePack str decode refuses it.
|
|
||||||
use crate::unicode_messageid_tables::{DEFAULT_IGNORABLE, LETTER_OR_NUMBER};
|
|
||||||
|
|
||||||
fn in_ranges(ranges: &[(u32, u32)], point: u32) -> bool {
|
|
||||||
ranges.binary_search_by(|&(low, high)| {
|
|
||||||
if point < low {
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
} else if point > high {
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
} else {
|
|
||||||
std::cmp::Ordering::Equal
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_admitted_message_id(value: &str) -> Result<()> {
|
|
||||||
let mut has_letter_or_number = false;
|
|
||||||
for ch in value.chars() {
|
|
||||||
let point = ch as u32;
|
|
||||||
if in_ranges(DEFAULT_IGNORABLE, point) {
|
|
||||||
return Err(anyhow!("CultNet field messageId must not hold a Default_Ignorable code point"));
|
|
||||||
}
|
|
||||||
has_letter_or_number |= in_ranges(LETTER_OR_NUMBER, point);
|
|
||||||
}
|
|
||||||
if !has_letter_or_number {
|
|
||||||
return Err(anyhow!("CultNet field messageId must contain a letter or number"));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn require_exact(value: &str, expected: &str, field: &str) -> Result<()> {
|
fn require_exact(value: &str, expected: &str, field: &str) -> Result<()> {
|
||||||
if value != expected {
|
if value != expected {
|
||||||
return Err(anyhow!("{field} must be {expected}"));
|
return Err(anyhow!("{field} must be {expected}"));
|
||||||
|
|
@ -1098,7 +1021,7 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
|
||||||
.to_vec(),
|
.to_vec(),
|
||||||
error: require_legacy_string(get("error"), "error")?,
|
error: require_legacy_string(get("error"), "error")?,
|
||||||
}),
|
}),
|
||||||
"cultnet.error.v0" | "cultnet.error.v1" => {
|
"cultnet.error.v0" => {
|
||||||
let code = get("code")
|
let code = get("code")
|
||||||
.filter(|value| !value.is_nil())
|
.filter(|value| !value.is_nil())
|
||||||
.map(|value| {
|
.map(|value| {
|
||||||
|
|
@ -1108,23 +1031,12 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
|
||||||
.and_then(CultNetErrorCode::from_wire_str)
|
.and_then(CultNetErrorCode::from_wire_str)
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
if schema_version == "cultnet.error.v0" && code == Some(CultNetErrorCode::LimitReached) {
|
|
||||||
return Err(anyhow!("ErrorMessage.Code is not a recognized cultnet.error.v0 code"));
|
|
||||||
}
|
|
||||||
let details = require_error_details(get("details"), "ErrorMessage.Details")?;
|
let details = require_error_details(get("details"), "ErrorMessage.Details")?;
|
||||||
let error = require_legacy_string(get("error"), "ErrorMessage.Error")?;
|
Ok(CultNetMessage::Error {
|
||||||
if schema_version == "cultnet.error.v1" {
|
error: require_legacy_string(get("error"), "ErrorMessage.Error")?,
|
||||||
return Ok(CultNetMessage::ErrorV1 {
|
code,
|
||||||
error,
|
details,
|
||||||
code,
|
})
|
||||||
details,
|
|
||||||
message_id: match get("messageId") {
|
|
||||||
None | Some(rmpv::Value::Nil) => None,
|
|
||||||
other => Some(require_legacy_string(other, "messageId")?),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(CultNetMessage::Error { error, code, details })
|
|
||||||
}
|
}
|
||||||
_ => Err(anyhow!(
|
_ => Err(anyhow!(
|
||||||
"Unsupported raw cultnet.schema.v0 message"
|
"Unsupported raw cultnet.schema.v0 message"
|
||||||
|
|
@ -1178,18 +1090,7 @@ fn require_legacy_optional_u64(
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::Value> {
|
fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::Value> {
|
||||||
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 {
|
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 {
|
CultNetMessage::DocumentPutRaw {
|
||||||
message_id,
|
message_id,
|
||||||
document,
|
document,
|
||||||
|
|
@ -1354,36 +1255,6 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
|
||||||
.unwrap_or(rmpv::Value::Nil),
|
.unwrap_or(rmpv::Value::Nil),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
CultNetMessage::ErrorV1 {
|
|
||||||
error,
|
|
||||||
code,
|
|
||||||
details,
|
|
||||||
message_id,
|
|
||||||
} => 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!(
|
return Err(anyhow!(
|
||||||
"Message is not a raw cultnet.schema.v0 binary replication message"
|
"Message is not a raw cultnet.schema.v0 binary replication message"
|
||||||
|
|
@ -2013,71 +1884,3 @@ fn legacy_schema_descriptor(schema: &CultNetSchemaDescriptor) -> Result<rmpv::Va
|
||||||
),
|
),
|
||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The content hash a write-receipt server binds to a `(actor, messageId)` dedupe key
|
|
||||||
/// (contracts/cultnet/interop/write-receipt.md, rule 5): SHA-256 of the MessagePack array
|
|
||||||
/// `[operation, schemaId, recordKey, payload]`, `payload` a `bin` for a put and nil for a delete.
|
|
||||||
pub fn write_content_hash(
|
|
||||||
operation: &str,
|
|
||||||
schema_id: &str,
|
|
||||||
record_key: &str,
|
|
||||||
payload: Option<&[u8]>,
|
|
||||||
) -> 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<String> {
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ mod contracts;
|
||||||
mod idunn_deployment_brake;
|
mod idunn_deployment_brake;
|
||||||
mod idunn_lifecycle_brake;
|
mod idunn_lifecycle_brake;
|
||||||
mod media_fec;
|
mod media_fec;
|
||||||
mod unicode_messageid_tables;
|
|
||||||
mod media_stream_contracts;
|
mod media_stream_contracts;
|
||||||
mod media_stream_wire;
|
mod media_stream_wire;
|
||||||
mod provider_health_contracts;
|
mod provider_health_contracts;
|
||||||
|
|
|
||||||
|
|
@ -468,14 +468,6 @@ impl CultNetRudpSession {
|
||||||
self.create_disconnect(RUDP_REFUSED_PACKET_REASON.to_vec())
|
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
|
/// 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,
|
/// built (see `is_permanent_send_error`). Resending it would fail forever,
|
||||||
/// and dropping it would leave a reliable sequence the peer waits on for
|
/// and dropping it would leave a reliable sequence the peer waits on for
|
||||||
|
|
@ -1277,12 +1269,6 @@ impl CultNetRudpSession {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether `receive` has taken this reliable sequence: delivered, held, superseded or a duplicate. A packet
|
|
||||||
/// `receive` refused unremembered (too far ahead, or before the handshake) is not, and its sender resends it.
|
|
||||||
pub fn has_received(&self, sequence: u32) -> bool {
|
|
||||||
self.was_received(sequence)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The one duplicate test: true for a sequence in the window that was
|
/// The one duplicate test: true for a sequence in the window that was
|
||||||
/// received, for one below the window, and for one at or before the
|
/// received, for one below the window, and for one at or before the
|
||||||
/// watermark. The watermark starts at the handshake's seed, so a frame the
|
/// watermark. The watermark starts at the handshake's seed, so a frame the
|
||||||
|
|
@ -2623,8 +2609,6 @@ where
|
||||||
/// Sent to a peer whose session refused one of its packets, and reported to
|
/// Sent to a peer whose session refused one of its packets, and reported to
|
||||||
/// the local caller as the disconnect reason.
|
/// the local caller as the disconnect reason.
|
||||||
const RUDP_REFUSED_PACKET_REASON: &[u8] = b"session refused a packet";
|
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";
|
const SESSION_TIMED_OUT_REASON: &[u8] = b"session timed out";
|
||||||
/// Prefix of the goodbye reason for a session that owed a packet which can
|
/// 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.
|
/// never be sent; the failure's fixed name (`send_error_code`) follows it.
|
||||||
|
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
|
||||||
|
|
||||||
// (low, high) pairs, sorted.
|
|
||||||
pub const LETTER_OR_NUMBER: &[(u32, u32)] = &[
|
|
||||||
(0x30, 0x39), (0x41, 0x5A), (0x61, 0x7A), (0xAA, 0xAA), (0xB2, 0xB3), (0xB5, 0xB5), (0xB9, 0xBA), (0xBC, 0xBE),
|
|
||||||
(0xC0, 0xD6), (0xD8, 0xF6), (0xF8, 0x2C1), (0x2C6, 0x2D1), (0x2E0, 0x2E4), (0x2EC, 0x2EC), (0x2EE, 0x2EE),
|
|
||||||
(0x370, 0x374), (0x376, 0x377), (0x37A, 0x37D), (0x37F, 0x37F), (0x386, 0x386), (0x388, 0x38A), (0x38C, 0x38C),
|
|
||||||
(0x38E, 0x3A1), (0x3A3, 0x3F5), (0x3F7, 0x481), (0x48A, 0x52F), (0x531, 0x556), (0x559, 0x559), (0x560, 0x588),
|
|
||||||
(0x5D0, 0x5EA), (0x5EF, 0x5F2), (0x620, 0x64A), (0x660, 0x669), (0x66E, 0x66F), (0x671, 0x6D3), (0x6D5, 0x6D5),
|
|
||||||
(0x6E5, 0x6E6), (0x6EE, 0x6FC), (0x6FF, 0x6FF), (0x710, 0x710), (0x712, 0x72F), (0x74D, 0x7A5), (0x7B1, 0x7B1),
|
|
||||||
(0x7C0, 0x7EA), (0x7F4, 0x7F5), (0x7FA, 0x7FA), (0x800, 0x815), (0x81A, 0x81A), (0x824, 0x824), (0x828, 0x828),
|
|
||||||
(0x840, 0x858), (0x860, 0x86A), (0x870, 0x887), (0x889, 0x88E), (0x8A0, 0x8C9), (0x904, 0x939), (0x93D, 0x93D),
|
|
||||||
(0x950, 0x950), (0x958, 0x961), (0x966, 0x96F), (0x971, 0x980), (0x985, 0x98C), (0x98F, 0x990), (0x993, 0x9A8),
|
|
||||||
(0x9AA, 0x9B0), (0x9B2, 0x9B2), (0x9B6, 0x9B9), (0x9BD, 0x9BD), (0x9CE, 0x9CE), (0x9DC, 0x9DD), (0x9DF, 0x9E1),
|
|
||||||
(0x9E6, 0x9F1), (0x9F4, 0x9F9), (0x9FC, 0x9FC), (0xA05, 0xA0A), (0xA0F, 0xA10), (0xA13, 0xA28), (0xA2A, 0xA30),
|
|
||||||
(0xA32, 0xA33), (0xA35, 0xA36), (0xA38, 0xA39), (0xA59, 0xA5C), (0xA5E, 0xA5E), (0xA66, 0xA6F), (0xA72, 0xA74),
|
|
||||||
(0xA85, 0xA8D), (0xA8F, 0xA91), (0xA93, 0xAA8), (0xAAA, 0xAB0), (0xAB2, 0xAB3), (0xAB5, 0xAB9), (0xABD, 0xABD),
|
|
||||||
(0xAD0, 0xAD0), (0xAE0, 0xAE1), (0xAE6, 0xAEF), (0xAF9, 0xAF9), (0xB05, 0xB0C), (0xB0F, 0xB10), (0xB13, 0xB28),
|
|
||||||
(0xB2A, 0xB30), (0xB32, 0xB33), (0xB35, 0xB39), (0xB3D, 0xB3D), (0xB5C, 0xB5D), (0xB5F, 0xB61), (0xB66, 0xB6F),
|
|
||||||
(0xB71, 0xB77), (0xB83, 0xB83), (0xB85, 0xB8A), (0xB8E, 0xB90), (0xB92, 0xB95), (0xB99, 0xB9A), (0xB9C, 0xB9C),
|
|
||||||
(0xB9E, 0xB9F), (0xBA3, 0xBA4), (0xBA8, 0xBAA), (0xBAE, 0xBB9), (0xBD0, 0xBD0), (0xBE6, 0xBF2), (0xC05, 0xC0C),
|
|
||||||
(0xC0E, 0xC10), (0xC12, 0xC28), (0xC2A, 0xC39), (0xC3D, 0xC3D), (0xC58, 0xC5A), (0xC5D, 0xC5D), (0xC60, 0xC61),
|
|
||||||
(0xC66, 0xC6F), (0xC78, 0xC7E), (0xC80, 0xC80), (0xC85, 0xC8C), (0xC8E, 0xC90), (0xC92, 0xCA8), (0xCAA, 0xCB3),
|
|
||||||
(0xCB5, 0xCB9), (0xCBD, 0xCBD), (0xCDD, 0xCDE), (0xCE0, 0xCE1), (0xCE6, 0xCEF), (0xCF1, 0xCF2), (0xD04, 0xD0C),
|
|
||||||
(0xD0E, 0xD10), (0xD12, 0xD3A), (0xD3D, 0xD3D), (0xD4E, 0xD4E), (0xD54, 0xD56), (0xD58, 0xD61), (0xD66, 0xD78),
|
|
||||||
(0xD7A, 0xD7F), (0xD85, 0xD96), (0xD9A, 0xDB1), (0xDB3, 0xDBB), (0xDBD, 0xDBD), (0xDC0, 0xDC6), (0xDE6, 0xDEF),
|
|
||||||
(0xE01, 0xE30), (0xE32, 0xE33), (0xE40, 0xE46), (0xE50, 0xE59), (0xE81, 0xE82), (0xE84, 0xE84), (0xE86, 0xE8A),
|
|
||||||
(0xE8C, 0xEA3), (0xEA5, 0xEA5), (0xEA7, 0xEB0), (0xEB2, 0xEB3), (0xEBD, 0xEBD), (0xEC0, 0xEC4), (0xEC6, 0xEC6),
|
|
||||||
(0xED0, 0xED9), (0xEDC, 0xEDF), (0xF00, 0xF00), (0xF20, 0xF33), (0xF40, 0xF47), (0xF49, 0xF6C), (0xF88, 0xF8C),
|
|
||||||
(0x1000, 0x102A), (0x103F, 0x1049), (0x1050, 0x1055), (0x105A, 0x105D), (0x1061, 0x1061), (0x1065, 0x1066),
|
|
||||||
(0x106E, 0x1070), (0x1075, 0x1081), (0x108E, 0x108E), (0x1090, 0x1099), (0x10A0, 0x10C5), (0x10C7, 0x10C7),
|
|
||||||
(0x10CD, 0x10CD), (0x10D0, 0x10FA), (0x10FC, 0x1248), (0x124A, 0x124D), (0x1250, 0x1256), (0x1258, 0x1258),
|
|
||||||
(0x125A, 0x125D), (0x1260, 0x1288), (0x128A, 0x128D), (0x1290, 0x12B0), (0x12B2, 0x12B5), (0x12B8, 0x12BE),
|
|
||||||
(0x12C0, 0x12C0), (0x12C2, 0x12C5), (0x12C8, 0x12D6), (0x12D8, 0x1310), (0x1312, 0x1315), (0x1318, 0x135A),
|
|
||||||
(0x1369, 0x137C), (0x1380, 0x138F), (0x13A0, 0x13F5), (0x13F8, 0x13FD), (0x1401, 0x166C), (0x166F, 0x167F),
|
|
||||||
(0x1681, 0x169A), (0x16A0, 0x16EA), (0x16EE, 0x16F8), (0x1700, 0x1711), (0x171F, 0x1731), (0x1740, 0x1751),
|
|
||||||
(0x1760, 0x176C), (0x176E, 0x1770), (0x1780, 0x17B3), (0x17D7, 0x17D7), (0x17DC, 0x17DC), (0x17E0, 0x17E9),
|
|
||||||
(0x17F0, 0x17F9), (0x1810, 0x1819), (0x1820, 0x1878), (0x1880, 0x1884), (0x1887, 0x18A8), (0x18AA, 0x18AA),
|
|
||||||
(0x18B0, 0x18F5), (0x1900, 0x191E), (0x1946, 0x196D), (0x1970, 0x1974), (0x1980, 0x19AB), (0x19B0, 0x19C9),
|
|
||||||
(0x19D0, 0x19DA), (0x1A00, 0x1A16), (0x1A20, 0x1A54), (0x1A80, 0x1A89), (0x1A90, 0x1A99), (0x1AA7, 0x1AA7),
|
|
||||||
(0x1B05, 0x1B33), (0x1B45, 0x1B4C), (0x1B50, 0x1B59), (0x1B83, 0x1BA0), (0x1BAE, 0x1BE5), (0x1C00, 0x1C23),
|
|
||||||
(0x1C40, 0x1C49), (0x1C4D, 0x1C7D), (0x1C80, 0x1C8A), (0x1C90, 0x1CBA), (0x1CBD, 0x1CBF), (0x1CE9, 0x1CEC),
|
|
||||||
(0x1CEE, 0x1CF3), (0x1CF5, 0x1CF6), (0x1CFA, 0x1CFA), (0x1D00, 0x1DBF), (0x1E00, 0x1F15), (0x1F18, 0x1F1D),
|
|
||||||
(0x1F20, 0x1F45), (0x1F48, 0x1F4D), (0x1F50, 0x1F57), (0x1F59, 0x1F59), (0x1F5B, 0x1F5B), (0x1F5D, 0x1F5D),
|
|
||||||
(0x1F5F, 0x1F7D), (0x1F80, 0x1FB4), (0x1FB6, 0x1FBC), (0x1FBE, 0x1FBE), (0x1FC2, 0x1FC4), (0x1FC6, 0x1FCC),
|
|
||||||
(0x1FD0, 0x1FD3), (0x1FD6, 0x1FDB), (0x1FE0, 0x1FEC), (0x1FF2, 0x1FF4), (0x1FF6, 0x1FFC), (0x2070, 0x2071),
|
|
||||||
(0x2074, 0x2079), (0x207F, 0x2089), (0x2090, 0x209C), (0x2102, 0x2102), (0x2107, 0x2107), (0x210A, 0x2113),
|
|
||||||
(0x2115, 0x2115), (0x2119, 0x211D), (0x2124, 0x2124), (0x2126, 0x2126), (0x2128, 0x2128), (0x212A, 0x212D),
|
|
||||||
(0x212F, 0x2139), (0x213C, 0x213F), (0x2145, 0x2149), (0x214E, 0x214E), (0x2150, 0x2189), (0x2460, 0x249B),
|
|
||||||
(0x24EA, 0x24FF), (0x2776, 0x2793), (0x2C00, 0x2CE4), (0x2CEB, 0x2CEE), (0x2CF2, 0x2CF3), (0x2CFD, 0x2CFD),
|
|
||||||
(0x2D00, 0x2D25), (0x2D27, 0x2D27), (0x2D2D, 0x2D2D), (0x2D30, 0x2D67), (0x2D6F, 0x2D6F), (0x2D80, 0x2D96),
|
|
||||||
(0x2DA0, 0x2DA6), (0x2DA8, 0x2DAE), (0x2DB0, 0x2DB6), (0x2DB8, 0x2DBE), (0x2DC0, 0x2DC6), (0x2DC8, 0x2DCE),
|
|
||||||
(0x2DD0, 0x2DD6), (0x2DD8, 0x2DDE), (0x2E2F, 0x2E2F), (0x3005, 0x3007), (0x3021, 0x3029), (0x3031, 0x3035),
|
|
||||||
(0x3038, 0x303C), (0x3041, 0x3096), (0x309D, 0x309F), (0x30A1, 0x30FA), (0x30FC, 0x30FF), (0x3105, 0x312F),
|
|
||||||
(0x3131, 0x318E), (0x3192, 0x3195), (0x31A0, 0x31BF), (0x31F0, 0x31FF), (0x3220, 0x3229), (0x3248, 0x324F),
|
|
||||||
(0x3251, 0x325F), (0x3280, 0x3289), (0x32B1, 0x32BF), (0x3400, 0x4DBF), (0x4E00, 0xA48C), (0xA4D0, 0xA4FD),
|
|
||||||
(0xA500, 0xA60C), (0xA610, 0xA62B), (0xA640, 0xA66E), (0xA67F, 0xA69D), (0xA6A0, 0xA6EF), (0xA717, 0xA71F),
|
|
||||||
(0xA722, 0xA788), (0xA78B, 0xA7CD), (0xA7D0, 0xA7D1), (0xA7D3, 0xA7D3), (0xA7D5, 0xA7DC), (0xA7F2, 0xA801),
|
|
||||||
(0xA803, 0xA805), (0xA807, 0xA80A), (0xA80C, 0xA822), (0xA830, 0xA835), (0xA840, 0xA873), (0xA882, 0xA8B3),
|
|
||||||
(0xA8D0, 0xA8D9), (0xA8F2, 0xA8F7), (0xA8FB, 0xA8FB), (0xA8FD, 0xA8FE), (0xA900, 0xA925), (0xA930, 0xA946),
|
|
||||||
(0xA960, 0xA97C), (0xA984, 0xA9B2), (0xA9CF, 0xA9D9), (0xA9E0, 0xA9E4), (0xA9E6, 0xA9FE), (0xAA00, 0xAA28),
|
|
||||||
(0xAA40, 0xAA42), (0xAA44, 0xAA4B), (0xAA50, 0xAA59), (0xAA60, 0xAA76), (0xAA7A, 0xAA7A), (0xAA7E, 0xAAAF),
|
|
||||||
(0xAAB1, 0xAAB1), (0xAAB5, 0xAAB6), (0xAAB9, 0xAABD), (0xAAC0, 0xAAC0), (0xAAC2, 0xAAC2), (0xAADB, 0xAADD),
|
|
||||||
(0xAAE0, 0xAAEA), (0xAAF2, 0xAAF4), (0xAB01, 0xAB06), (0xAB09, 0xAB0E), (0xAB11, 0xAB16), (0xAB20, 0xAB26),
|
|
||||||
(0xAB28, 0xAB2E), (0xAB30, 0xAB5A), (0xAB5C, 0xAB69), (0xAB70, 0xABE2), (0xABF0, 0xABF9), (0xAC00, 0xD7A3),
|
|
||||||
(0xD7B0, 0xD7C6), (0xD7CB, 0xD7FB), (0xF900, 0xFA6D), (0xFA70, 0xFAD9), (0xFB00, 0xFB06), (0xFB13, 0xFB17),
|
|
||||||
(0xFB1D, 0xFB1D), (0xFB1F, 0xFB28), (0xFB2A, 0xFB36), (0xFB38, 0xFB3C), (0xFB3E, 0xFB3E), (0xFB40, 0xFB41),
|
|
||||||
(0xFB43, 0xFB44), (0xFB46, 0xFBB1), (0xFBD3, 0xFD3D), (0xFD50, 0xFD8F), (0xFD92, 0xFDC7), (0xFDF0, 0xFDFB),
|
|
||||||
(0xFE70, 0xFE74), (0xFE76, 0xFEFC), (0xFF10, 0xFF19), (0xFF21, 0xFF3A), (0xFF41, 0xFF5A), (0xFF66, 0xFFBE),
|
|
||||||
(0xFFC2, 0xFFC7), (0xFFCA, 0xFFCF), (0xFFD2, 0xFFD7), (0xFFDA, 0xFFDC), (0x10000, 0x1000B), (0x1000D, 0x10026),
|
|
||||||
(0x10028, 0x1003A), (0x1003C, 0x1003D), (0x1003F, 0x1004D), (0x10050, 0x1005D), (0x10080, 0x100FA),
|
|
||||||
(0x10107, 0x10133), (0x10140, 0x10178), (0x1018A, 0x1018B), (0x10280, 0x1029C), (0x102A0, 0x102D0),
|
|
||||||
(0x102E1, 0x102FB), (0x10300, 0x10323), (0x1032D, 0x1034A), (0x10350, 0x10375), (0x10380, 0x1039D),
|
|
||||||
(0x103A0, 0x103C3), (0x103C8, 0x103CF), (0x103D1, 0x103D5), (0x10400, 0x1049D), (0x104A0, 0x104A9),
|
|
||||||
(0x104B0, 0x104D3), (0x104D8, 0x104FB), (0x10500, 0x10527), (0x10530, 0x10563), (0x10570, 0x1057A),
|
|
||||||
(0x1057C, 0x1058A), (0x1058C, 0x10592), (0x10594, 0x10595), (0x10597, 0x105A1), (0x105A3, 0x105B1),
|
|
||||||
(0x105B3, 0x105B9), (0x105BB, 0x105BC), (0x105C0, 0x105F3), (0x10600, 0x10736), (0x10740, 0x10755),
|
|
||||||
(0x10760, 0x10767), (0x10780, 0x10785), (0x10787, 0x107B0), (0x107B2, 0x107BA), (0x10800, 0x10805),
|
|
||||||
(0x10808, 0x10808), (0x1080A, 0x10835), (0x10837, 0x10838), (0x1083C, 0x1083C), (0x1083F, 0x10855),
|
|
||||||
(0x10858, 0x10876), (0x10879, 0x1089E), (0x108A7, 0x108AF), (0x108E0, 0x108F2), (0x108F4, 0x108F5),
|
|
||||||
(0x108FB, 0x1091B), (0x10920, 0x10939), (0x10980, 0x109B7), (0x109BC, 0x109CF), (0x109D2, 0x10A00),
|
|
||||||
(0x10A10, 0x10A13), (0x10A15, 0x10A17), (0x10A19, 0x10A35), (0x10A40, 0x10A48), (0x10A60, 0x10A7E),
|
|
||||||
(0x10A80, 0x10A9F), (0x10AC0, 0x10AC7), (0x10AC9, 0x10AE4), (0x10AEB, 0x10AEF), (0x10B00, 0x10B35),
|
|
||||||
(0x10B40, 0x10B55), (0x10B58, 0x10B72), (0x10B78, 0x10B91), (0x10BA9, 0x10BAF), (0x10C00, 0x10C48),
|
|
||||||
(0x10C80, 0x10CB2), (0x10CC0, 0x10CF2), (0x10CFA, 0x10D23), (0x10D30, 0x10D39), (0x10D40, 0x10D65),
|
|
||||||
(0x10D6F, 0x10D85), (0x10E60, 0x10E7E), (0x10E80, 0x10EA9), (0x10EB0, 0x10EB1), (0x10EC2, 0x10EC4),
|
|
||||||
(0x10F00, 0x10F27), (0x10F30, 0x10F45), (0x10F51, 0x10F54), (0x10F70, 0x10F81), (0x10FB0, 0x10FCB),
|
|
||||||
(0x10FE0, 0x10FF6), (0x11003, 0x11037), (0x11052, 0x1106F), (0x11071, 0x11072), (0x11075, 0x11075),
|
|
||||||
(0x11083, 0x110AF), (0x110D0, 0x110E8), (0x110F0, 0x110F9), (0x11103, 0x11126), (0x11136, 0x1113F),
|
|
||||||
(0x11144, 0x11144), (0x11147, 0x11147), (0x11150, 0x11172), (0x11176, 0x11176), (0x11183, 0x111B2),
|
|
||||||
(0x111C1, 0x111C4), (0x111D0, 0x111DA), (0x111DC, 0x111DC), (0x111E1, 0x111F4), (0x11200, 0x11211),
|
|
||||||
(0x11213, 0x1122B), (0x1123F, 0x11240), (0x11280, 0x11286), (0x11288, 0x11288), (0x1128A, 0x1128D),
|
|
||||||
(0x1128F, 0x1129D), (0x1129F, 0x112A8), (0x112B0, 0x112DE), (0x112F0, 0x112F9), (0x11305, 0x1130C),
|
|
||||||
(0x1130F, 0x11310), (0x11313, 0x11328), (0x1132A, 0x11330), (0x11332, 0x11333), (0x11335, 0x11339),
|
|
||||||
(0x1133D, 0x1133D), (0x11350, 0x11350), (0x1135D, 0x11361), (0x11380, 0x11389), (0x1138B, 0x1138B),
|
|
||||||
(0x1138E, 0x1138E), (0x11390, 0x113B5), (0x113B7, 0x113B7), (0x113D1, 0x113D1), (0x113D3, 0x113D3),
|
|
||||||
(0x11400, 0x11434), (0x11447, 0x1144A), (0x11450, 0x11459), (0x1145F, 0x11461), (0x11480, 0x114AF),
|
|
||||||
(0x114C4, 0x114C5), (0x114C7, 0x114C7), (0x114D0, 0x114D9), (0x11580, 0x115AE), (0x115D8, 0x115DB),
|
|
||||||
(0x11600, 0x1162F), (0x11644, 0x11644), (0x11650, 0x11659), (0x11680, 0x116AA), (0x116B8, 0x116B8),
|
|
||||||
(0x116C0, 0x116C9), (0x116D0, 0x116E3), (0x11700, 0x1171A), (0x11730, 0x1173B), (0x11740, 0x11746),
|
|
||||||
(0x11800, 0x1182B), (0x118A0, 0x118F2), (0x118FF, 0x11906), (0x11909, 0x11909), (0x1190C, 0x11913),
|
|
||||||
(0x11915, 0x11916), (0x11918, 0x1192F), (0x1193F, 0x1193F), (0x11941, 0x11941), (0x11950, 0x11959),
|
|
||||||
(0x119A0, 0x119A7), (0x119AA, 0x119D0), (0x119E1, 0x119E1), (0x119E3, 0x119E3), (0x11A00, 0x11A00),
|
|
||||||
(0x11A0B, 0x11A32), (0x11A3A, 0x11A3A), (0x11A50, 0x11A50), (0x11A5C, 0x11A89), (0x11A9D, 0x11A9D),
|
|
||||||
(0x11AB0, 0x11AF8), (0x11BC0, 0x11BE0), (0x11BF0, 0x11BF9), (0x11C00, 0x11C08), (0x11C0A, 0x11C2E),
|
|
||||||
(0x11C40, 0x11C40), (0x11C50, 0x11C6C), (0x11C72, 0x11C8F), (0x11D00, 0x11D06), (0x11D08, 0x11D09),
|
|
||||||
(0x11D0B, 0x11D30), (0x11D46, 0x11D46), (0x11D50, 0x11D59), (0x11D60, 0x11D65), (0x11D67, 0x11D68),
|
|
||||||
(0x11D6A, 0x11D89), (0x11D98, 0x11D98), (0x11DA0, 0x11DA9), (0x11EE0, 0x11EF2), (0x11F02, 0x11F02),
|
|
||||||
(0x11F04, 0x11F10), (0x11F12, 0x11F33), (0x11F50, 0x11F59), (0x11FB0, 0x11FB0), (0x11FC0, 0x11FD4),
|
|
||||||
(0x12000, 0x12399), (0x12400, 0x1246E), (0x12480, 0x12543), (0x12F90, 0x12FF0), (0x13000, 0x1342F),
|
|
||||||
(0x13441, 0x13446), (0x13460, 0x143FA), (0x14400, 0x14646), (0x16100, 0x1611D), (0x16130, 0x16139),
|
|
||||||
(0x16800, 0x16A38), (0x16A40, 0x16A5E), (0x16A60, 0x16A69), (0x16A70, 0x16ABE), (0x16AC0, 0x16AC9),
|
|
||||||
(0x16AD0, 0x16AED), (0x16B00, 0x16B2F), (0x16B40, 0x16B43), (0x16B50, 0x16B59), (0x16B5B, 0x16B61),
|
|
||||||
(0x16B63, 0x16B77), (0x16B7D, 0x16B8F), (0x16D40, 0x16D6C), (0x16D70, 0x16D79), (0x16E40, 0x16E96),
|
|
||||||
(0x16F00, 0x16F4A), (0x16F50, 0x16F50), (0x16F93, 0x16F9F), (0x16FE0, 0x16FE1), (0x16FE3, 0x16FE3),
|
|
||||||
(0x17000, 0x187F7), (0x18800, 0x18CD5), (0x18CFF, 0x18D08), (0x1AFF0, 0x1AFF3), (0x1AFF5, 0x1AFFB),
|
|
||||||
(0x1AFFD, 0x1AFFE), (0x1B000, 0x1B122), (0x1B132, 0x1B132), (0x1B150, 0x1B152), (0x1B155, 0x1B155),
|
|
||||||
(0x1B164, 0x1B167), (0x1B170, 0x1B2FB), (0x1BC00, 0x1BC6A), (0x1BC70, 0x1BC7C), (0x1BC80, 0x1BC88),
|
|
||||||
(0x1BC90, 0x1BC99), (0x1CCF0, 0x1CCF9), (0x1D2C0, 0x1D2D3), (0x1D2E0, 0x1D2F3), (0x1D360, 0x1D378),
|
|
||||||
(0x1D400, 0x1D454), (0x1D456, 0x1D49C), (0x1D49E, 0x1D49F), (0x1D4A2, 0x1D4A2), (0x1D4A5, 0x1D4A6),
|
|
||||||
(0x1D4A9, 0x1D4AC), (0x1D4AE, 0x1D4B9), (0x1D4BB, 0x1D4BB), (0x1D4BD, 0x1D4C3), (0x1D4C5, 0x1D505),
|
|
||||||
(0x1D507, 0x1D50A), (0x1D50D, 0x1D514), (0x1D516, 0x1D51C), (0x1D51E, 0x1D539), (0x1D53B, 0x1D53E),
|
|
||||||
(0x1D540, 0x1D544), (0x1D546, 0x1D546), (0x1D54A, 0x1D550), (0x1D552, 0x1D6A5), (0x1D6A8, 0x1D6C0),
|
|
||||||
(0x1D6C2, 0x1D6DA), (0x1D6DC, 0x1D6FA), (0x1D6FC, 0x1D714), (0x1D716, 0x1D734), (0x1D736, 0x1D74E),
|
|
||||||
(0x1D750, 0x1D76E), (0x1D770, 0x1D788), (0x1D78A, 0x1D7A8), (0x1D7AA, 0x1D7C2), (0x1D7C4, 0x1D7CB),
|
|
||||||
(0x1D7CE, 0x1D7FF), (0x1DF00, 0x1DF1E), (0x1DF25, 0x1DF2A), (0x1E030, 0x1E06D), (0x1E100, 0x1E12C),
|
|
||||||
(0x1E137, 0x1E13D), (0x1E140, 0x1E149), (0x1E14E, 0x1E14E), (0x1E290, 0x1E2AD), (0x1E2C0, 0x1E2EB),
|
|
||||||
(0x1E2F0, 0x1E2F9), (0x1E4D0, 0x1E4EB), (0x1E4F0, 0x1E4F9), (0x1E5D0, 0x1E5ED), (0x1E5F0, 0x1E5FA),
|
|
||||||
(0x1E7E0, 0x1E7E6), (0x1E7E8, 0x1E7EB), (0x1E7ED, 0x1E7EE), (0x1E7F0, 0x1E7FE), (0x1E800, 0x1E8C4),
|
|
||||||
(0x1E8C7, 0x1E8CF), (0x1E900, 0x1E943), (0x1E94B, 0x1E94B), (0x1E950, 0x1E959), (0x1EC71, 0x1ECAB),
|
|
||||||
(0x1ECAD, 0x1ECAF), (0x1ECB1, 0x1ECB4), (0x1ED01, 0x1ED2D), (0x1ED2F, 0x1ED3D), (0x1EE00, 0x1EE03),
|
|
||||||
(0x1EE05, 0x1EE1F), (0x1EE21, 0x1EE22), (0x1EE24, 0x1EE24), (0x1EE27, 0x1EE27), (0x1EE29, 0x1EE32),
|
|
||||||
(0x1EE34, 0x1EE37), (0x1EE39, 0x1EE39), (0x1EE3B, 0x1EE3B), (0x1EE42, 0x1EE42), (0x1EE47, 0x1EE47),
|
|
||||||
(0x1EE49, 0x1EE49), (0x1EE4B, 0x1EE4B), (0x1EE4D, 0x1EE4F), (0x1EE51, 0x1EE52), (0x1EE54, 0x1EE54),
|
|
||||||
(0x1EE57, 0x1EE57), (0x1EE59, 0x1EE59), (0x1EE5B, 0x1EE5B), (0x1EE5D, 0x1EE5D), (0x1EE5F, 0x1EE5F),
|
|
||||||
(0x1EE61, 0x1EE62), (0x1EE64, 0x1EE64), (0x1EE67, 0x1EE6A), (0x1EE6C, 0x1EE72), (0x1EE74, 0x1EE77),
|
|
||||||
(0x1EE79, 0x1EE7C), (0x1EE7E, 0x1EE7E), (0x1EE80, 0x1EE89), (0x1EE8B, 0x1EE9B), (0x1EEA1, 0x1EEA3),
|
|
||||||
(0x1EEA5, 0x1EEA9), (0x1EEAB, 0x1EEBB), (0x1F100, 0x1F10C), (0x1FBF0, 0x1FBF9), (0x20000, 0x2A6DF),
|
|
||||||
(0x2A700, 0x2B739), (0x2B740, 0x2B81D), (0x2B820, 0x2CEA1), (0x2CEB0, 0x2EBE0), (0x2EBF0, 0x2EE5D),
|
|
||||||
(0x2F800, 0x2FA1D), (0x30000, 0x3134A), (0x31350, 0x323AF),
|
|
||||||
];
|
|
||||||
|
|
||||||
pub const DEFAULT_IGNORABLE: &[(u32, u32)] = &[
|
|
||||||
(0xAD, 0xAD), (0x34F, 0x34F), (0x61C, 0x61C), (0x115F, 0x1160), (0x17B4, 0x17B5), (0x180B, 0x180F),
|
|
||||||
(0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
|
|
||||||
(0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF),
|
|
||||||
];
|
|
||||||
|
|
@ -1,255 +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: the message could not be read".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<u8> {
|
|
||||||
(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());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vectors `admittedMessageId`: a write_admitted messageId needs a letter or number and no Default_Ignorable code
|
|
||||||
/// point, compared raw (never trimmed). The frame is the spec's `write_admitted_put` bytes with the id swapped in; a
|
|
||||||
/// case with `wireHex` (the lone surrogate, invalid UTF-8) supplies the whole frame.
|
|
||||||
#[test]
|
|
||||||
fn write_admitted_message_id_vectors_are_refused_or_accepted_raw() {
|
|
||||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-receipt-vectors.json");
|
|
||||||
let vectors: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
|
||||||
let cases = vectors["admittedMessageId"]["cases"].as_array().unwrap();
|
|
||||||
assert_eq!(vectors["counts"]["admittedMessageIdCases"].as_u64().unwrap() as usize, cases.len());
|
|
||||||
let template = unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31");
|
|
||||||
let old_id_at = template.windows(5).position(|w| w == b"put-1").unwrap() - 1;
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
for case in cases {
|
|
||||||
let name = case["name"].as_str().unwrap();
|
|
||||||
let id: String = case["codePoints"]
|
|
||||||
.as_array()
|
|
||||||
.unwrap()
|
|
||||||
.iter()
|
|
||||||
.filter_map(|cp| char::from_u32(u32::from_str_radix(cp.as_str().unwrap(), 16).unwrap()))
|
|
||||||
.collect::<String>()
|
|
||||||
.repeat(case["repeat"].as_u64().unwrap_or(1) as usize);
|
|
||||||
let frame = match case["wireHex"].as_str() {
|
|
||||||
Some(hex) => unhex(hex),
|
|
||||||
None => {
|
|
||||||
let len = id.len();
|
|
||||||
let header: Vec<u8> = if len < 32 {
|
|
||||||
vec![0xA0 | len as u8]
|
|
||||||
} else if len < 256 {
|
|
||||||
vec![0xD9, len as u8]
|
|
||||||
} else {
|
|
||||||
vec![0xDA, (len >> 8) as u8, len as u8]
|
|
||||||
};
|
|
||||||
[&template[..old_id_at], &header[..], id.as_bytes(), &template[old_id_at + 6..]].concat()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let decoded = decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0);
|
|
||||||
match (case["expect"].as_str().unwrap(), decoded) {
|
|
||||||
("refuse", Ok(_)) => failures.push(format!("{name}: expected refuse")),
|
|
||||||
("accept", Err(error)) => failures.push(format!("{name}: expected accept, got {error}")),
|
|
||||||
("accept", Ok(CultNetMessage::WriteAdmitted { message_id, .. })) if message_id != id => {
|
|
||||||
failures.push(format!("{name}: id not echoed raw"))
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(failures.is_empty(), "{} failing:\n{}", failures.len(), failures.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every range.
|
|
||||||
#[test]
|
|
||||||
fn every_range_of_the_unicode_tables_is_pinned_at_both_ends() {
|
|
||||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-admitted-message-id-ranges.json");
|
|
||||||
let ranges: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
|
||||||
let letters = ranges["letterOrNumber"].as_array().unwrap();
|
|
||||||
let ignorable = ranges["defaultIgnorable"].as_array().unwrap();
|
|
||||||
assert!(letters.len() > 700);
|
|
||||||
assert_eq!(ignorable.len(), 17);
|
|
||||||
let template = unhex("85AD736368656D6156657273696F6EB963756C746E65742E77726974655F61646D69747465642E7630A96D6573736167654964A57075742D31A96F7065726174696F6EA3707574A8736368656D614964A76E6F74652E7630A97265636F72644B6579A66E6F74653A31");
|
|
||||||
let old_id_at = template.windows(5).position(|w| w == b"put-1").unwrap() - 1;
|
|
||||||
let accepted = |id: &str| {
|
|
||||||
let header = vec![0xA0 | id.len() as u8];
|
|
||||||
let frame = [&template[..old_id_at], &header[..], id.as_bytes(), &template[old_id_at + 6..]].concat();
|
|
||||||
decode_cultnet_message_from_slice(&frame, CultNetWireContract::CultNetSchemaV0).is_ok()
|
|
||||||
};
|
|
||||||
let point = |value: i64| char::from_u32(value as u32).unwrap().to_string();
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
for range in letters {
|
|
||||||
let (low, high) = (range[0].as_i64().unwrap(), range[1].as_i64().unwrap());
|
|
||||||
for (at, want) in [(low, true), (high, true), (low - 1, false), (high + 1, false)] {
|
|
||||||
if accepted(&point(at)) != want {
|
|
||||||
failures.push(format!("U+{at:X} alone should be {}", if want { "accepted" } else { "refused" }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for range in ignorable {
|
|
||||||
let (low, high) = (range[0].as_i64().unwrap(), range[1].as_i64().unwrap());
|
|
||||||
for (at, want) in [(low, false), (high, false), (low - 1, true), (high + 1, true)] {
|
|
||||||
if accepted(&format!("a{}", point(at))) != want {
|
|
||||||
failures.push(format!("a + U+{at:X} should be {}", if want { "accepted" } else { "refused" }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(failures.is_empty(), "{} failing:\n{}", failures.len(), failures.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Vectors `admittedMessageIdDuplicates`: a repeated messageId key is refused whatever the values. This pins the outcome, not the
|
|
||||||
/// cause (today serde's `duplicate field messageId` in the typed decode of write_admitted).
|
|
||||||
#[test]
|
|
||||||
fn a_repeated_message_id_key_is_refused_whatever_the_values() {
|
|
||||||
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../contracts/cultnet/interop/write-receipt-vectors.json");
|
|
||||||
let vectors: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
|
|
||||||
let cases = vectors["admittedMessageIdDuplicates"]["cases"].as_array().unwrap();
|
|
||||||
assert_eq!(vectors["counts"]["admittedMessageIdDuplicateCases"].as_u64().unwrap() as usize, cases.len());
|
|
||||||
for case in cases {
|
|
||||||
assert_eq!(case["seesDuplicate"], "refuse");
|
|
||||||
let decoded = decode_cultnet_message_from_slice(&unhex(case["wireHex"].as_str().unwrap()), CultNetWireContract::CultNetSchemaV0);
|
|
||||||
assert!(decoded.is_err(), "{}: a repeated messageId key must be refused", case["name"]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -7,8 +7,6 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `WriteTracker`: a write sent after `sessionEnd` settles unconfirmed `session_ended` when it is sent (it stayed unanswered forever). The replay runner now fails on an event key outside `scenarios.semantics` and on a vector count that differs from the file's `counts`; seven tracker vectors are new. A blank text in a raw frame is Unicode White_Space, not `String.prototype.trim()`.
|
|
||||||
- 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`.
|
- `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.
|
- `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.
|
- `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.
|
||||||
|
|
@ -18,7 +16,6 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
|
||||||
|
|
||||||
### Breaking
|
### 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.
|
- The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged.
|
||||||
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.
|
- `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
serve and dial commands also exercise schema-v0 over the shared RUDP socket
|
||||||
transport and advertise both transport profiles.
|
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
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,6 @@ import registerSchema from "../contracts/cultnet/cultnet.register.schema.json";
|
||||||
import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json";
|
import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json";
|
||||||
import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json";
|
import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json";
|
||||||
import errorSchema from "../contracts/cultnet/cultnet.error.schema.json";
|
import errorSchema from "../contracts/cultnet/cultnet.error.schema.json";
|
||||||
import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json";
|
|
||||||
import { DEFAULT_IGNORABLE, LETTER_OR_NUMBER } from "./unicode-messageid-tables";
|
|
||||||
import writeAdmittedSchema from "../contracts/cultnet/interop/cultnet.write-admitted.schema.json";
|
|
||||||
import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json";
|
import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json";
|
||||||
import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json";
|
import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json";
|
||||||
import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json";
|
import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json";
|
||||||
|
|
@ -65,8 +62,6 @@ export type CultNetSchemaVersion =
|
||||||
| "cultnet.verify.v0"
|
| "cultnet.verify.v0"
|
||||||
| "cultnet.login_success.v0"
|
| "cultnet.login_success.v0"
|
||||||
| "cultnet.error.v0"
|
| "cultnet.error.v0"
|
||||||
| "cultnet.error.v1"
|
|
||||||
| "cultnet.write_admitted.v0"
|
|
||||||
| "cultnet.sample.change_name.v0"
|
| "cultnet.sample.change_name.v0"
|
||||||
| "cultnet.sample.chat.v0"
|
| "cultnet.sample.chat.v0"
|
||||||
| "cultnet.document_put.v0"
|
| "cultnet.document_put.v0"
|
||||||
|
|
@ -206,11 +201,6 @@ export const CULTNET_ERROR_CODES = [
|
||||||
|
|
||||||
export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number];
|
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. */
|
/** 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 {
|
export function cultNetTextError(error: string): CultNetErrorMessage {
|
||||||
return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null };
|
return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null };
|
||||||
|
|
@ -265,34 +255,6 @@ export interface CultNetErrorMessage {
|
||||||
details: CultNetErrorDetails | null;
|
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<CultNetErrorMessage, "schemaVersion" | "code"> {
|
|
||||||
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<CultNetHelloMessage, "supportedMessageVersions">): boolean {
|
|
||||||
const listed = hello.supportedMessageVersions ?? [];
|
|
||||||
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.every((version) => listed.includes(version));
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CultNetSampleChangeNameMessage {
|
export interface CultNetSampleChangeNameMessage {
|
||||||
schemaVersion: "cultnet.sample.change_name.v0";
|
schemaVersion: "cultnet.sample.change_name.v0";
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -808,8 +770,6 @@ export interface GhostlightAgentStateDocument {
|
||||||
|
|
||||||
export type CultNetMessage =
|
export type CultNetMessage =
|
||||||
| CultNetHelloMessage
|
| CultNetHelloMessage
|
||||||
| CultNetErrorV1Message
|
|
||||||
| CultNetWriteAdmittedMessage
|
|
||||||
| CultNetLoginMessage
|
| CultNetLoginMessage
|
||||||
| CultNetRegisterMessage
|
| CultNetRegisterMessage
|
||||||
| CultNetVerifyMessage
|
| CultNetVerifyMessage
|
||||||
|
|
@ -852,8 +812,6 @@ const CULTNET_MESSAGE_SCHEMAS = [
|
||||||
verifySchema,
|
verifySchema,
|
||||||
loginSuccessSchema,
|
loginSuccessSchema,
|
||||||
errorSchema,
|
errorSchema,
|
||||||
errorV1Schema,
|
|
||||||
writeAdmittedSchema,
|
|
||||||
sampleChangeNameSchema,
|
sampleChangeNameSchema,
|
||||||
sampleChatSchema,
|
sampleChatSchema,
|
||||||
documentPutSchema,
|
documentPutSchema,
|
||||||
|
|
@ -951,10 +909,6 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon
|
||||||
return input as CultNetDatabaseChangeRawMessage;
|
return input as CultNetDatabaseChangeRawMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (schemaVersion === "cultnet.write_admitted.v0" && !isValidAdmittedMessageId((input as { messageId?: unknown }).messageId)) {
|
|
||||||
throw new Error("Validation failed for cultnet.write_admitted.v0: /messageId: must contain a letter or number and no Default_Ignorable code point");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!validator(input)) {
|
if (!validator(input)) {
|
||||||
const keys = Object.keys(input as Record<string, unknown>).join(",");
|
const keys = Object.keys(input as Record<string, unknown>).join(",");
|
||||||
throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`);
|
throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`);
|
||||||
|
|
@ -1427,7 +1381,7 @@ function validateBinaryMessageRoot(
|
||||||
if (candidate.schemaVersion !== schemaVersion) {
|
if (candidate.schemaVersion !== schemaVersion) {
|
||||||
throw new Error(`Validation failed for ${schemaVersion}: /schemaVersion: must equal ${schemaVersion}`);
|
throw new Error(`Validation failed for ${schemaVersion}: /schemaVersion: must equal ${schemaVersion}`);
|
||||||
}
|
}
|
||||||
if (typeof candidate.messageId !== "string" || isBlank(candidate.messageId)) {
|
if (typeof candidate.messageId !== "string" || candidate.messageId.trim().length === 0) {
|
||||||
throw new Error(`Validation failed for ${schemaVersion}: /messageId: must be a non-empty string`);
|
throw new Error(`Validation failed for ${schemaVersion}: /messageId: must be a non-empty string`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1465,52 +1419,8 @@ function validateRawDocumentRecord(
|
||||||
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
|
requireOptionalStringArray(candidate.tags, `${instancePath}/tags`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* A write_admitted messageId is valid iff it holds a Unicode letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) and no
|
|
||||||
* Default_Ignorable_Code_Point, as of Unicode 16.0, compared raw (write-receipt.md; the id is echoed byte for byte, so nothing
|
|
||||||
* is trimmed). The tables are embedded (unicode-messageid-tables.ts, generated by scripts/gen-unicode-messageid-tables.py), not
|
|
||||||
* the regex engine's, so the running Node's Unicode version changes no result; the other four runtimes embed the same tables.
|
|
||||||
*/
|
|
||||||
function isValidAdmittedMessageId(value: unknown): boolean {
|
|
||||||
if (typeof value !== "string") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let hasLetterOrNumber = false;
|
|
||||||
for (const ch of value) {
|
|
||||||
const point = ch.codePointAt(0) as number;
|
|
||||||
// The decoded string must be well-formed Unicode: @msgpack/msgpack turns invalid UTF-8 (ED A0 80) into a lone surrogate.
|
|
||||||
if ((point >= 0xd800 && point <= 0xdfff) || inRanges(DEFAULT_IGNORABLE, point)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
hasLetterOrNumber = hasLetterOrNumber || inRanges(LETTER_OR_NUMBER, point);
|
|
||||||
}
|
|
||||||
return hasLetterOrNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether [point] lies in the flattened, sorted [low, high] pairs. */
|
|
||||||
function inRanges(ranges: readonly number[], point: number): boolean {
|
|
||||||
let low = 0;
|
|
||||||
let high = ranges.length / 2 - 1;
|
|
||||||
while (low <= high) {
|
|
||||||
const mid = (low + high) >> 1;
|
|
||||||
if (point < ranges[2 * mid]) {
|
|
||||||
high = mid - 1;
|
|
||||||
} else if (point > ranges[2 * mid + 1]) {
|
|
||||||
low = mid + 1;
|
|
||||||
} else {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Blank is empty or only Unicode White_Space (write-receipt.md, rule 5), not `trim()`: JS trims U+FEFF and keeps U+0085. */
|
|
||||||
function isBlank(value: string): boolean {
|
|
||||||
return /^\p{White_Space}*$/u.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireNonEmptyString(input: unknown, instancePath: string): void {
|
function requireNonEmptyString(input: unknown, instancePath: string): void {
|
||||||
if (typeof input !== "string" || isBlank(input)) {
|
if (typeof input !== "string" || input.trim().length === 0) {
|
||||||
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be a non-empty string`);
|
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be a non-empty string`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1526,7 +1436,7 @@ function requireOptionalStringArray(input: unknown, instancePath: string): void
|
||||||
if (typeof input === "undefined" || input === null) {
|
if (typeof input === "undefined" || input === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || isBlank(item))) {
|
if (!Array.isArray(input) || input.some((item) => typeof item !== "string" || item.trim().length === 0)) {
|
||||||
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an array of non-empty strings`);
|
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an array of non-empty strings`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1625,8 +1535,6 @@ export const cultNetSchemas = {
|
||||||
verifySchema,
|
verifySchema,
|
||||||
loginSuccessSchema,
|
loginSuccessSchema,
|
||||||
errorSchema,
|
errorSchema,
|
||||||
errorV1Schema,
|
|
||||||
writeAdmittedSchema,
|
|
||||||
sampleChangeNameSchema,
|
sampleChangeNameSchema,
|
||||||
sampleChatSchema,
|
sampleChatSchema,
|
||||||
documentPutSchema,
|
documentPutSchema,
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ export function createIdunnRuntimePresencePublisher(
|
||||||
const publish = async (detail: string) => {
|
const publish = async (detail: string) => {
|
||||||
const signed = signer.sign(detail);
|
const signed = signer.sign(detail);
|
||||||
messageSequence += 1;
|
messageSequence += 1;
|
||||||
await sendPresenceDocument(endpoint, connectionId, {
|
await publishDocument(endpoint, connectionId, {
|
||||||
schemaVersion: "cultnet.document_put_raw.v0",
|
schemaVersion: "cultnet.document_put_raw.v0",
|
||||||
messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`,
|
messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`,
|
||||||
document: signed.document,
|
document: signed.document,
|
||||||
|
|
@ -86,19 +86,11 @@ export function createIdunnRuntimePresencePublisher(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function publishDocument(
|
||||||
* 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(
|
|
||||||
endpoint: { host: string; port: number },
|
endpoint: { host: string; port: number },
|
||||||
connectionId: number,
|
connectionId: number,
|
||||||
message: Record<string, unknown>,
|
message: Record<string, unknown>,
|
||||||
): Promise<"sent"> {
|
): Promise<void> {
|
||||||
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
|
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
socket.once("error", reject);
|
socket.once("error", reject);
|
||||||
|
|
@ -115,24 +107,26 @@ export async function sendPresenceDocument(
|
||||||
const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0"));
|
const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0"));
|
||||||
const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() });
|
const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() });
|
||||||
for (const packet of packets) await sendPacket(socket, endpoint, packet);
|
for (const packet of packets) await sendPacket(socket, endpoint, packet);
|
||||||
// The transport ack is delivery, not admission. A refusal that arrives by then rejects; no other frame means anything.
|
// The transport ack is not admission: the put completes on Odin's result frame. A refusal rejects whether
|
||||||
|
// it arrives before the ack, after it, or inside the result window; a success reply ends the wait early.
|
||||||
|
let succeeded = false;
|
||||||
const onFrame = (frame: { channelId: string; payload: Uint8Array }) => {
|
const onFrame = (frame: { channelId: string; payload: Uint8Array }) => {
|
||||||
if (frame.channelId !== "schema") return;
|
if (frame.channelId !== "schema") return;
|
||||||
const response = parseCultNetMessage(decode(frame.payload));
|
const response = parseCultNetMessage(decode(frame.payload));
|
||||||
if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response);
|
if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response);
|
||||||
// An error.v1 refuses this put only when it names it (or names nothing).
|
succeeded = true;
|
||||||
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);
|
await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame);
|
||||||
return "sent";
|
if (!succeeded) await receiveUntil(receiver, session, endpoint, () => succeeded, RESULT_WINDOW_MS, onFrame, true);
|
||||||
} finally {
|
} finally {
|
||||||
receiver.close();
|
receiver.close();
|
||||||
socket.close();
|
socket.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long after the transport ack Odin's refusal may still arrive; a success reply ends the wait early. */
|
||||||
|
const RESULT_WINDOW_MS = 500;
|
||||||
|
|
||||||
/** Odin refused the presence put: the typed `cultnet.error.v0` it answered with. */
|
/** Odin refused the presence put: the typed `cultnet.error.v0` it answered with. */
|
||||||
export class IdunnPresenceRefusedError extends Error {
|
export class IdunnPresenceRefusedError extends Error {
|
||||||
readonly code: string | undefined;
|
readonly code: string | undefined;
|
||||||
|
|
@ -206,6 +200,7 @@ async function receiveUntil(
|
||||||
predicate: (packet: CultNetRudpPacket) => boolean,
|
predicate: (packet: CultNetRudpPacket) => boolean,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void,
|
onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void,
|
||||||
|
returnOnTimeout = false,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
|
@ -220,6 +215,7 @@ async function receiveUntil(
|
||||||
}
|
}
|
||||||
for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet);
|
for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet);
|
||||||
}
|
}
|
||||||
|
if (returnOnTimeout) return;
|
||||||
throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`);
|
throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,6 @@ export {
|
||||||
type CultNetRawDocumentRecord,
|
type CultNetRawDocumentRecord,
|
||||||
type CultNetRawPayloadEncoding,
|
type CultNetRawPayloadEncoding,
|
||||||
CULTNET_ERROR_CODES,
|
CULTNET_ERROR_CODES,
|
||||||
CULTNET_ERROR_V1_CODES,
|
|
||||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
|
||||||
helloAdvertisesWriteReceipts,
|
|
||||||
type CultNetErrorV1Code,
|
|
||||||
type CultNetErrorV1Message,
|
|
||||||
type CultNetWriteAdmittedMessage,
|
|
||||||
cultNetTextError,
|
cultNetTextError,
|
||||||
cultNetMessageInvalidError,
|
cultNetMessageInvalidError,
|
||||||
cultNetWriteNotAuthorizedError,
|
cultNetWriteNotAuthorizedError,
|
||||||
|
|
@ -255,15 +249,3 @@ export {
|
||||||
type IdunnRuntimePresencePublisher,
|
type IdunnRuntimePresencePublisher,
|
||||||
type IdunnRuntimePresencePublisherOptions,
|
type IdunnRuntimePresencePublisherOptions,
|
||||||
} from "./idunn-odin-presence-publisher";
|
} 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";
|
|
||||||
|
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
|
||||||
|
|
||||||
// [low, high] pairs, sorted.
|
|
||||||
export const LETTER_OR_NUMBER: readonly number[] = [
|
|
||||||
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0, 0xD6, 0xD8,
|
|
||||||
0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374, 0x376, 0x377, 0x37A,
|
|
||||||
0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3F5, 0x3F7, 0x481, 0x48A,
|
|
||||||
0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2, 0x620, 0x64A, 0x660, 0x669, 0x66E,
|
|
||||||
0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF, 0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D,
|
|
||||||
0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA, 0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828,
|
|
||||||
0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889, 0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950,
|
|
||||||
0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2,
|
|
||||||
0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE, 0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC,
|
|
||||||
0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59,
|
|
||||||
0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2,
|
|
||||||
0xAB3, 0xAB5, 0xAB9, 0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F,
|
|
||||||
0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66,
|
|
||||||
0xB6F, 0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
|
|
||||||
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12,
|
|
||||||
0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78, 0xC7E, 0xC80,
|
|
||||||
0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD, 0xCDD, 0xCDE, 0xCE0,
|
|
||||||
0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D, 0xD3D, 0xD4E, 0xD4E, 0xD54,
|
|
||||||
0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1, 0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0,
|
|
||||||
0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86,
|
|
||||||
0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0,
|
|
||||||
0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40, 0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A, 0x103F,
|
|
||||||
0x1049, 0x1050, 0x1055, 0x105A, 0x105D, 0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081, 0x108E,
|
|
||||||
0x108E, 0x1090, 0x1099, 0x10A0, 0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248, 0x124A,
|
|
||||||
0x124D, 0x1250, 0x1256, 0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0, 0x12B2,
|
|
||||||
0x12B5, 0x12B8, 0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315, 0x1318,
|
|
||||||
0x135A, 0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F, 0x1681,
|
|
||||||
0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C, 0x176E,
|
|
||||||
0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810, 0x1819, 0x1820,
|
|
||||||
0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E, 0x1946, 0x196D, 0x1970,
|
|
||||||
0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20, 0x1A54, 0x1A80, 0x1A89, 0x1A90,
|
|
||||||
0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59, 0x1B83, 0x1BA0, 0x1BAE, 0x1BE5, 0x1C00,
|
|
||||||
0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90, 0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC, 0x1CEE,
|
|
||||||
0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF, 0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48,
|
|
||||||
0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6,
|
|
||||||
0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2,
|
|
||||||
0x1FF4, 0x1FF6, 0x1FFC, 0x2070, 0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102, 0x2107,
|
|
||||||
0x2107, 0x210A, 0x2113, 0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128, 0x212A,
|
|
||||||
0x212D, 0x212F, 0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B, 0x24EA,
|
|
||||||
0x24FF, 0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25, 0x2D27,
|
|
||||||
0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE, 0x2DB0,
|
|
||||||
0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F, 0x2E2F, 0x3005,
|
|
||||||
0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F, 0x30A1, 0x30FA, 0x30FC,
|
|
||||||
0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0, 0x31FF, 0x3220, 0x3229, 0x3248,
|
|
||||||
0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF, 0x4E00, 0xA48C, 0xA4D0, 0xA4FD, 0xA500,
|
|
||||||
0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0, 0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788, 0xA78B,
|
|
||||||
0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC, 0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A, 0xA80C,
|
|
||||||
0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882, 0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB, 0xA8FD,
|
|
||||||
0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946, 0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4, 0xA9E6,
|
|
||||||
0xA9FE, 0xAA00, 0xAA28, 0xAA40, 0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A, 0xAA7E,
|
|
||||||
0xAAAF, 0xAAB1, 0xAAB1, 0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD, 0xAAE0,
|
|
||||||
0xAAEA, 0xAAF2, 0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E, 0xAB30,
|
|
||||||
0xAB5A, 0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB, 0xF900,
|
|
||||||
0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36, 0xFB38,
|
|
||||||
0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50, 0xFD8F, 0xFD92,
|
|
||||||
0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A, 0xFF41, 0xFF5A, 0xFF66,
|
|
||||||
0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000, 0x1000B, 0x1000D, 0x10026,
|
|
||||||
0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D, 0x10080, 0x100FA, 0x10107, 0x10133,
|
|
||||||
0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0, 0x102D0, 0x102E1, 0x102FB, 0x10300, 0x10323,
|
|
||||||
0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D, 0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1, 0x103D5,
|
|
||||||
0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0, 0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530, 0x10563,
|
|
||||||
0x10570, 0x1057A, 0x1057C, 0x1058A, 0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3, 0x105B1,
|
|
||||||
0x105B3, 0x105B9, 0x105BB, 0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760, 0x10767,
|
|
||||||
0x10780, 0x10785, 0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A, 0x10835,
|
|
||||||
0x10837, 0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7, 0x108AF,
|
|
||||||
0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC, 0x109CF,
|
|
||||||
0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48, 0x10A60, 0x10A7E,
|
|
||||||
0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00, 0x10B35, 0x10B40, 0x10B55,
|
|
||||||
0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48, 0x10C80, 0x10CB2, 0x10CC0, 0x10CF2,
|
|
||||||
0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F, 0x10D85, 0x10E60, 0x10E7E, 0x10E80, 0x10EA9,
|
|
||||||
0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27, 0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70, 0x10F81,
|
|
||||||
0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003, 0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075, 0x11075,
|
|
||||||
0x11083, 0x110AF, 0x110D0, 0x110E8, 0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144, 0x11144,
|
|
||||||
0x11147, 0x11147, 0x11150, 0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0, 0x111DA,
|
|
||||||
0x111DC, 0x111DC, 0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280, 0x11286,
|
|
||||||
0x11288, 0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0, 0x112F9,
|
|
||||||
0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335, 0x11339,
|
|
||||||
0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B, 0x1138E, 0x1138E,
|
|
||||||
0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400, 0x11434, 0x11447, 0x1144A,
|
|
||||||
0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5, 0x114C7, 0x114C7, 0x114D0, 0x114D9,
|
|
||||||
0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644, 0x11644, 0x11650, 0x11659, 0x11680, 0x116AA,
|
|
||||||
0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3, 0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740, 0x11746,
|
|
||||||
0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF, 0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915, 0x11916,
|
|
||||||
0x11918, 0x1192F, 0x1193F, 0x1193F, 0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA, 0x119D0,
|
|
||||||
0x119E1, 0x119E1, 0x119E3, 0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50, 0x11A50,
|
|
||||||
0x11A5C, 0x11A89, 0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00, 0x11C08,
|
|
||||||
0x11C0A, 0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08, 0x11D09,
|
|
||||||
0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A, 0x11D89,
|
|
||||||
0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10, 0x11F12, 0x11F33,
|
|
||||||
0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400, 0x1246E, 0x12480, 0x12543,
|
|
||||||
0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA, 0x14400, 0x14646, 0x16100, 0x1611D,
|
|
||||||
0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60, 0x16A69, 0x16A70, 0x16ABE, 0x16AC0, 0x16AC9,
|
|
||||||
0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43, 0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63, 0x16B77,
|
|
||||||
0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70, 0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50, 0x16F50,
|
|
||||||
0x16F93, 0x16F9F, 0x16FE0, 0x16FE1, 0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF, 0x18D08,
|
|
||||||
0x1AFF0, 0x1AFF3, 0x1AFF5, 0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152,
|
|
||||||
0x1B155, 0x1B155, 0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80, 0x1BC88,
|
|
||||||
0x1BC90, 0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400, 0x1D454,
|
|
||||||
0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE, 0x1D4B9,
|
|
||||||
0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514, 0x1D516, 0x1D51C,
|
|
||||||
0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A, 0x1D550, 0x1D552, 0x1D6A5,
|
|
||||||
0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714, 0x1D716, 0x1D734, 0x1D736, 0x1D74E,
|
|
||||||
0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA, 0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE, 0x1D7FF,
|
|
||||||
0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D, 0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140, 0x1E149,
|
|
||||||
0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0, 0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0, 0x1E4F9,
|
|
||||||
0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA, 0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0, 0x1E7FE,
|
|
||||||
0x1E800, 0x1E8C4, 0x1E8C7, 0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71, 0x1ECAB,
|
|
||||||
0x1ECAD, 0x1ECAF, 0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05, 0x1EE1F,
|
|
||||||
0x1EE21, 0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39, 0x1EE39,
|
|
||||||
0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D, 0x1EE4F,
|
|
||||||
0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B, 0x1EE5D, 0x1EE5D,
|
|
||||||
0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C, 0x1EE72, 0x1EE74, 0x1EE77,
|
|
||||||
0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B, 0x1EEA1, 0x1EEA3, 0x1EEA5, 0x1EEA9,
|
|
||||||
0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000, 0x2A6DF, 0x2A700, 0x2B739, 0x2B740, 0x2B81D,
|
|
||||||
0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D, 0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350, 0x323AF,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_IGNORABLE: readonly number[] = [
|
|
||||||
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F, 0x202A,
|
|
||||||
0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0, 0xFFF8, 0x1BCA0,
|
|
||||||
0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
|
|
||||||
];
|
|
||||||
|
|
@ -1,150 +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<string, Entry>();
|
|
||||||
#ended = false;
|
|
||||||
|
|
||||||
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)) return;
|
|
||||||
const entry: Entry = { sentAtMs: atMs, state: { outcome: "pending" } };
|
|
||||||
this.#writes.set(messageId, entry);
|
|
||||||
// On a session that already ended nothing can answer this write.
|
|
||||||
if (this.#ended) this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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, and so does any write sent afterwards. */
|
|
||||||
sessionEnd(atMs: number): void {
|
|
||||||
this.tick(atMs);
|
|
||||||
this.#ended = true;
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -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<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
|
|
||||||
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
|
|
||||||
const send = (packet: Parameters<typeof encodeRudpPacket>[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(); }
|
|
||||||
});
|
|
||||||
|
|
@ -345,27 +345,18 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte
|
||||||
assert.equal(peer.failure, undefined, peer.failure?.message);
|
assert.equal(peer.failure, undefined, peer.failure?.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the Odin publisher rejects a presence whose refusal arrives before the transport ack", async (context) => {
|
for (const order of ["ack-then-refusal", "refusal-then-ack"] as const) {
|
||||||
const endpoint = expectedFacts("service").odinEndpoint!;
|
test(`the Odin publisher does not complete on the ack of a refused presence (${order})`, async (context) => {
|
||||||
const peer = await startOdinPeer(context, endpoint);
|
const endpoint = expectedFacts("service").odinEndpoint!;
|
||||||
peer.reject = true;
|
const peer = await startOdinPeer(context, endpoint);
|
||||||
peer.order = "refusal-then-ack";
|
peer.reject = true;
|
||||||
const signer = signerFor(context, "service");
|
peer.order = order;
|
||||||
signer.reportHealth("active");
|
const signer = signerFor(context, "service");
|
||||||
const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint });
|
signer.reportHealth("active");
|
||||||
await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/);
|
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) => {
|
test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => {
|
||||||
const endpoint = expectedFacts("service").odinEndpoint!;
|
const endpoint = expectedFacts("service").odinEndpoint!;
|
||||||
|
|
|
||||||
|
|
@ -1,339 +0,0 @@
|
||||||
import test from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
||||||
import { tmpdir } from "node:os";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { decode } from "@msgpack/msgpack";
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A runner that skips what it cannot read passes a file it did not replay. It must fail instead.
|
|
||||||
test("the replay runner fails on an unknown event key and on a vector count the file does not declare", () => {
|
|
||||||
const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs");
|
|
||||||
const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), "receipt-vectors-"));
|
|
||||||
const runWith = (edit: (vectors: any) => void) => {
|
|
||||||
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
|
|
||||||
edit(vectors);
|
|
||||||
const file = join(dir, "vectors.json");
|
|
||||||
writeFileSync(file, JSON.stringify(vectors));
|
|
||||||
return spawnSync(process.execPath, [script, file], { encoding: "utf8" });
|
|
||||||
};
|
|
||||||
const typo = runWith((v) => { v.scenarios.cases[0].events[0] = { atMs: 0, sendfailed: "w1" }; });
|
|
||||||
assert.equal(typo.status, 1, typo.stdout);
|
|
||||||
assert.match(typo.stdout, /^FAIL scenario\/receipt_admits$/m);
|
|
||||||
const dropped = runWith((v) => { v.scenarios.cases.pop(); });
|
|
||||||
assert.equal(dropped.status, 1, dropped.stdout);
|
|
||||||
assert.match(dropped.stdout, /^FAIL counts$/m);
|
|
||||||
const undeclared = runWith((v) => { v.wire.push(v.wire[0]); });
|
|
||||||
assert.equal(undeclared.status, 1, undeclared.stdout);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a write sent after the session ended settles unconfirmed session_ended at once, and the earlier receipt stands", () => {
|
|
||||||
const settled: [string, WriteSettlement][] = [];
|
|
||||||
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
|
|
||||||
tracker.send("w1", 0);
|
|
||||||
tracker.frame(admitted("w1"), 5);
|
|
||||||
tracker.sessionEnd(40);
|
|
||||||
tracker.send("w2", 50);
|
|
||||||
assert.deepEqual(settled, [["w1", { outcome: "admitted" }], ["w2", { outcome: "unconfirmed", reason: "session_ended" }]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
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" });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Blank is Unicode White_Space (write-receipt.md, rule 5), not what String.prototype.trim() trims: trim() takes U+FEFF and
|
|
||||||
// misses U+0085. Every frame decoder that rejects a blank text must agree with the servers that do.
|
|
||||||
test("a blank messageId, record key or tag is exactly Unicode White_Space, not trim()", () => {
|
|
||||||
const put = (document: Record<string, unknown>, messageId = "p1") => ({
|
|
||||||
schemaVersion: "cultnet.document_put_raw.v0", messageId,
|
|
||||||
document: { schemaId: "note.v0", recordKey: "note:1", storedAt: "2026-11-08T00:00:00Z", payloadEncoding: "messagepack", payload: Uint8Array.of(0x81, 0xa1, 0x61, 0x01), ...document },
|
|
||||||
});
|
|
||||||
for (const blank of ["\u0085", " ", " "]) {
|
|
||||||
assert.throws(() => parseCultNetMessage(put({}, blank)), `messageId ${JSON.stringify(blank)}`);
|
|
||||||
assert.throws(() => parseCultNetMessage(put({ recordKey: blank })), `recordKey ${JSON.stringify(blank)}`);
|
|
||||||
assert.throws(() => parseCultNetMessage(put({ tags: [blank] })), `tag ${JSON.stringify(blank)}`);
|
|
||||||
}
|
|
||||||
for (const named of ["", "", "\u001c", ""]) {
|
|
||||||
parseCultNetMessage(put({}, named));
|
|
||||||
parseCultNetMessage(put({ recordKey: named }));
|
|
||||||
parseCultNetMessage(put({ tags: [named] }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// `admittedMessageId` vectors: a write_admitted messageId needs a letter or number and no Default_Ignorable code point,
|
|
||||||
// compared raw (never trimmed). The D800 vector is the frame's bytes (wireHex), decoded by the msgpack library like the others.
|
|
||||||
test("write_admitted messageId: every admittedMessageId vector is refused or accepted raw", () => {
|
|
||||||
const vectorsPath = join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json");
|
|
||||||
const vectors = JSON.parse(readFileSync(vectorsPath, "utf8"));
|
|
||||||
assert.equal(vectors.counts.admittedMessageIdCases, vectors.admittedMessageId.cases.length);
|
|
||||||
const failures: string[] = [];
|
|
||||||
for (const vector of vectors.admittedMessageId.cases as { name: string; codePoints: string[]; repeat?: number; wireHex?: string; expect: "refuse" | "accept" }[]) {
|
|
||||||
const id = vector.codePoints.map((cp) => String.fromCodePoint(parseInt(cp, 16))).join("").repeat(vector.repeat ?? 1);
|
|
||||||
let decodedId: string | undefined;
|
|
||||||
try {
|
|
||||||
const frame = vector.wireHex ? decode(Uint8Array.from(Buffer.from(vector.wireHex, "hex"))) : admitted(id);
|
|
||||||
decodedId = (parseCultNetMessage(frame) as CultNetWriteAdmittedMessage).messageId;
|
|
||||||
} catch {
|
|
||||||
decodedId = undefined;
|
|
||||||
}
|
|
||||||
if ((decodedId === undefined) !== (vector.expect === "refuse")) failures.push(`${vector.name}: expected ${vector.expect}`);
|
|
||||||
else if (decodedId !== undefined && decodedId !== id) failures.push(`${vector.name}: id not echoed raw`);
|
|
||||||
}
|
|
||||||
assert.deepEqual(failures, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every range.
|
|
||||||
test("write_admitted messageId: every range of the Unicode 16.0 tables is pinned at both ends", () => {
|
|
||||||
const ranges = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-admitted-message-id-ranges.json"), "utf8"));
|
|
||||||
assert.ok(ranges.letterOrNumber.length > 700);
|
|
||||||
assert.equal(ranges.defaultIgnorable.length, 17);
|
|
||||||
const accepted = (id: string) => { try { parseCultNetMessage(admitted(id)); return true; } catch { return false; } };
|
|
||||||
const failures: string[] = [];
|
|
||||||
for (const [low, high] of ranges.letterOrNumber as [number, number][]) {
|
|
||||||
for (const [point, want] of [[low, true], [high, true], [low - 1, false], [high + 1, false]] as [number, boolean][]) {
|
|
||||||
if (accepted(String.fromCodePoint(point)) !== want) failures.push(`U+${point.toString(16)} alone should be ${want ? "accepted" : "refused"}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const [low, high] of ranges.defaultIgnorable as [number, number][]) {
|
|
||||||
for (const [point, want] of [[low, false], [high, false], [low - 1, true], [high + 1, true]] as [number, boolean][]) {
|
|
||||||
if (accepted("a" + String.fromCodePoint(point)) !== want) failures.push(`a + U+${point.toString(16)} should be ${want ? "accepted" : "refused"}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.deepEqual(failures, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
// TypeScript decodes the frame to an object, so it cannot see a duplicate key: it judges the last one (admittedMessageIdDuplicates).
|
|
||||||
// This pins @msgpack/msgpack's last-key behaviour plus the id check on the decoded object, not a first-key or duplicate path in
|
|
||||||
// cultnet-ts source: there is none, so a refuse-on-first mutation in src would not be killed.
|
|
||||||
test("write_admitted messageId: a repeated key follows the last-key-wins policy", () => {
|
|
||||||
const vectors = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json"), "utf8"));
|
|
||||||
const section = vectors.admittedMessageIdDuplicates;
|
|
||||||
assert.equal(vectors.counts.admittedMessageIdDuplicateCases, section.cases.length);
|
|
||||||
for (const vector of section.cases as { name: string; wireHex: string; lastKeyWins: "accept" | "refuse"; lastKeyWinsEcho?: string }[]) {
|
|
||||||
const frame = decode(Uint8Array.from(Buffer.from(vector.wireHex, "hex")));
|
|
||||||
if (vector.lastKeyWins === "refuse") assert.throws(() => parseCultNetMessage(frame), /./s, vector.name);
|
|
||||||
else assert.equal((parseCultNetMessage(frame) as CultNetWriteAdmittedMessage).messageId, vector.lastKeyWinsEcho, vector.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,132 +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"));
|
|
||||||
|
|
||||||
// The event actions scenarios.semantics names; anything else is refused, not skipped.
|
|
||||||
const EVENT_KEYS = ["send", "frame", "tick", "sessionEnd", "sendFailed", "transportAck"];
|
|
||||||
|
|
||||||
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)) {
|
|
||||||
const actions = Object.keys(event).filter((key) => key !== "atMs");
|
|
||||||
if (actions.length !== 1 || !EVENT_KEYS.includes(actions[0])) { problems.push(`event at ${event.atMs} has keys [${actions}], wanted atMs and exactly one of ${EVENT_KEYS}`); continue; }
|
|
||||||
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);
|
|
||||||
else if ("sendFailed" in event) tracker.sendFailed(event.sendFailed, 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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// A runner that read fewer vectors than the file declares has silently skipped some. (contentHash is not replayed here.)
|
|
||||||
check("counts", () => {
|
|
||||||
const ran = (prefix) => results.filter((r) => r.name.startsWith(prefix)).length;
|
|
||||||
const loaded = { wire: vectors.wire.length, schemaInvalid: vectors.schemaInvalid.length, scenarioCases: vectors.scenarios.cases.length };
|
|
||||||
const executed = { wire: ran("wire/"), schemaInvalid: ran("schemaInvalid/"), scenarioCases: ran("scenario/") };
|
|
||||||
return Object.keys(executed)
|
|
||||||
.filter((key) => loaded[key] !== vectors.counts[key] || executed[key] !== vectors.counts[key])
|
|
||||||
.map((key) => `${key}: declared ${vectors.counts[key]}, loaded ${loaded[key]}, ran ${executed[key]}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate the Unicode 16.0 tables of the write_admitted messageId rule for the five CultNet runtimes.
|
|
||||||
|
|
||||||
The rule (contracts/cultnet/interop/write-receipt.md): a messageId is valid iff it holds a letter (Lu Ll Lt Lm Lo) or a number
|
|
||||||
(Nd Nl No) and no Default_Ignorable_Code_Point, all as of Unicode 16.0. The tables are embedded in each runtime so that no
|
|
||||||
platform's own Unicode version can change a result. Bumping the version is one coordinated change: run this script on the new
|
|
||||||
UCD files, commit the five regenerated files and the vectors that pin the new version, together.
|
|
||||||
|
|
||||||
gen-unicode-messageid-tables.py DerivedGeneralCategory.txt DerivedCoreProperties.txt [--check]
|
|
||||||
|
|
||||||
The inputs are https://www.unicode.org/Public/16.0.0/ucd/extracted/DerivedGeneralCategory.txt and
|
|
||||||
https://www.unicode.org/Public/16.0.0/ucd/DerivedCoreProperties.txt. With --check nothing is written; the exit status is 1 if
|
|
||||||
any generated file differs from what these inputs produce (a stale or hand-edited table). Standard library only.
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
VERSION = "16.0.0"
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
LETTER_OR_NUMBER_CATEGORIES = {"Lu", "Ll", "Lt", "Lm", "Lo", "Nd", "Nl", "No"}
|
|
||||||
|
|
||||||
TARGETS = {
|
|
||||||
"ts": ROOT / "packages/cultnet-ts/src/unicode-messageid-tables.ts",
|
|
||||||
"py": ROOT / "packages/cultnet-py/src/cultnet_py/unicode_messageid_tables.py",
|
|
||||||
"kt": ROOT / "packages/cultmesh-kotlin/src/main/kotlin/org/gamecult/cultmesh/UnicodeMessageIdTables.kt",
|
|
||||||
"cs": ROOT / "src/GameCult.Networking/CultNetUnicodeMessageIdTables.cs",
|
|
||||||
"rs": ROOT / "packages/cultnet-rs/src/unicode_messageid_tables.rs",
|
|
||||||
}
|
|
||||||
# The independent copy the runtimes' tests probe (first, last, and the code point either side of every range), so that deleting
|
|
||||||
# or narrowing any range of any embedded table fails a test.
|
|
||||||
RANGES_JSON = ROOT / "contracts/cultnet/interop/write-admitted-message-id-ranges.json"
|
|
||||||
|
|
||||||
|
|
||||||
def parse(path, wanted):
|
|
||||||
"""Ranges (low, high) of the lines `LOW[..HIGH] ; VALUE`, VALUE accepted by [wanted], merged and sorted."""
|
|
||||||
points = []
|
|
||||||
for line in Path(path).read_text(encoding="utf-8").splitlines():
|
|
||||||
m = re.match(r"([0-9A-F]+)(?:\.\.([0-9A-F]+))?\s*;\s*(\S+)", line)
|
|
||||||
if m and wanted(m.group(3)):
|
|
||||||
points.append((int(m.group(1), 16), int(m.group(2) or m.group(1), 16)))
|
|
||||||
points.sort()
|
|
||||||
merged = []
|
|
||||||
for low, high in points:
|
|
||||||
if merged and low <= merged[-1][1] + 1:
|
|
||||||
merged[-1] = (merged[-1][0], max(merged[-1][1], high))
|
|
||||||
else:
|
|
||||||
merged.append((low, high))
|
|
||||||
return merged
|
|
||||||
|
|
||||||
|
|
||||||
def flat(ranges):
|
|
||||||
return [value for pair in ranges for value in pair]
|
|
||||||
|
|
||||||
|
|
||||||
def wrapped(items, indent, render, width=118):
|
|
||||||
lines, current = [], indent
|
|
||||||
for item in items:
|
|
||||||
text = render(item) + ", "
|
|
||||||
if len(current) + len(text) > width:
|
|
||||||
lines.append(current.rstrip())
|
|
||||||
current = indent
|
|
||||||
current += text
|
|
||||||
lines.append(current.rstrip())
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def emit(target, letters, ignorable, header):
|
|
||||||
num = lambda v: f"0x{v:X}"
|
|
||||||
if target == "ts":
|
|
||||||
body = lambda name, r: f"export const {name}: readonly number[] = [\n{wrapped(flat(r), ' ', num)}\n];\n"
|
|
||||||
return f"// {header}\n\n// [low, high] pairs, sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
|
|
||||||
if target == "py":
|
|
||||||
body = lambda name, r: f"{name}: tuple[int, ...] = (\n{wrapped(flat(r), ' ', num)}\n)\n"
|
|
||||||
return f"# {header}\n\n# (low, high) pairs, flattened and sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
|
|
||||||
if target == "kt":
|
|
||||||
body = lambda name, r: f"internal val {name}: IntArray = intArrayOf(\n{wrapped(flat(r), ' ', num)}\n)\n"
|
|
||||||
return f"// {header}\n\npackage org.gamecult.cultmesh\n\n// (low, high) pairs, flattened and sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
|
|
||||||
if target == "cs":
|
|
||||||
body = lambda name, r: f" public static readonly int[] {name} =\n {{\n{wrapped(flat(r), ' ', num)}\n }};\n"
|
|
||||||
return f"// {header}\n\nnamespace GameCult.Networking\n{{\n internal static class CultNetUnicodeMessageIdTables\n {{\n // (low, high) pairs, flattened and sorted.\n{body('LetterOrNumber', letters)}\n{body('DefaultIgnorable', ignorable)} }}\n}}\n"
|
|
||||||
body = lambda name, r: f"pub const {name}: &[(u32, u32)] = &[\n{wrapped(r, ' ', lambda p: f'({num(p[0])}, {num(p[1])})')}\n];\n"
|
|
||||||
return f"// {header}\n\n// (low, high) pairs, sorted.\n{body('LETTER_OR_NUMBER', letters)}\n{body('DEFAULT_IGNORABLE', ignorable)}"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
args = [a for a in sys.argv[1:] if a != "--check"]
|
|
||||||
check = "--check" in sys.argv[1:]
|
|
||||||
if len(args) != 2:
|
|
||||||
sys.exit(__doc__)
|
|
||||||
gc, dcp = args
|
|
||||||
for path, marker in ((gc, f"DerivedGeneralCategory-{VERSION}"), (dcp, f"DerivedCoreProperties-{VERSION}")):
|
|
||||||
if marker not in Path(path).read_text(encoding="utf-8").splitlines()[0]:
|
|
||||||
sys.exit(f"{path} is not {marker}.txt")
|
|
||||||
letters = parse(gc, lambda v: v in LETTER_OR_NUMBER_CATEGORIES)
|
|
||||||
ignorable = parse(dcp, lambda v: v == "Default_Ignorable_Code_Point")
|
|
||||||
digest = hashlib.sha256(b"".join(Path(p).read_bytes() for p in (gc, dcp))).hexdigest()[:16]
|
|
||||||
header = f"generated, Unicode {VERSION}, by scripts/gen-unicode-messageid-tables.py (inputs sha256 {digest}); do not edit"
|
|
||||||
stale = []
|
|
||||||
texts = {path: emit(target, letters, ignorable, header) for target, path in TARGETS.items()}
|
|
||||||
texts[RANGES_JSON] = json.dumps(
|
|
||||||
{"generated": header, "unicodeVersion": VERSION, "letterOrNumber": [list(r) for r in letters], "defaultIgnorable": [list(r) for r in ignorable]},
|
|
||||||
separators=(",", ":"),
|
|
||||||
) + "\n"
|
|
||||||
for path, text in texts.items():
|
|
||||||
if check:
|
|
||||||
if not path.exists() or path.read_text(encoding="utf-8") != text:
|
|
||||||
stale.append(str(path.relative_to(ROOT)))
|
|
||||||
else:
|
|
||||||
path.write_text(text, encoding="utf-8")
|
|
||||||
if stale:
|
|
||||||
sys.exit("stale or hand-edited: " + ", ".join(stale))
|
|
||||||
print(f"{'checked' if check else 'wrote'} {len(texts)} files: {len(letters)} letter-or-number ranges, {len(ignorable)} default-ignorable ranges")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
@ -1017,8 +1017,6 @@ namespace GameCult.Networking
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies a raw document mutation after checking shard authority. This is the host's own door (and replication's):
|
/// 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 <see cref="CultNetDatabaseServer"/>, which does.
|
/// it asks no remote write authority. A peer's put goes through <see cref="CultNetDatabaseServer"/>, which does.
|
||||||
/// Returning normally means the put is committed and visible to readers: a receipt-capable peer is told
|
|
||||||
/// <c>write_admitted</c> on the strength of it, so a write that has only been queued must not return.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
||||||
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
||||||
|
|
@ -1049,7 +1047,6 @@ namespace GameCult.Networking
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies a raw document delete after checking shard authority. This is the host's own door: it asks no remote
|
/// 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 <see cref="CultNetDatabaseServer"/>, which does.
|
/// write authority. A peer's delete goes through <see cref="CultNetDatabaseServer"/>, which does.
|
||||||
/// Returning normally means the delete is committed and visible to readers (see <see cref="ApplyPutAsync(CultNetDocumentPutRawMessage)"/>).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
/// <exception cref="CultNetUnownedSchemaException">No shard owns the schema and key; nothing was written.</exception>
|
||||||
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
/// <exception cref="CultNetShardLogException">The change committed and was published, but the shard log could not record it.</exception>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ namespace GameCult.Networking
|
||||||
private readonly Func<CultNetSnapshotRequestV1Message, CultNetServerPeer, Task> _snapshotHandlerV1;
|
private readonly Func<CultNetSnapshotRequestV1Message, CultNetServerPeer, Task> _snapshotHandlerV1;
|
||||||
private readonly Func<CultNetDocumentPutRawMessage, CultNetServerPeer, Task> _putHandler;
|
private readonly Func<CultNetDocumentPutRawMessage, CultNetServerPeer, Task> _putHandler;
|
||||||
private readonly Func<CultNetDocumentDeleteMessage, CultNetServerPeer, Task> _deleteHandler;
|
private readonly Func<CultNetDocumentDeleteMessage, CultNetServerPeer, Task> _deleteHandler;
|
||||||
private readonly Func<CultNetHelloMessage, CultNetServerPeer, Task> _helloHandler;
|
|
||||||
private readonly Func<CultNetShardCatalogRequestMessage, CultNetServerPeer, Task> _shardCatalogHandler;
|
private readonly Func<CultNetShardCatalogRequestMessage, CultNetServerPeer, Task> _shardCatalogHandler;
|
||||||
private readonly Func<CultNetShardLogRequestMessage, CultNetServerPeer, Task> _shardLogHandler;
|
private readonly Func<CultNetShardLogRequestMessage, CultNetServerPeer, Task> _shardLogHandler;
|
||||||
private readonly Func<CultNetDatabaseSubscribeMessage, CultNetServerPeer, Task> _subscribeHandler;
|
private readonly Func<CultNetDatabaseSubscribeMessage, CultNetServerPeer, Task> _subscribeHandler;
|
||||||
|
|
@ -46,7 +45,6 @@ namespace GameCult.Networking
|
||||||
_snapshotHandlerV1 = HandleSnapshotRequestV1Async;
|
_snapshotHandlerV1 = HandleSnapshotRequestV1Async;
|
||||||
_putHandler = HandlePutAsync;
|
_putHandler = HandlePutAsync;
|
||||||
_deleteHandler = HandleDeleteAsync;
|
_deleteHandler = HandleDeleteAsync;
|
||||||
_helloHandler = HandleHelloAsync;
|
|
||||||
_shardCatalogHandler = HandleShardCatalogRequestAsync;
|
_shardCatalogHandler = HandleShardCatalogRequestAsync;
|
||||||
_shardLogHandler = HandleShardLogRequestAsync;
|
_shardLogHandler = HandleShardLogRequestAsync;
|
||||||
_subscribeHandler = HandleSubscribeAsync;
|
_subscribeHandler = HandleSubscribeAsync;
|
||||||
|
|
@ -57,7 +55,6 @@ namespace GameCult.Networking
|
||||||
_server.OnCultNet(_snapshotHandlerV1);
|
_server.OnCultNet(_snapshotHandlerV1);
|
||||||
_server.OnCultNet(_putHandler);
|
_server.OnCultNet(_putHandler);
|
||||||
_server.OnCultNet(_deleteHandler);
|
_server.OnCultNet(_deleteHandler);
|
||||||
_server.OnCultNet(_helloHandler);
|
|
||||||
_server.OnCultNet(_shardCatalogHandler);
|
_server.OnCultNet(_shardCatalogHandler);
|
||||||
_server.OnCultNet(_shardLogHandler);
|
_server.OnCultNet(_shardLogHandler);
|
||||||
_server.OnCultNet(_subscribeHandler);
|
_server.OnCultNet(_subscribeHandler);
|
||||||
|
|
@ -220,7 +217,6 @@ namespace GameCult.Networking
|
||||||
_server.RemoveCultNetMessageListener<CultNetSnapshotRequestV1Message>(_snapshotHandlerV1);
|
_server.RemoveCultNetMessageListener<CultNetSnapshotRequestV1Message>(_snapshotHandlerV1);
|
||||||
_server.RemoveCultNetMessageListener<CultNetDocumentPutRawMessage>(_putHandler);
|
_server.RemoveCultNetMessageListener<CultNetDocumentPutRawMessage>(_putHandler);
|
||||||
_server.RemoveCultNetMessageListener<CultNetDocumentDeleteMessage>(_deleteHandler);
|
_server.RemoveCultNetMessageListener<CultNetDocumentDeleteMessage>(_deleteHandler);
|
||||||
_server.RemoveCultNetMessageListener<CultNetHelloMessage>(_helloHandler);
|
|
||||||
_server.RemoveCultNetMessageListener<CultNetShardCatalogRequestMessage>(_shardCatalogHandler);
|
_server.RemoveCultNetMessageListener<CultNetShardCatalogRequestMessage>(_shardCatalogHandler);
|
||||||
_server.RemoveCultNetMessageListener<CultNetShardLogRequestMessage>(_shardLogHandler);
|
_server.RemoveCultNetMessageListener<CultNetShardLogRequestMessage>(_shardLogHandler);
|
||||||
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribeHandler);
|
_server.RemoveCultNetMessageListener<CultNetDatabaseSubscribeMessage>(_subscribeHandler);
|
||||||
|
|
@ -266,7 +262,7 @@ namespace GameCult.Networking
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_server.Logger.LogError($"CultNet snapshot v1 request failed: {ex.Message}");
|
_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;
|
return Task.CompletedTask;
|
||||||
|
|
@ -286,8 +282,7 @@ namespace GameCult.Networking
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_server.Logger.LogError($"CultNet shard log request failed: {ex.Message}");
|
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
|
||||||
peer.SendCultNet(new CultNetErrorMessage { Error = "the shard log request could not be answered" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
@ -328,273 +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 write that names nothing is 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: a blank schemaId, recordKey or storedAt, an empty
|
|
||||||
// payload, and a source id or tag that is present but blank.
|
|
||||||
private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) =>
|
|
||||||
IsBlank(schemaId) || IsBlank(recordKey);
|
|
||||||
|
|
||||||
private static bool IsUnreadableDocument(CultNetRawDocumentRecord document) =>
|
|
||||||
IsUnreadableWriteTarget(document.SchemaId, document.RecordKey)
|
|
||||||
|| IsBlank(document.StoredAt)
|
|
||||||
|| document.Payload == null || document.Payload.Length == 0
|
|
||||||
|| IsPresentButBlank(document.SourceRuntimeId)
|
|
||||||
|| IsPresentButBlank(document.SourceAgentId)
|
|
||||||
|| IsPresentButBlank(document.SourceRole)
|
|
||||||
|| document.Tags != null && document.Tags.Any(IsBlank);
|
|
||||||
|
|
||||||
private static bool IsPresentButBlank(string? value) => value != null && IsBlank(value);
|
|
||||||
|
|
||||||
// Blank is the Unicode White_Space property, listed here and in write-receipt.md, not char.IsWhiteSpace: the platform's
|
|
||||||
// own test follows its Unicode tables (and has put U+180E in the set), the spec's does not move. A null is not text.
|
|
||||||
private static bool IsBlank(string? value) => value == null || value.All(IsWhiteSpaceCodePoint);
|
|
||||||
|
|
||||||
private static bool IsWhiteSpaceCodePoint(char c) =>
|
|
||||||
c is (>= '\u0009' and <= '\u000D') or '\u0020' or '\u0085' or '\u00A0' or '\u1680'
|
|
||||||
or (>= '\u2000' and <= '\u200A') or '\u2028' or '\u2029' or '\u202F' or '\u205F' or '\u3000';
|
|
||||||
|
|
||||||
// A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other.
|
|
||||||
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)
|
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 (IsUnreadableDocument(message.Document!))
|
|
||||||
{
|
|
||||||
Refuse(peer, write, CultNetErrorMessage.ForUnreadableMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (write != null && !write.Begin()) return;
|
|
||||||
|
|
||||||
var authorize = Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _database.ApplyPutAsync(message, authorize).ConfigureAwait(false);
|
await _database.ApplyPutAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch))
|
||||||
write?.Admitted();
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (CultNetRemoteWriteRefusedException ex)
|
catch (CultNetRemoteWriteRefusedException ex)
|
||||||
{
|
{
|
||||||
_server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}");
|
_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)
|
catch (CultNetShardAuthorityException ex)
|
||||||
{
|
{
|
||||||
bool forwarded;
|
if (!await TryForwardPutAsync(ex, message).ConfigureAwait(false))
|
||||||
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
|
|
||||||
{
|
{
|
||||||
_server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}");
|
_server.Logger.LogError($"CultNet raw put authority failed: {ex.Message}");
|
||||||
Refuse(peer, write, CreateRoutingError(ex));
|
peer.SendCultNet(CreateRoutingError(ex));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (CultNetUnownedSchemaException ex)
|
catch (CultNetUnownedSchemaException ex)
|
||||||
{
|
{
|
||||||
_server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}");
|
_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))
|
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.
|
// 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}");
|
_server.Logger.LogWarning($"CultNet raw put committed but its shard log failed: {ex.Message}");
|
||||||
write?.Admitted();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_server.Logger.LogError($"CultNet raw put failed: {ex.Message}");
|
_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)
|
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, CultNetErrorMessage.ForUnreadableMessage());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (write != null && !write.Begin()) return;
|
|
||||||
|
|
||||||
var authorize = Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch);
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _database.ApplyDeleteAsync(message, authorize).ConfigureAwait(false);
|
await _database.ApplyDeleteAsync(message, Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch))
|
||||||
write?.Admitted();
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (CultNetRemoteWriteRefusedException ex)
|
catch (CultNetRemoteWriteRefusedException ex)
|
||||||
{
|
{
|
||||||
_server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}");
|
_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)
|
catch (CultNetShardAuthorityException ex)
|
||||||
{
|
{
|
||||||
bool forwarded;
|
if (!await TryForwardDeleteAsync(ex, message).ConfigureAwait(false))
|
||||||
try
|
|
||||||
{
|
{
|
||||||
forwarded = await TryForwardDeleteAsync(ex, message).ConfigureAwait(false);
|
peer.SendCultNet(CreateRoutingError(ex));
|
||||||
}
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (CultNetUnownedSchemaException ex)
|
catch (CultNetUnownedSchemaException ex)
|
||||||
{
|
{
|
||||||
_server.Logger.LogWarning($"CultNet raw delete refused: {ex.Message}");
|
_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))
|
catch (Exception ex) when (CommittedWithLogFailure(ex))
|
||||||
{
|
{
|
||||||
_server.Logger.LogWarning($"CultNet raw delete committed but its shard log failed: {ex.Message}");
|
_server.Logger.LogWarning($"CultNet raw delete committed but its shard log failed: {ex.Message}");
|
||||||
write?.Admitted();
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Refuse(peer, write, new CultNetErrorMessage { Error = CultNetWriteReceiptSession.WriteFailedText });
|
peer.SendCultNet(new CultNetErrorMessage { Error = ex.Message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -792,7 +588,7 @@ namespace GameCult.Networking
|
||||||
var refusedKey = change.GetType().GetProperty("Key")?.GetValue(change) is CultRecordKey changed ? changed.Value : string.Empty;
|
var refusedKey = change.GetType().GetProperty("Key")?.GetValue(change) is CultRecordKey changed ? changed.Value : string.Empty;
|
||||||
lock (refused)
|
lock (refused)
|
||||||
refused.Add(refusedKey);
|
refused.Add(refusedKey);
|
||||||
sendError(new CultNetErrorMessage { Error = "the record is a variant CultNet cannot carry" });
|
sendError(new CultNetErrorMessage { Error = refusal.Message });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -870,14 +666,11 @@ namespace GameCult.Networking
|
||||||
exception is CultNetShardLogException ||
|
exception is CultNetShardLogException ||
|
||||||
exception is AggregateException aggregate && aggregate.Flatten().InnerExceptions.Any(inner => inner 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)
|
private static CultNetErrorMessage CreateRoutingError(CultNetShardAuthorityException exception)
|
||||||
{
|
{
|
||||||
return new CultNetErrorMessage
|
return new CultNetErrorMessage
|
||||||
{
|
{
|
||||||
Error = RoutingRefusalText,
|
Error = exception.Message,
|
||||||
RoutingHint = new CultNetShardRoutingHint
|
RoutingHint = new CultNetShardRoutingHint
|
||||||
{
|
{
|
||||||
Reason = exception.Reason,
|
Reason = exception.Reason,
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,7 @@ namespace GameCult.Networking
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends one schema-v0 message through the transport. Fire-and-forget: an ack or a return is not admission;
|
/// Sends one schema-v0 message through the transport. Fire-and-forget: an ack or a return is not admission;
|
||||||
/// read the result frame (<see cref="CultNetWriteCompletion"/>). Throws <see cref="CultNetWriteNotSentException"/> only
|
/// read the result frame (<see cref="CultNetWriteCompletion"/>).
|
||||||
/// when it can prove no byte was written; any other exception leaves the send ambiguous and a forwarded write unconfirmed.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
void SendCultNet<T>(T message)
|
void SendCultNet<T>(T message)
|
||||||
where T : ICultNetSchemaMessage;
|
where T : ICultNetSchemaMessage;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Buffers;
|
|
||||||
using MessagePack;
|
using MessagePack;
|
||||||
|
|
||||||
namespace GameCult.Networking
|
namespace GameCult.Networking
|
||||||
|
|
@ -30,7 +29,6 @@ namespace GameCult.Networking
|
||||||
public static T Deserialize<T>(byte[] payload) where T : ICultNetSchemaMessage
|
public static T Deserialize<T>(byte[] payload) where T : ICultNetSchemaMessage
|
||||||
{
|
{
|
||||||
if (payload == null) throw new ArgumentNullException(nameof(payload));
|
if (payload == null) throw new ArgumentNullException(nameof(payload));
|
||||||
if (typeof(T) == typeof(CultNetWriteAdmittedMessage)) CultNetAdmittedMessageId.Require(payload);
|
|
||||||
return MessagePackSerializer.Deserialize<T>(payload, Options);
|
return MessagePackSerializer.Deserialize<T>(payload, Options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,8 +53,6 @@ namespace GameCult.Networking
|
||||||
CultNetSchemaVersions.Verify => Deserialize<CultNetVerifyMessage>(payload),
|
CultNetSchemaVersions.Verify => Deserialize<CultNetVerifyMessage>(payload),
|
||||||
CultNetSchemaVersions.LoginSuccess => Deserialize<CultNetLoginSuccessMessage>(payload),
|
CultNetSchemaVersions.LoginSuccess => Deserialize<CultNetLoginSuccessMessage>(payload),
|
||||||
CultNetSchemaVersions.Error => Deserialize<CultNetErrorMessage>(payload),
|
CultNetSchemaVersions.Error => Deserialize<CultNetErrorMessage>(payload),
|
||||||
CultNetSchemaVersions.ErrorV1 => Deserialize<CultNetErrorV1Message>(payload),
|
|
||||||
CultNetSchemaVersions.WriteAdmitted => Deserialize<CultNetWriteAdmittedMessage>(payload),
|
|
||||||
CultNetSchemaVersions.SampleChangeName => Deserialize<CultNetSampleChangeNameMessage>(payload),
|
CultNetSchemaVersions.SampleChangeName => Deserialize<CultNetSampleChangeNameMessage>(payload),
|
||||||
CultNetSchemaVersions.SampleChat => Deserialize<CultNetSampleChatMessage>(payload),
|
CultNetSchemaVersions.SampleChat => Deserialize<CultNetSampleChatMessage>(payload),
|
||||||
CultNetSchemaVersions.OperationRequest => Deserialize<CultNetOperationRequestMessage>(payload),
|
CultNetSchemaVersions.OperationRequest => Deserialize<CultNetOperationRequestMessage>(payload),
|
||||||
|
|
@ -100,67 +96,4 @@ namespace GameCult.Networking
|
||||||
[Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty;
|
[Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The write_admitted messageId rule (write-receipt.md): a letter (Lu Ll Lt Lm Lo) or number (Nd Nl No) somewhere, no
|
|
||||||
/// Default_Ignorable code point anywhere, as of Unicode 16.0, wire bytes that are valid UTF-8, compared raw (never trimmed).
|
|
||||||
/// The tables are embedded (CultNetUnicodeMessageIdTables.cs, generated by scripts/gen-unicode-messageid-tables.py), not
|
|
||||||
/// .NET's, so the running runtime's Unicode version changes no result; the other four runtimes embed the same tables.
|
|
||||||
/// </summary>
|
|
||||||
internal static class CultNetAdmittedMessageId
|
|
||||||
{
|
|
||||||
private static readonly System.Text.UTF8Encoding StrictUtf8 = new(false, true);
|
|
||||||
|
|
||||||
/// <summary>Throws unless the frame's one top-level <c>messageId</c> key is a valid write_admitted id (a repeated key is malformed); other keys are left to the serializer.</summary>
|
|
||||||
public static void Require(byte[] payload)
|
|
||||||
{
|
|
||||||
var reader = new MessagePackReader(payload);
|
|
||||||
var entries = reader.ReadMapHeader();
|
|
||||||
var seen = false;
|
|
||||||
for (var index = 0; index < entries; index++)
|
|
||||||
{
|
|
||||||
var key = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null;
|
|
||||||
if (key == "messageId" && reader.NextMessagePackType == MessagePackType.String)
|
|
||||||
{
|
|
||||||
if (seen) throw new MessagePackSerializationException("cultnet.write_admitted.v0 carries messageId more than once.");
|
|
||||||
seen = true;
|
|
||||||
string id;
|
|
||||||
try { id = StrictUtf8.GetString(reader.ReadStringSequence()!.Value.ToArray()); }
|
|
||||||
catch (System.Text.DecoderFallbackException) { throw new MessagePackSerializationException("cultnet.write_admitted.v0 messageId is not valid UTF-8."); }
|
|
||||||
if (!IsValid(id)) throw new MessagePackSerializationException("cultnet.write_admitted.v0 messageId must contain a letter or number and no Default_Ignorable code point.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key == null) reader.Skip();
|
|
||||||
reader.Skip();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool IsValid(string id)
|
|
||||||
{
|
|
||||||
var hasLetterOrNumber = false;
|
|
||||||
for (var at = 0; at < id.Length; at += char.IsSurrogatePair(id, at) ? 2 : 1)
|
|
||||||
{
|
|
||||||
var point = char.IsSurrogatePair(id, at) ? char.ConvertToUtf32(id, at) : id[at];
|
|
||||||
if (InRanges(CultNetUnicodeMessageIdTables.DefaultIgnorable, point)) return false;
|
|
||||||
hasLetterOrNumber |= InRanges(CultNetUnicodeMessageIdTables.LetterOrNumber, point);
|
|
||||||
}
|
|
||||||
|
|
||||||
return hasLetterOrNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool InRanges(int[] ranges, int point)
|
|
||||||
{
|
|
||||||
int low = 0, high = ranges.Length / 2 - 1;
|
|
||||||
while (low <= high)
|
|
||||||
{
|
|
||||||
var mid = (low + high) >> 1;
|
|
||||||
if (point < ranges[2 * mid]) high = mid - 1;
|
|
||||||
else if (point > ranges[2 * mid + 1]) low = mid + 1;
|
|
||||||
else return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,6 @@ namespace GameCult.Networking
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Error = "cultnet.error.v0";
|
public const string Error = "cultnet.error.v0";
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// error v1 contract identifier: error v0 plus the id of the request it answers (write-receipt.md).
|
|
||||||
/// </summary>
|
|
||||||
public const string ErrorV1 = "cultnet.error.v1";
|
|
||||||
/// <summary>
|
|
||||||
/// write admitted receipt contract identifier (write-receipt.md).
|
|
||||||
/// </summary>
|
|
||||||
public const string WriteAdmitted = "cultnet.write_admitted.v0";
|
|
||||||
/// <summary>
|
|
||||||
/// sample change name contract identifier.
|
/// sample change name contract identifier.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string SampleChangeName = "cultnet.sample.change_name.v0";
|
public const string SampleChangeName = "cultnet.sample.change_name.v0";
|
||||||
|
|
@ -546,7 +538,7 @@ namespace GameCult.Networking
|
||||||
public static CultNetErrorMessage ForUnownedSchema(CultNetUnownedSchemaException ex) =>
|
public static CultNetErrorMessage ForUnownedSchema(CultNetUnownedSchemaException ex) =>
|
||||||
new CultNetErrorMessage
|
new CultNetErrorMessage
|
||||||
{
|
{
|
||||||
Error = "unowned_schema: no shard owns the schema and key",
|
Error = $"unowned_schema: {ex.Message}",
|
||||||
Code = "unowned_schema",
|
Code = "unowned_schema",
|
||||||
Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId }
|
Details = new CultNetErrorDetails { Field = "schemaId", Value = ex.SchemaId }
|
||||||
};
|
};
|
||||||
|
|
@ -587,13 +579,6 @@ namespace GameCult.Networking
|
||||||
Details = new CultNetErrorDetails { Field = field }
|
Details = new CultNetErrorDetails { Field = field }
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The refusal of a frame that does not read, or whose write names nothing (write-receipt.md, Fixed texts):
|
|
||||||
/// <c>message_invalid</c>, details <c>{ field: "message" }</c>, and the one fixed text. A decoder's own text never reaches the wire.
|
|
||||||
/// </summary>
|
|
||||||
public static CultNetErrorMessage ForUnreadableMessage() =>
|
|
||||||
ForMessageInvalid("message", "the message could not be read");
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the wire error for a cursor refusal (R-N): code <c>cursor_stale</c> with details
|
/// Builds the wire error for a cursor refusal (R-N): code <c>cursor_stale</c> with details
|
||||||
/// <c>{ asOf, current }</c>, or code <c>cursor_invalid</c> with no details.
|
/// <c>{ asOf, current }</c>, or code <c>cursor_invalid</c> with no details.
|
||||||
|
|
@ -636,80 +621,6 @@ namespace GameCult.Networking
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <c>cultnet.error.v1</c> (contracts/cultnet/interop/write-receipt.md): the v0 error plus <see cref="MessageId"/>,
|
|
||||||
/// the id of the request it answers. Sent only to a connection that declared the write-receipt capability.
|
|
||||||
/// </summary>
|
|
||||||
[MessagePackObject]
|
|
||||||
public class CultNetErrorV1Message : ICultNetSchemaMessage
|
|
||||||
{
|
|
||||||
/// <summary>Gets or sets the schema version.</summary>
|
|
||||||
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.ErrorV1;
|
|
||||||
/// <summary>Gets or sets the error.</summary>
|
|
||||||
[Key("error")] public string Error { get; set; } = string.Empty;
|
|
||||||
/// <summary>Gets or sets optional shard routing details for authority failures.</summary>
|
|
||||||
[Key("routingHint")] public CultNetShardRoutingHint? RoutingHint { get; set; }
|
|
||||||
/// <summary>Gets or sets the machine-readable refusal code (the v0 enum).</summary>
|
|
||||||
[Key("code")] public string? Code { get; set; }
|
|
||||||
/// <summary>Gets or sets the code-specific structured detail.</summary>
|
|
||||||
[Key("details")] public CultNetErrorDetails? Details { get; set; }
|
|
||||||
/// <summary>Gets or sets the id of the request this answers; null only when no id could be read.</summary>
|
|
||||||
[Key("messageId")] public string? MessageId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Builds the v1 form of <paramref name="error"/> answering <paramref name="messageId"/>.</summary>
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The refusal of a write a session has no room to remember (write-receipt.md, rule 5): code <c>limit_reached</c>,
|
|
||||||
/// details present and all nil. Exists only as error.v1; v0 has no such code.
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Gets the v0 form, dropping the id.</summary>
|
|
||||||
public CultNetErrorMessage ToV0() => new CultNetErrorMessage
|
|
||||||
{
|
|
||||||
Error = Error,
|
|
||||||
RoutingHint = RoutingHint,
|
|
||||||
Code = Code,
|
|
||||||
Details = Details
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// <c>cultnet.write_admitted.v0</c>: the receipt for one admitted put or delete, sent after its commit point.
|
|
||||||
/// </summary>
|
|
||||||
[MessagePackObject]
|
|
||||||
public class CultNetWriteAdmittedMessage : ICultNetSchemaMessage
|
|
||||||
{
|
|
||||||
/// <summary>Gets or sets the schema version.</summary>
|
|
||||||
[Key("schemaVersion")] public string SchemaVersion { get; set; } = CultNetSchemaVersions.WriteAdmitted;
|
|
||||||
/// <summary>Gets or sets the messageId of the admitted write, echoed.</summary>
|
|
||||||
[Key("messageId")] public string MessageId { get; set; } = string.Empty;
|
|
||||||
/// <summary>Gets or sets <c>put</c> or <c>delete</c>.</summary>
|
|
||||||
[Key("operation")] public string Operation { get; set; } = string.Empty;
|
|
||||||
/// <summary>Gets or sets the schemaId of the admitted write, echoed.</summary>
|
|
||||||
[Key("schemaId")] public string SchemaId { get; set; } = string.Empty;
|
|
||||||
/// <summary>Gets or sets the recordKey of the admitted write, echoed.</summary>
|
|
||||||
[Key("recordKey")] public string RecordKey { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Code-specific structured detail carried on <see cref="CultNetErrorMessage"/> (R-N). Fields are
|
/// Code-specific structured detail carried on <see cref="CultNetErrorMessage"/> (R-N). Fields are
|
||||||
/// populated only for the code that defines them; every field is otherwise omitted (nil on the wire).
|
/// populated only for the code that defines them; every field is otherwise omitted (nil on the wire).
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,12 @@ namespace GameCult.Networking
|
||||||
public interface ICultNetShardWriteForwarder
|
public interface ICultNetShardWriteForwarder
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Forwards a raw document put to the shard owner. Completes only when the owner answered that it admitted the write;
|
/// Forwards a raw document put to the shard owner.
|
||||||
/// throws <see cref="CultNetWriteRefusedException"/> when it refused, <see cref="CultNetWriteNotSentException"/> 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 <see cref="CultNetWriteUnconfirmedException"/>). The server relays to the original peer exactly what the owner answered.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message);
|
Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Forwards a raw document delete to the shard owner; completes, refuses and goes unconfirmed as
|
/// Forwards a raw document delete to the shard owner.
|
||||||
/// <see cref="ForwardPutAsync"/> does.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message);
|
Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message);
|
||||||
}
|
}
|
||||||
|
|
@ -74,12 +70,10 @@ namespace GameCult.Networking
|
||||||
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
|
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets how long the primary has to answer a forwarded write (from the send). Its receipt completes the forward,
|
/// Gets or sets how long after a forwarded write the primary's refusal may still arrive; a refusal fails the
|
||||||
/// its refusal fails it with <see cref="CultNetWriteRefusedException"/>, and silence fails it with
|
/// forward with <see cref="CultNetWriteRefusedException"/>. Defaults to <see cref="CultNetWriteCompletion.DefaultResultWindow"/>.
|
||||||
/// <see cref="CultNetWriteUnconfirmedException"/>. Defaults to <see cref="CultNetWriteCompletion.ReceiptResultWindow"/>; a
|
|
||||||
/// primary that predates write receipts never answers, so forwarding to one is unconfirmed after this window.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.ReceiptResultWindow;
|
public TimeSpan ResultWindow { get; set; } = CultNetWriteCompletion.DefaultResultWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -98,29 +92,29 @@ namespace GameCult.Networking
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Forwards a put under its original message id and completes on the primary's result frame: a receipt returns, a refusal
|
/// Forwards a put and completes on the primary's result frame: a refusal throws
|
||||||
/// throws <see cref="CultNetWriteRefusedException"/> and silence throws <see cref="CultNetWriteUnconfirmedException"/>.
|
/// <see cref="CultNetWriteRefusedException"/>, the same failure path as an unreachable primary.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message)
|
public async Task ForwardPutAsync(CultNetShardDescriptor shard, CultNetDocumentPutRawMessage message)
|
||||||
{
|
{
|
||||||
if (message == null) throw new ArgumentNullException(nameof(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.ShardId ??= shard.ShardId;
|
||||||
message.ShardEpoch ??= shard.Epoch;
|
message.ShardEpoch ??= shard.Epoch;
|
||||||
await SendAsync(shard, message, message.MessageId).ConfigureAwait(false);
|
await SendAsync(endpoint, message).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Forwards a delete under its original message id; completes, refuses and goes unconfirmed as
|
/// Forwards a delete and completes on the primary's result frame: a refusal throws
|
||||||
/// <see cref="ForwardPutAsync"/> does.
|
/// <see cref="CultNetWriteRefusedException"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message)
|
public async Task ForwardDeleteAsync(CultNetShardDescriptor shard, CultNetDocumentDeleteMessage message)
|
||||||
{
|
{
|
||||||
if (message == null) throw new ArgumentNullException(nameof(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.ShardId ??= shard.ShardId;
|
||||||
message.ShardEpoch ??= shard.Epoch;
|
message.ShardEpoch ??= shard.Epoch;
|
||||||
await SendAsync(shard, message, message.MessageId).ConfigureAwait(false);
|
await SendAsync(endpoint, message).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -144,50 +138,19 @@ namespace GameCult.Networking
|
||||||
return (uri.Host, uri.IsDefaultPort ? 3075 : uri.Port);
|
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,
|
private async Task SendAsync<T>(string endpoint, T message) where T : ICultNetSchemaMessage
|
||||||
// the one failure the caller may retry under the same id.
|
|
||||||
private async Task<ICultNetSchemaClient> ConnectBeforeSendAsync(CultNetShardDescriptor shard, string messageId)
|
|
||||||
{
|
{
|
||||||
ICultNetSchemaClient? client = null;
|
var (host, port) = ParseEndpoint(endpoint);
|
||||||
try
|
using var client = CreateClient(endpoint);
|
||||||
{
|
client.Connect(host, port);
|
||||||
var endpoint = ResolvePrimaryEndpoint(shard);
|
await WaitForConnectionAsync(client, endpoint).ConfigureAwait(false);
|
||||||
var (host, port) = ParseEndpoint(endpoint);
|
await CultNetWriteCompletion.SendAndAwaitResultAsync(
|
||||||
client = CreateClient(endpoint);
|
refuse =>
|
||||||
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<T>(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(refuse);
|
||||||
client.OnCultNet(refuseV1);
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
() => client.SendCultNet(message),
|
() => client.SendCultNet(message),
|
||||||
messageId,
|
|
||||||
_options.ResultWindow).ConfigureAwait(false);
|
_options.ResultWindow).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3533,8 +3533,7 @@ namespace GameCult.Networking
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LiteNetLibTransportConnection
|
public sealed class LiteNetLibTransportConnection
|
||||||
{
|
{
|
||||||
private readonly Action<byte[]>? _deliver;
|
private readonly NetPeer? _peer;
|
||||||
private readonly Action? _end;
|
|
||||||
private readonly CultNetTransportStats _stats = new CultNetTransportStats();
|
private readonly CultNetTransportStats _stats = new CultNetTransportStats();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -3542,99 +3541,10 @@ namespace GameCult.Networking
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public LiteNetLibTransportConnection(NetPeer peer, CultNetTransportProfile? profile = null)
|
public LiteNetLibTransportConnection(NetPeer peer, CultNetTransportProfile? profile = null)
|
||||||
{
|
{
|
||||||
if (peer == null) throw new ArgumentNullException(nameof(peer));
|
_peer = peer ?? throw new ArgumentNullException(nameof(peer));
|
||||||
_deliver = payload => peer.Send(payload, DeliveryMethod.ReliableOrdered);
|
|
||||||
_end = peer.Disconnect;
|
|
||||||
Profile = profile ?? CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-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<byte[]> deliver, Action? end = null)
|
|
||||||
{
|
|
||||||
_deliver = deliver ?? throw new ArgumentNullException(nameof(deliver));
|
|
||||||
_end = end;
|
|
||||||
Profile = CultNetTransportProfiles.CreateLiteNetLib("csharp-litenetlib-peer");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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 (<c>session_ended</c>), never as silence.
|
|
||||||
/// </summary>
|
|
||||||
internal void End() => _end?.Invoke();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Answers a schema frame that did not decode with a <c>message_invalid</c> error.v1 naming its id when it holds one;
|
|
||||||
/// when that answer cannot be sent the connection ends instead.
|
|
||||||
/// </summary>
|
|
||||||
internal void AnswerUnreadableSchemaFrame(byte[] payload)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
SendSchema(CultNetErrorV1Message.From(
|
|
||||||
CultNetErrorMessage.ForUnreadableMessage(),
|
|
||||||
CultNetReadableMessageId.From(payload)));
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
|
||||||
End();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A put or delete that arrived on an unreliable or sequenced channel of a receipt session (write-receipt.md, Reliable channel only):
|
|
||||||
/// answered <c>message_invalid</c>, details <c>channel</c>, fixed text, carrying the write's id, and not applied. True when
|
|
||||||
/// the frame was such a write and has been answered here.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The write-receipt state of a connection that opted in (write-receipt.md); null for every other connection, which is
|
|
||||||
/// answered exactly as before.
|
|
||||||
/// </summary>
|
|
||||||
internal CultNetWriteReceiptSession? WriteReceipts { get; private set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification.
|
/// Initializes a receive-only channel-aware adapter for LiteNetLib payload classification.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -3670,14 +3580,14 @@ namespace GameCult.Networking
|
||||||
public void Send(string channelId, byte[] payload)
|
public void Send(string channelId, byte[] payload)
|
||||||
{
|
{
|
||||||
if (payload == null) throw new ArgumentNullException(nameof(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)
|
if (!string.Equals(channelId, "schema", StringComparison.Ordinal)
|
||||||
&& !string.Equals(channelId, "legacy", StringComparison.Ordinal))
|
&& !string.Equals(channelId, "legacy", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException($"litenetlib transport only supports schema and legacy channels, got \"{channelId}\".");
|
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.BytesSent += payload.Length;
|
||||||
_stats.FramesSent++;
|
_stats.FramesSent++;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
// generated, Unicode 16.0.0, by scripts/gen-unicode-messageid-tables.py (inputs sha256 24a07ef9fabf71e3); do not edit
|
|
||||||
|
|
||||||
namespace GameCult.Networking
|
|
||||||
{
|
|
||||||
internal static class CultNetUnicodeMessageIdTables
|
|
||||||
{
|
|
||||||
// (low, high) pairs, flattened and sorted.
|
|
||||||
public static readonly int[] LetterOrNumber =
|
|
||||||
{
|
|
||||||
0x30, 0x39, 0x41, 0x5A, 0x61, 0x7A, 0xAA, 0xAA, 0xB2, 0xB3, 0xB5, 0xB5, 0xB9, 0xBA, 0xBC, 0xBE, 0xC0,
|
|
||||||
0xD6, 0xD8, 0xF6, 0xF8, 0x2C1, 0x2C6, 0x2D1, 0x2E0, 0x2E4, 0x2EC, 0x2EC, 0x2EE, 0x2EE, 0x370, 0x374,
|
|
||||||
0x376, 0x377, 0x37A, 0x37D, 0x37F, 0x37F, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3,
|
|
||||||
0x3F5, 0x3F7, 0x481, 0x48A, 0x52F, 0x531, 0x556, 0x559, 0x559, 0x560, 0x588, 0x5D0, 0x5EA, 0x5EF, 0x5F2,
|
|
||||||
0x620, 0x64A, 0x660, 0x669, 0x66E, 0x66F, 0x671, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x6EE, 0x6FC, 0x6FF,
|
|
||||||
0x6FF, 0x710, 0x710, 0x712, 0x72F, 0x74D, 0x7A5, 0x7B1, 0x7B1, 0x7C0, 0x7EA, 0x7F4, 0x7F5, 0x7FA, 0x7FA,
|
|
||||||
0x800, 0x815, 0x81A, 0x81A, 0x824, 0x824, 0x828, 0x828, 0x840, 0x858, 0x860, 0x86A, 0x870, 0x887, 0x889,
|
|
||||||
0x88E, 0x8A0, 0x8C9, 0x904, 0x939, 0x93D, 0x93D, 0x950, 0x950, 0x958, 0x961, 0x966, 0x96F, 0x971, 0x980,
|
|
||||||
0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9BD, 0x9BD, 0x9CE,
|
|
||||||
0x9CE, 0x9DC, 0x9DD, 0x9DF, 0x9E1, 0x9E6, 0x9F1, 0x9F4, 0x9F9, 0x9FC, 0x9FC, 0xA05, 0xA0A, 0xA0F, 0xA10,
|
|
||||||
0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA66,
|
|
||||||
0xA6F, 0xA72, 0xA74, 0xA85, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3, 0xAB5, 0xAB9,
|
|
||||||
0xABD, 0xABD, 0xAD0, 0xAD0, 0xAE0, 0xAE1, 0xAE6, 0xAEF, 0xAF9, 0xAF9, 0xB05, 0xB0C, 0xB0F, 0xB10, 0xB13,
|
|
||||||
0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB35, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66, 0xB6F,
|
|
||||||
0xB71, 0xB77, 0xB83, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E,
|
|
||||||
0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB9, 0xBD0, 0xBD0, 0xBE6, 0xBF2, 0xC05, 0xC0C, 0xC0E, 0xC10,
|
|
||||||
0xC12, 0xC28, 0xC2A, 0xC39, 0xC3D, 0xC3D, 0xC58, 0xC5A, 0xC5D, 0xC5D, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC78,
|
|
||||||
0xC7E, 0xC80, 0xC80, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBD, 0xCBD,
|
|
||||||
0xCDD, 0xCDE, 0xCE0, 0xCE1, 0xCE6, 0xCEF, 0xCF1, 0xCF2, 0xD04, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD3A, 0xD3D,
|
|
||||||
0xD3D, 0xD4E, 0xD4E, 0xD54, 0xD56, 0xD58, 0xD61, 0xD66, 0xD78, 0xD7A, 0xD7F, 0xD85, 0xD96, 0xD9A, 0xDB1,
|
|
||||||
0xDB3, 0xDBB, 0xDBD, 0xDBD, 0xDC0, 0xDC6, 0xDE6, 0xDEF, 0xE01, 0xE30, 0xE32, 0xE33, 0xE40, 0xE46, 0xE50,
|
|
||||||
0xE59, 0xE81, 0xE82, 0xE84, 0xE84, 0xE86, 0xE8A, 0xE8C, 0xEA3, 0xEA5, 0xEA5, 0xEA7, 0xEB0, 0xEB2, 0xEB3,
|
|
||||||
0xEBD, 0xEBD, 0xEC0, 0xEC4, 0xEC6, 0xEC6, 0xED0, 0xED9, 0xEDC, 0xEDF, 0xF00, 0xF00, 0xF20, 0xF33, 0xF40,
|
|
||||||
0xF47, 0xF49, 0xF6C, 0xF88, 0xF8C, 0x1000, 0x102A, 0x103F, 0x1049, 0x1050, 0x1055, 0x105A, 0x105D,
|
|
||||||
0x1061, 0x1061, 0x1065, 0x1066, 0x106E, 0x1070, 0x1075, 0x1081, 0x108E, 0x108E, 0x1090, 0x1099, 0x10A0,
|
|
||||||
0x10C5, 0x10C7, 0x10C7, 0x10CD, 0x10CD, 0x10D0, 0x10FA, 0x10FC, 0x1248, 0x124A, 0x124D, 0x1250, 0x1256,
|
|
||||||
0x1258, 0x1258, 0x125A, 0x125D, 0x1260, 0x1288, 0x128A, 0x128D, 0x1290, 0x12B0, 0x12B2, 0x12B5, 0x12B8,
|
|
||||||
0x12BE, 0x12C0, 0x12C0, 0x12C2, 0x12C5, 0x12C8, 0x12D6, 0x12D8, 0x1310, 0x1312, 0x1315, 0x1318, 0x135A,
|
|
||||||
0x1369, 0x137C, 0x1380, 0x138F, 0x13A0, 0x13F5, 0x13F8, 0x13FD, 0x1401, 0x166C, 0x166F, 0x167F, 0x1681,
|
|
||||||
0x169A, 0x16A0, 0x16EA, 0x16EE, 0x16F8, 0x1700, 0x1711, 0x171F, 0x1731, 0x1740, 0x1751, 0x1760, 0x176C,
|
|
||||||
0x176E, 0x1770, 0x1780, 0x17B3, 0x17D7, 0x17D7, 0x17DC, 0x17DC, 0x17E0, 0x17E9, 0x17F0, 0x17F9, 0x1810,
|
|
||||||
0x1819, 0x1820, 0x1878, 0x1880, 0x1884, 0x1887, 0x18A8, 0x18AA, 0x18AA, 0x18B0, 0x18F5, 0x1900, 0x191E,
|
|
||||||
0x1946, 0x196D, 0x1970, 0x1974, 0x1980, 0x19AB, 0x19B0, 0x19C9, 0x19D0, 0x19DA, 0x1A00, 0x1A16, 0x1A20,
|
|
||||||
0x1A54, 0x1A80, 0x1A89, 0x1A90, 0x1A99, 0x1AA7, 0x1AA7, 0x1B05, 0x1B33, 0x1B45, 0x1B4C, 0x1B50, 0x1B59,
|
|
||||||
0x1B83, 0x1BA0, 0x1BAE, 0x1BE5, 0x1C00, 0x1C23, 0x1C40, 0x1C49, 0x1C4D, 0x1C7D, 0x1C80, 0x1C8A, 0x1C90,
|
|
||||||
0x1CBA, 0x1CBD, 0x1CBF, 0x1CE9, 0x1CEC, 0x1CEE, 0x1CF3, 0x1CF5, 0x1CF6, 0x1CFA, 0x1CFA, 0x1D00, 0x1DBF,
|
|
||||||
0x1E00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B,
|
|
||||||
0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4,
|
|
||||||
0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2070,
|
|
||||||
0x2071, 0x2074, 0x2079, 0x207F, 0x2089, 0x2090, 0x209C, 0x2102, 0x2102, 0x2107, 0x2107, 0x210A, 0x2113,
|
|
||||||
0x2115, 0x2115, 0x2119, 0x211D, 0x2124, 0x2124, 0x2126, 0x2126, 0x2128, 0x2128, 0x212A, 0x212D, 0x212F,
|
|
||||||
0x2139, 0x213C, 0x213F, 0x2145, 0x2149, 0x214E, 0x214E, 0x2150, 0x2189, 0x2460, 0x249B, 0x24EA, 0x24FF,
|
|
||||||
0x2776, 0x2793, 0x2C00, 0x2CE4, 0x2CEB, 0x2CEE, 0x2CF2, 0x2CF3, 0x2CFD, 0x2CFD, 0x2D00, 0x2D25, 0x2D27,
|
|
||||||
0x2D27, 0x2D2D, 0x2D2D, 0x2D30, 0x2D67, 0x2D6F, 0x2D6F, 0x2D80, 0x2D96, 0x2DA0, 0x2DA6, 0x2DA8, 0x2DAE,
|
|
||||||
0x2DB0, 0x2DB6, 0x2DB8, 0x2DBE, 0x2DC0, 0x2DC6, 0x2DC8, 0x2DCE, 0x2DD0, 0x2DD6, 0x2DD8, 0x2DDE, 0x2E2F,
|
|
||||||
0x2E2F, 0x3005, 0x3007, 0x3021, 0x3029, 0x3031, 0x3035, 0x3038, 0x303C, 0x3041, 0x3096, 0x309D, 0x309F,
|
|
||||||
0x30A1, 0x30FA, 0x30FC, 0x30FF, 0x3105, 0x312F, 0x3131, 0x318E, 0x3192, 0x3195, 0x31A0, 0x31BF, 0x31F0,
|
|
||||||
0x31FF, 0x3220, 0x3229, 0x3248, 0x324F, 0x3251, 0x325F, 0x3280, 0x3289, 0x32B1, 0x32BF, 0x3400, 0x4DBF,
|
|
||||||
0x4E00, 0xA48C, 0xA4D0, 0xA4FD, 0xA500, 0xA60C, 0xA610, 0xA62B, 0xA640, 0xA66E, 0xA67F, 0xA69D, 0xA6A0,
|
|
||||||
0xA6EF, 0xA717, 0xA71F, 0xA722, 0xA788, 0xA78B, 0xA7CD, 0xA7D0, 0xA7D1, 0xA7D3, 0xA7D3, 0xA7D5, 0xA7DC,
|
|
||||||
0xA7F2, 0xA801, 0xA803, 0xA805, 0xA807, 0xA80A, 0xA80C, 0xA822, 0xA830, 0xA835, 0xA840, 0xA873, 0xA882,
|
|
||||||
0xA8B3, 0xA8D0, 0xA8D9, 0xA8F2, 0xA8F7, 0xA8FB, 0xA8FB, 0xA8FD, 0xA8FE, 0xA900, 0xA925, 0xA930, 0xA946,
|
|
||||||
0xA960, 0xA97C, 0xA984, 0xA9B2, 0xA9CF, 0xA9D9, 0xA9E0, 0xA9E4, 0xA9E6, 0xA9FE, 0xAA00, 0xAA28, 0xAA40,
|
|
||||||
0xAA42, 0xAA44, 0xAA4B, 0xAA50, 0xAA59, 0xAA60, 0xAA76, 0xAA7A, 0xAA7A, 0xAA7E, 0xAAAF, 0xAAB1, 0xAAB1,
|
|
||||||
0xAAB5, 0xAAB6, 0xAAB9, 0xAABD, 0xAAC0, 0xAAC0, 0xAAC2, 0xAAC2, 0xAADB, 0xAADD, 0xAAE0, 0xAAEA, 0xAAF2,
|
|
||||||
0xAAF4, 0xAB01, 0xAB06, 0xAB09, 0xAB0E, 0xAB11, 0xAB16, 0xAB20, 0xAB26, 0xAB28, 0xAB2E, 0xAB30, 0xAB5A,
|
|
||||||
0xAB5C, 0xAB69, 0xAB70, 0xABE2, 0xABF0, 0xABF9, 0xAC00, 0xD7A3, 0xD7B0, 0xD7C6, 0xD7CB, 0xD7FB, 0xF900,
|
|
||||||
0xFA6D, 0xFA70, 0xFAD9, 0xFB00, 0xFB06, 0xFB13, 0xFB17, 0xFB1D, 0xFB1D, 0xFB1F, 0xFB28, 0xFB2A, 0xFB36,
|
|
||||||
0xFB38, 0xFB3C, 0xFB3E, 0xFB3E, 0xFB40, 0xFB41, 0xFB43, 0xFB44, 0xFB46, 0xFBB1, 0xFBD3, 0xFD3D, 0xFD50,
|
|
||||||
0xFD8F, 0xFD92, 0xFDC7, 0xFDF0, 0xFDFB, 0xFE70, 0xFE74, 0xFE76, 0xFEFC, 0xFF10, 0xFF19, 0xFF21, 0xFF3A,
|
|
||||||
0xFF41, 0xFF5A, 0xFF66, 0xFFBE, 0xFFC2, 0xFFC7, 0xFFCA, 0xFFCF, 0xFFD2, 0xFFD7, 0xFFDA, 0xFFDC, 0x10000,
|
|
||||||
0x1000B, 0x1000D, 0x10026, 0x10028, 0x1003A, 0x1003C, 0x1003D, 0x1003F, 0x1004D, 0x10050, 0x1005D,
|
|
||||||
0x10080, 0x100FA, 0x10107, 0x10133, 0x10140, 0x10178, 0x1018A, 0x1018B, 0x10280, 0x1029C, 0x102A0,
|
|
||||||
0x102D0, 0x102E1, 0x102FB, 0x10300, 0x10323, 0x1032D, 0x1034A, 0x10350, 0x10375, 0x10380, 0x1039D,
|
|
||||||
0x103A0, 0x103C3, 0x103C8, 0x103CF, 0x103D1, 0x103D5, 0x10400, 0x1049D, 0x104A0, 0x104A9, 0x104B0,
|
|
||||||
0x104D3, 0x104D8, 0x104FB, 0x10500, 0x10527, 0x10530, 0x10563, 0x10570, 0x1057A, 0x1057C, 0x1058A,
|
|
||||||
0x1058C, 0x10592, 0x10594, 0x10595, 0x10597, 0x105A1, 0x105A3, 0x105B1, 0x105B3, 0x105B9, 0x105BB,
|
|
||||||
0x105BC, 0x105C0, 0x105F3, 0x10600, 0x10736, 0x10740, 0x10755, 0x10760, 0x10767, 0x10780, 0x10785,
|
|
||||||
0x10787, 0x107B0, 0x107B2, 0x107BA, 0x10800, 0x10805, 0x10808, 0x10808, 0x1080A, 0x10835, 0x10837,
|
|
||||||
0x10838, 0x1083C, 0x1083C, 0x1083F, 0x10855, 0x10858, 0x10876, 0x10879, 0x1089E, 0x108A7, 0x108AF,
|
|
||||||
0x108E0, 0x108F2, 0x108F4, 0x108F5, 0x108FB, 0x1091B, 0x10920, 0x10939, 0x10980, 0x109B7, 0x109BC,
|
|
||||||
0x109CF, 0x109D2, 0x10A00, 0x10A10, 0x10A13, 0x10A15, 0x10A17, 0x10A19, 0x10A35, 0x10A40, 0x10A48,
|
|
||||||
0x10A60, 0x10A7E, 0x10A80, 0x10A9F, 0x10AC0, 0x10AC7, 0x10AC9, 0x10AE4, 0x10AEB, 0x10AEF, 0x10B00,
|
|
||||||
0x10B35, 0x10B40, 0x10B55, 0x10B58, 0x10B72, 0x10B78, 0x10B91, 0x10BA9, 0x10BAF, 0x10C00, 0x10C48,
|
|
||||||
0x10C80, 0x10CB2, 0x10CC0, 0x10CF2, 0x10CFA, 0x10D23, 0x10D30, 0x10D39, 0x10D40, 0x10D65, 0x10D6F,
|
|
||||||
0x10D85, 0x10E60, 0x10E7E, 0x10E80, 0x10EA9, 0x10EB0, 0x10EB1, 0x10EC2, 0x10EC4, 0x10F00, 0x10F27,
|
|
||||||
0x10F30, 0x10F45, 0x10F51, 0x10F54, 0x10F70, 0x10F81, 0x10FB0, 0x10FCB, 0x10FE0, 0x10FF6, 0x11003,
|
|
||||||
0x11037, 0x11052, 0x1106F, 0x11071, 0x11072, 0x11075, 0x11075, 0x11083, 0x110AF, 0x110D0, 0x110E8,
|
|
||||||
0x110F0, 0x110F9, 0x11103, 0x11126, 0x11136, 0x1113F, 0x11144, 0x11144, 0x11147, 0x11147, 0x11150,
|
|
||||||
0x11172, 0x11176, 0x11176, 0x11183, 0x111B2, 0x111C1, 0x111C4, 0x111D0, 0x111DA, 0x111DC, 0x111DC,
|
|
||||||
0x111E1, 0x111F4, 0x11200, 0x11211, 0x11213, 0x1122B, 0x1123F, 0x11240, 0x11280, 0x11286, 0x11288,
|
|
||||||
0x11288, 0x1128A, 0x1128D, 0x1128F, 0x1129D, 0x1129F, 0x112A8, 0x112B0, 0x112DE, 0x112F0, 0x112F9,
|
|
||||||
0x11305, 0x1130C, 0x1130F, 0x11310, 0x11313, 0x11328, 0x1132A, 0x11330, 0x11332, 0x11333, 0x11335,
|
|
||||||
0x11339, 0x1133D, 0x1133D, 0x11350, 0x11350, 0x1135D, 0x11361, 0x11380, 0x11389, 0x1138B, 0x1138B,
|
|
||||||
0x1138E, 0x1138E, 0x11390, 0x113B5, 0x113B7, 0x113B7, 0x113D1, 0x113D1, 0x113D3, 0x113D3, 0x11400,
|
|
||||||
0x11434, 0x11447, 0x1144A, 0x11450, 0x11459, 0x1145F, 0x11461, 0x11480, 0x114AF, 0x114C4, 0x114C5,
|
|
||||||
0x114C7, 0x114C7, 0x114D0, 0x114D9, 0x11580, 0x115AE, 0x115D8, 0x115DB, 0x11600, 0x1162F, 0x11644,
|
|
||||||
0x11644, 0x11650, 0x11659, 0x11680, 0x116AA, 0x116B8, 0x116B8, 0x116C0, 0x116C9, 0x116D0, 0x116E3,
|
|
||||||
0x11700, 0x1171A, 0x11730, 0x1173B, 0x11740, 0x11746, 0x11800, 0x1182B, 0x118A0, 0x118F2, 0x118FF,
|
|
||||||
0x11906, 0x11909, 0x11909, 0x1190C, 0x11913, 0x11915, 0x11916, 0x11918, 0x1192F, 0x1193F, 0x1193F,
|
|
||||||
0x11941, 0x11941, 0x11950, 0x11959, 0x119A0, 0x119A7, 0x119AA, 0x119D0, 0x119E1, 0x119E1, 0x119E3,
|
|
||||||
0x119E3, 0x11A00, 0x11A00, 0x11A0B, 0x11A32, 0x11A3A, 0x11A3A, 0x11A50, 0x11A50, 0x11A5C, 0x11A89,
|
|
||||||
0x11A9D, 0x11A9D, 0x11AB0, 0x11AF8, 0x11BC0, 0x11BE0, 0x11BF0, 0x11BF9, 0x11C00, 0x11C08, 0x11C0A,
|
|
||||||
0x11C2E, 0x11C40, 0x11C40, 0x11C50, 0x11C6C, 0x11C72, 0x11C8F, 0x11D00, 0x11D06, 0x11D08, 0x11D09,
|
|
||||||
0x11D0B, 0x11D30, 0x11D46, 0x11D46, 0x11D50, 0x11D59, 0x11D60, 0x11D65, 0x11D67, 0x11D68, 0x11D6A,
|
|
||||||
0x11D89, 0x11D98, 0x11D98, 0x11DA0, 0x11DA9, 0x11EE0, 0x11EF2, 0x11F02, 0x11F02, 0x11F04, 0x11F10,
|
|
||||||
0x11F12, 0x11F33, 0x11F50, 0x11F59, 0x11FB0, 0x11FB0, 0x11FC0, 0x11FD4, 0x12000, 0x12399, 0x12400,
|
|
||||||
0x1246E, 0x12480, 0x12543, 0x12F90, 0x12FF0, 0x13000, 0x1342F, 0x13441, 0x13446, 0x13460, 0x143FA,
|
|
||||||
0x14400, 0x14646, 0x16100, 0x1611D, 0x16130, 0x16139, 0x16800, 0x16A38, 0x16A40, 0x16A5E, 0x16A60,
|
|
||||||
0x16A69, 0x16A70, 0x16ABE, 0x16AC0, 0x16AC9, 0x16AD0, 0x16AED, 0x16B00, 0x16B2F, 0x16B40, 0x16B43,
|
|
||||||
0x16B50, 0x16B59, 0x16B5B, 0x16B61, 0x16B63, 0x16B77, 0x16B7D, 0x16B8F, 0x16D40, 0x16D6C, 0x16D70,
|
|
||||||
0x16D79, 0x16E40, 0x16E96, 0x16F00, 0x16F4A, 0x16F50, 0x16F50, 0x16F93, 0x16F9F, 0x16FE0, 0x16FE1,
|
|
||||||
0x16FE3, 0x16FE3, 0x17000, 0x187F7, 0x18800, 0x18CD5, 0x18CFF, 0x18D08, 0x1AFF0, 0x1AFF3, 0x1AFF5,
|
|
||||||
0x1AFFB, 0x1AFFD, 0x1AFFE, 0x1B000, 0x1B122, 0x1B132, 0x1B132, 0x1B150, 0x1B152, 0x1B155, 0x1B155,
|
|
||||||
0x1B164, 0x1B167, 0x1B170, 0x1B2FB, 0x1BC00, 0x1BC6A, 0x1BC70, 0x1BC7C, 0x1BC80, 0x1BC88, 0x1BC90,
|
|
||||||
0x1BC99, 0x1CCF0, 0x1CCF9, 0x1D2C0, 0x1D2D3, 0x1D2E0, 0x1D2F3, 0x1D360, 0x1D378, 0x1D400, 0x1D454,
|
|
||||||
0x1D456, 0x1D49C, 0x1D49E, 0x1D49F, 0x1D4A2, 0x1D4A2, 0x1D4A5, 0x1D4A6, 0x1D4A9, 0x1D4AC, 0x1D4AE,
|
|
||||||
0x1D4B9, 0x1D4BB, 0x1D4BB, 0x1D4BD, 0x1D4C3, 0x1D4C5, 0x1D505, 0x1D507, 0x1D50A, 0x1D50D, 0x1D514,
|
|
||||||
0x1D516, 0x1D51C, 0x1D51E, 0x1D539, 0x1D53B, 0x1D53E, 0x1D540, 0x1D544, 0x1D546, 0x1D546, 0x1D54A,
|
|
||||||
0x1D550, 0x1D552, 0x1D6A5, 0x1D6A8, 0x1D6C0, 0x1D6C2, 0x1D6DA, 0x1D6DC, 0x1D6FA, 0x1D6FC, 0x1D714,
|
|
||||||
0x1D716, 0x1D734, 0x1D736, 0x1D74E, 0x1D750, 0x1D76E, 0x1D770, 0x1D788, 0x1D78A, 0x1D7A8, 0x1D7AA,
|
|
||||||
0x1D7C2, 0x1D7C4, 0x1D7CB, 0x1D7CE, 0x1D7FF, 0x1DF00, 0x1DF1E, 0x1DF25, 0x1DF2A, 0x1E030, 0x1E06D,
|
|
||||||
0x1E100, 0x1E12C, 0x1E137, 0x1E13D, 0x1E140, 0x1E149, 0x1E14E, 0x1E14E, 0x1E290, 0x1E2AD, 0x1E2C0,
|
|
||||||
0x1E2EB, 0x1E2F0, 0x1E2F9, 0x1E4D0, 0x1E4EB, 0x1E4F0, 0x1E4F9, 0x1E5D0, 0x1E5ED, 0x1E5F0, 0x1E5FA,
|
|
||||||
0x1E7E0, 0x1E7E6, 0x1E7E8, 0x1E7EB, 0x1E7ED, 0x1E7EE, 0x1E7F0, 0x1E7FE, 0x1E800, 0x1E8C4, 0x1E8C7,
|
|
||||||
0x1E8CF, 0x1E900, 0x1E943, 0x1E94B, 0x1E94B, 0x1E950, 0x1E959, 0x1EC71, 0x1ECAB, 0x1ECAD, 0x1ECAF,
|
|
||||||
0x1ECB1, 0x1ECB4, 0x1ED01, 0x1ED2D, 0x1ED2F, 0x1ED3D, 0x1EE00, 0x1EE03, 0x1EE05, 0x1EE1F, 0x1EE21,
|
|
||||||
0x1EE22, 0x1EE24, 0x1EE24, 0x1EE27, 0x1EE27, 0x1EE29, 0x1EE32, 0x1EE34, 0x1EE37, 0x1EE39, 0x1EE39,
|
|
||||||
0x1EE3B, 0x1EE3B, 0x1EE42, 0x1EE42, 0x1EE47, 0x1EE47, 0x1EE49, 0x1EE49, 0x1EE4B, 0x1EE4B, 0x1EE4D,
|
|
||||||
0x1EE4F, 0x1EE51, 0x1EE52, 0x1EE54, 0x1EE54, 0x1EE57, 0x1EE57, 0x1EE59, 0x1EE59, 0x1EE5B, 0x1EE5B,
|
|
||||||
0x1EE5D, 0x1EE5D, 0x1EE5F, 0x1EE5F, 0x1EE61, 0x1EE62, 0x1EE64, 0x1EE64, 0x1EE67, 0x1EE6A, 0x1EE6C,
|
|
||||||
0x1EE72, 0x1EE74, 0x1EE77, 0x1EE79, 0x1EE7C, 0x1EE7E, 0x1EE7E, 0x1EE80, 0x1EE89, 0x1EE8B, 0x1EE9B,
|
|
||||||
0x1EEA1, 0x1EEA3, 0x1EEA5, 0x1EEA9, 0x1EEAB, 0x1EEBB, 0x1F100, 0x1F10C, 0x1FBF0, 0x1FBF9, 0x20000,
|
|
||||||
0x2A6DF, 0x2A700, 0x2B739, 0x2B740, 0x2B81D, 0x2B820, 0x2CEA1, 0x2CEB0, 0x2EBE0, 0x2EBF0, 0x2EE5D,
|
|
||||||
0x2F800, 0x2FA1D, 0x30000, 0x3134A, 0x31350, 0x323AF,
|
|
||||||
};
|
|
||||||
|
|
||||||
public static readonly int[] DefaultIgnorable =
|
|
||||||
{
|
|
||||||
0xAD, 0xAD, 0x34F, 0x34F, 0x61C, 0x61C, 0x115F, 0x1160, 0x17B4, 0x17B5, 0x180B, 0x180F, 0x200B, 0x200F,
|
|
||||||
0x202A, 0x202E, 0x2060, 0x206F, 0x3164, 0x3164, 0xFE00, 0xFE0F, 0xFEFF, 0xFEFF, 0xFFA0, 0xFFA0, 0xFFF0,
|
|
||||||
0xFFF8, 0x1BCA0, 0x1BCA3, 0x1D173, 0x1D17A, 0xE0000, 0xE0FFF,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,40 +24,6 @@ namespace GameCult.Networking
|
||||||
public CultNetErrorMessage Refusal { get; }
|
public CultNetErrorMessage Refusal { get; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A write nothing answered within its window: neither admitted nor refused (write-receipt.md, "unconfirmed").
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CultNetWriteUnconfirmedException : Exception
|
|
||||||
{
|
|
||||||
/// <summary>Creates the exception for the write <paramref name="messageId"/>.</summary>
|
|
||||||
public CultNetWriteUnconfirmedException(string messageId)
|
|
||||||
: base($"No admission or refusal arrived for write '{messageId}'.")
|
|
||||||
{
|
|
||||||
MessageId = messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Gets the id of the unconfirmed write.</summary>
|
|
||||||
public string MessageId { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class CultNetWriteNotSentException : Exception
|
|
||||||
{
|
|
||||||
/// <summary>Creates the exception for the write <paramref name="messageId"/>; <paramref name="cause"/> is for logs only.</summary>
|
|
||||||
public CultNetWriteNotSentException(string messageId, Exception? cause = null)
|
|
||||||
: base($"Write '{messageId}' was not sent.", cause)
|
|
||||||
{
|
|
||||||
MessageId = messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Gets the id of the write that was not sent.</summary>
|
|
||||||
public string MessageId { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Completes a write on the peer's result frame, never on a send's return or a transport acknowledgement:
|
/// 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.
|
/// an ack proves delivery, not admission.
|
||||||
|
|
@ -67,49 +33,6 @@ namespace GameCult.Networking
|
||||||
/// <summary>How long after a send a refusal may still arrive when the caller names no window.</summary>
|
/// <summary>How long after a send a refusal may still arrive when the caller names no window.</summary>
|
||||||
public static readonly TimeSpan DefaultResultWindow = TimeSpan.FromMilliseconds(500);
|
public static readonly TimeSpan DefaultResultWindow = TimeSpan.FromMilliseconds(500);
|
||||||
|
|
||||||
/// <summary>How long a receipt-capable peer has to answer a write (write-receipt.md, <c>RECEIPT_RESULT_WINDOW_MS</c>).</summary>
|
|
||||||
public static readonly TimeSpan ReceiptResultWindow = TimeSpan.FromMilliseconds(5000);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Sends one write to a receipt-capable peer and waits <paramref name="resultWindow"/> for its answer: a
|
|
||||||
/// <c>write_admitted</c> naming <paramref name="messageId"/> returns, an <c>error.v1</c> naming it throws
|
|
||||||
/// <see cref="CultNetWriteRefusedException"/>, 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 <see cref="CultNetWriteUnconfirmedException"/>.
|
|
||||||
/// </summary>
|
|
||||||
public static async Task SendAndAwaitReceiptAsync(
|
|
||||||
Func<Action<CultNetWriteAdmittedMessage>, Action<CultNetErrorMessage>, Action<CultNetErrorV1Message>, 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<CultNetErrorMessage?>(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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends one write and waits <paramref name="resultWindow"/> for the peer's answer. A refusal frame rejects with
|
/// Sends one write and waits <paramref name="resultWindow"/> for the peer's answer. A refusal frame rejects with
|
||||||
/// <see cref="CultNetWriteRefusedException"/> whatever its order against the transport acknowledgement. A peer
|
/// <see cref="CultNetWriteRefusedException"/> whatever its order against the transport acknowledgement. A peer
|
||||||
|
|
|
||||||
|
|
@ -1,396 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using GameCult.Caching;
|
|
||||||
using MessagePack;
|
|
||||||
|
|
||||||
namespace GameCult.Networking
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// The content hash a write-receipt server binds to a (peer, messageId) dedupe key
|
|
||||||
/// (contracts/cultnet/interop/write-receipt.md, rule 5).
|
|
||||||
/// </summary>
|
|
||||||
public static class CultNetWriteContentHash
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// SHA-256 of the MessagePack array <c>[operation, schemaId, recordKey, payload]</c>, <c>payload</c> a <c>bin</c>
|
|
||||||
/// for a put and nil for a delete.
|
|
||||||
/// </summary>
|
|
||||||
public static byte[] Compute(string operation, string schemaId, string recordKey, byte[]? payload)
|
|
||||||
{
|
|
||||||
var buffer = new System.Buffers.ArrayBufferWriter<byte>();
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether a peer's hello opted in to write receipts: it lists both <c>cultnet.write_admitted.v0</c> and
|
|
||||||
/// <c>cultnet.error.v1</c>. The capability changes framing only; it never authorizes.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A SHA-256 digest held by value: the fixed-size stand-in for an id or a content hash.</summary>
|
|
||||||
internal readonly struct CultNetDigest : IEquatable<CultNetDigest>
|
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
|
|
||||||
/// <summary>The refusal, in fixed text, naming the write's own record key or schema id where its details do.</summary>
|
|
||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class CultNetWriteReceiptSession
|
|
||||||
{
|
|
||||||
/// <summary>The most writes a peer is remembered for, refused ones included; a peer at the limit has new writes refused.</summary>
|
|
||||||
internal const int MaxRememberedWrites = 1024;
|
|
||||||
|
|
||||||
/// <summary>The most ids refused at the limit that are remembered, so their resend is judged the same way.</summary>
|
|
||||||
internal const int MaxRememberedLimitRefusals = 1024;
|
|
||||||
|
|
||||||
/// <summary>The longest messageId a write may carry, in UTF-8 bytes (write-receipt.md, Id size).</summary>
|
|
||||||
internal const int MaxMessageIdBytes = 256;
|
|
||||||
|
|
||||||
/// <summary>What a write is refused with when it failed for a reason no typed refusal names. Never an exception's own text.</summary>
|
|
||||||
internal const string WriteFailedText = "the write could not be applied";
|
|
||||||
|
|
||||||
private readonly object _gate = new();
|
|
||||||
private readonly Dictionary<CultNetDigest, Entry> _writes = new();
|
|
||||||
private readonly Dictionary<CultNetDigest, CultNetDigest> _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));
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Looks <paramref name="messageId"/> up first. An id not seen is claimed for this content (<see cref="Begin.New"/>);
|
|
||||||
/// otherwise the outcome says what already holds it, and <paramref name="refusal"/> is the refusal to repeat.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The write was admitted: remember it so a resend repeats the receipt.</summary>
|
|
||||||
public void Admitted(string messageId)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Admitted = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The write was refused: remember the refusal so a resend repeats it.</summary>
|
|
||||||
public void Refused(string messageId, CultNetRememberedRefusal refusal)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Refusal = refusal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public void Abandoned(string messageId)
|
|
||||||
{
|
|
||||||
lock (_gate)
|
|
||||||
{
|
|
||||||
if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Abandoned = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
internal static class CultNetReadableMessageId
|
|
||||||
{
|
|
||||||
/// <summary>The frame's top-level string <c>messageId</c>, or null when the bytes are not a map holding one.</summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5).
|
|
||||||
/// </summary>
|
|
||||||
internal sealed class CultNetReceiptedWrite
|
|
||||||
{
|
|
||||||
private readonly CultNetWriteReceiptSession _session;
|
|
||||||
private readonly Action<ICultNetSchemaMessage> _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<ICultNetSchemaMessage> 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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).
|
|
||||||
/// </summary>
|
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>The write committed: remember the receipt and send it.</summary>
|
|
||||||
public void Admitted()
|
|
||||||
{
|
|
||||||
if (_answered) return;
|
|
||||||
_answered = true;
|
|
||||||
if (_claimed) _session.Admitted(_messageId);
|
|
||||||
_send(AdmittedReceipt());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>The write was refused: remember the refusal (a resend repeats it) and send its one error.v1.</summary>
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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.
|
|
||||||
/// </summary>
|
|
||||||
public void LeftSilent()
|
|
||||||
{
|
|
||||||
if (_answered) return;
|
|
||||||
_answered = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A forward that was provably not sent (connect failed, or the send failed before any byte was written): forget the claim, send nothing.</summary>
|
|
||||||
public void NotSent()
|
|
||||||
{
|
|
||||||
if (_answered) return;
|
|
||||||
_answered = true;
|
|
||||||
if (_claimed) _session.Abandoned(_messageId);
|
|
||||||
_claimed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Why the write's id cannot be used at all, before anything is remembered: it is missing (refused with no id) or longer
|
|
||||||
/// than <see cref="CultNetWriteReceiptSession.MaxMessageIdBytes"/> UTF-8 bytes. Null when the id is usable.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -23,7 +23,7 @@ namespace GameCult.Networking
|
||||||
private const string EmailPattern =
|
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})$";
|
@"^([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 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 SessionTimeoutSeconds = 1800; // 30 minutes
|
||||||
private const float SessionRefreshThresholdSeconds = 300; // 5 minutes
|
private const float SessionRefreshThresholdSeconds = 300; // 5 minutes
|
||||||
private const int MaxConnectionAttemptsPerMinute = 30;
|
private const int MaxConnectionAttemptsPerMinute = 30;
|
||||||
|
|
@ -63,12 +63,6 @@ namespace GameCult.Networking
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool LogSensitivePayloads { get; set; }
|
public bool LogSensitivePayloads { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets or sets the UDP port <see cref="Start"/> binds (default 3075; 0 asks the OS for a free one). After
|
|
||||||
/// <see cref="Start"/> it holds the port actually bound.
|
|
||||||
/// </summary>
|
|
||||||
public int Port { get; set; } = DefaultPort;
|
|
||||||
|
|
||||||
private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d);
|
private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -80,7 +74,7 @@ namespace GameCult.Networking
|
||||||
{
|
{
|
||||||
TransportId = "litenetlib-server",
|
TransportId = "litenetlib-server",
|
||||||
Host = "0.0.0.0",
|
Host = "0.0.0.0",
|
||||||
Port = Port
|
Port = ServerPort
|
||||||
});
|
});
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -363,8 +357,7 @@ namespace GameCult.Networking
|
||||||
UnsyncedEvents = true,
|
UnsyncedEvents = true,
|
||||||
NatPunchEnabled = true
|
NatPunchEnabled = true
|
||||||
};
|
};
|
||||||
_netManager.Start(Port);
|
_netManager.Start(ServerPort);
|
||||||
Port = _netManager.LocalPort;
|
|
||||||
|
|
||||||
listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}");
|
listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}");
|
||||||
|
|
||||||
|
|
@ -415,25 +408,11 @@ namespace GameCult.Networking
|
||||||
{
|
{
|
||||||
var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame);
|
var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame);
|
||||||
Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}");
|
Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}");
|
||||||
if (transport.RefuseUnreliableWrite(cultNetMessage, method)) return;
|
|
||||||
await HandleCultNetSchemaMessageAsync(peer, user, cultNetMessage).ConfigureAwait(false);
|
await HandleCultNetSchemaMessageAsync(peer, user, cultNetMessage).ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Message message;
|
var message = LiteNetLibTransportConnection.DecodeLegacy(frame);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (LogSensitivePayloads)
|
if (LogSensitivePayloads)
|
||||||
{
|
{
|
||||||
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(frame.Payload))}");
|
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(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)
|
private async Task HandleCultNetSchemaMessageAsync(NetPeer peer, User user, ICultNetSchemaMessage message)
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ static async Task<int> ProgramMainAsync(string[] args)
|
||||||
{
|
{
|
||||||
if (args.Length == 0)
|
if (args.Length == 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Expected mode: serve | probe | dial | rudp-serve-once | rudp-dial-once | rudp-serve-message-once | rudp-dial-message-once | quic-realtime-serve | receipt-serve | receipt-relay");
|
throw new InvalidOperationException("Expected mode: serve | probe | dial | rudp-serve-once | rudp-dial-once | rudp-serve-message-once | rudp-dial-message-once | quic-realtime-serve");
|
||||||
}
|
}
|
||||||
|
|
||||||
var mode = args[0];
|
var mode = args[0];
|
||||||
|
|
@ -53,12 +53,6 @@ static async Task<int> ProgramMainAsync(string[] args)
|
||||||
case "rudp-dial-message-once":
|
case "rudp-dial-message-once":
|
||||||
RudpDialMessageOnce(options);
|
RudpDialMessageOnce(options);
|
||||||
return 0;
|
return 0;
|
||||||
case "receipt-serve":
|
|
||||||
await GameCult.Networking.InteropPeer.ReceiptHarness.ServeAsync();
|
|
||||||
return 0;
|
|
||||||
case "receipt-relay":
|
|
||||||
await GameCult.Networking.InteropPeer.ReceiptHarness.RelayAsync(int.Parse(options["port"], CultureInfo.InvariantCulture));
|
|
||||||
return 0;
|
|
||||||
case "quic-realtime-serve":
|
case "quic-realtime-serve":
|
||||||
await QuicRealtimeServeAsync(ParseQuicRealtimeServeConfig(options));
|
await QuicRealtimeServeAsync(ParseQuicRealtimeServeConfig(options));
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.IO;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using GameCult.Caching;
|
|
||||||
using GameCult.Networking;
|
|
||||||
using LiteNetLib;
|
|
||||||
using R3;
|
|
||||||
|
|
||||||
// The real C# receipt server (CultNetDatabaseServer on Server) and the one transport shim the TypeScript client needs to reach it,
|
|
||||||
// for the cross-runtime write-receipt interop harness (packages/cultmesh-ts/test/interop/write-receipt-interop.test.ts).
|
|
||||||
// TypeScript has no LiteNetLib client, so the shim moves bytes and nothing else: each CultNet frame is a LiteNetLib reliable
|
|
||||||
// ordered payload, and on the pipe it is a 4-byte big-endian length followed by the frame.
|
|
||||||
namespace GameCult.Networking.InteropPeer
|
|
||||||
{
|
|
||||||
// CompatibleSchemaIds are the names the spec's blankFields vectors admit (U+200B, U+001C, and a name padded with NBSP and EM SPACE):
|
|
||||||
// a name is any string that is not blank, so the store must hold a record put under one of them.
|
|
||||||
[CultDocument("receipt.interop_note", ReceiptHarness.SchemaId, CompatibleSchemaIds = ["\u200b", "\u001c", "\u00a0a\u2003"])]
|
|
||||||
[MessagePack.MessagePackObject]
|
|
||||||
public sealed class ReceiptInteropNote
|
|
||||||
{
|
|
||||||
[MessagePack.Key(0)]
|
|
||||||
public string Text { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static class ReceiptHarness
|
|
||||||
{
|
|
||||||
public const string SchemaId = "receipt.interop_note.v1";
|
|
||||||
public const string ShardId = "receipt-interop-shard";
|
|
||||||
// A record key with this prefix is refused by the authority: how the harness asks for a refused write.
|
|
||||||
public const string RefusedPrefix = "refuse:";
|
|
||||||
|
|
||||||
private sealed class Authority : ICultNetRemoteWriteAuthority
|
|
||||||
{
|
|
||||||
public CultNetRemoteWriteDecision Decide(CultNetRemoteWriteRequest request) =>
|
|
||||||
request.RecordKey.ToString().StartsWith(RefusedPrefix, StringComparison.Ordinal)
|
|
||||||
? CultNetRemoteWriteDecision.Refuse("denied")
|
|
||||||
: CultNetRemoteWriteDecision.Grant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Emit(object fact)
|
|
||||||
{
|
|
||||||
lock (Console.Out) Console.Out.WriteLine(JsonSerializer.Serialize(fact));
|
|
||||||
}
|
|
||||||
|
|
||||||
// stdout: {"event":"listening","port":N} then {"event":"applied"|"removed","recordKey":"..."} per committed change; runs until stdin closes.
|
|
||||||
public static async Task ServeAsync()
|
|
||||||
{
|
|
||||||
var cache = new CultCache();
|
|
||||||
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions
|
|
||||||
{
|
|
||||||
Shards = [new CultNetShardDescriptor(ShardId, "primary", epoch: 1, isPrimary: true, schemaIds: [SchemaId])]
|
|
||||||
});
|
|
||||||
using var server = new Server(cache, ServerSecurityOptions.Development()) { Port = 0 };
|
|
||||||
using var databaseServer = new CultNetDatabaseServer(server, database, new CultNetDatabaseServerOptions
|
|
||||||
{
|
|
||||||
RemoteWriteAuthority = new Authority()
|
|
||||||
});
|
|
||||||
using var watch = cache.Watch<ReceiptInteropNote>().Subscribe(change =>
|
|
||||||
Emit(new { @event = change.Kind == CultCacheDocumentChangeKind.Removed ? "removed" : "applied", recordKey = change.Key.ToString() }));
|
|
||||||
server.Start();
|
|
||||||
Emit(new { @event = "listening", port = server.Port, shardId = ShardId, schemaId = SchemaId });
|
|
||||||
await Task.Run(() => Console.In.ReadToEnd());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connects to the server on `port` and relays frames between stdin/stdout and the connection; exits when either ends.
|
|
||||||
public static async Task RelayAsync(int port)
|
|
||||||
{
|
|
||||||
var listener = new EventBasedNetListener();
|
|
||||||
var connected = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
var ended = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
||||||
var output = Console.OpenStandardOutput();
|
|
||||||
var gate = new object();
|
|
||||||
listener.PeerConnectedEvent += _ => connected.TrySetResult(true);
|
|
||||||
listener.PeerDisconnectedEvent += (_, _) => ended.TrySetResult(true);
|
|
||||||
listener.NetworkReceiveEvent += (_, reader, _, _) =>
|
|
||||||
{
|
|
||||||
var frame = reader.GetRemainingBytes();
|
|
||||||
var header = new byte[] { (byte)(frame.Length >> 24), (byte)(frame.Length >> 16), (byte)(frame.Length >> 8), (byte)frame.Length };
|
|
||||||
lock (gate)
|
|
||||||
{
|
|
||||||
output.Write(header);
|
|
||||||
output.Write(frame);
|
|
||||||
output.Flush();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var manager = new NetManager(listener) { UnsyncedEvents = true };
|
|
||||||
manager.Start(0);
|
|
||||||
var peer = manager.Connect("127.0.0.1", port, ServerSecurityOptions.Development().ConnectionKey);
|
|
||||||
if (await Task.WhenAny(connected.Task, Task.Delay(TimeSpan.FromSeconds(10))) != connected.Task)
|
|
||||||
throw new InvalidOperationException("the relay did not connect");
|
|
||||||
var input = Console.OpenStandardInput();
|
|
||||||
var pump = Task.Run(() =>
|
|
||||||
{
|
|
||||||
var header = new byte[4];
|
|
||||||
while (ReadExactly(input, header))
|
|
||||||
{
|
|
||||||
var frame = new byte[(header[0] << 24) | (header[1] << 16) | (header[2] << 8) | header[3]];
|
|
||||||
if (!ReadExactly(input, frame)) break;
|
|
||||||
peer.Send(frame, DeliveryMethod.ReliableOrdered);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await Task.WhenAny(pump, ended.Task);
|
|
||||||
manager.Stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool ReadExactly(Stream stream, byte[] buffer)
|
|
||||||
{
|
|
||||||
var read = 0;
|
|
||||||
while (read < buffer.Length)
|
|
||||||
{
|
|
||||||
var count = stream.Read(buffer, read, buffer.Length - read);
|
|
||||||
if (count <= 0) return false;
|
|
||||||
read += count;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -418,7 +418,6 @@ namespace GameCult.Networking.Tests
|
||||||
CultNetDatabaseChangeKind.Added, VariantKey, schemaId, database.Shards[0], cache.Get<VariantWireFixture>(VariantKey)!, previousDocument: null);
|
CultNetDatabaseChangeKind.Added, VariantKey, schemaId, database.Shards[0], cache.Get<VariantWireFixture>(VariantKey)!, previousDocument: null);
|
||||||
Assert.That(Deliver(databaseServer, added, selection, refused, errors), Is.Null);
|
Assert.That(Deliver(databaseServer, added, selection, refused, errors), Is.Null);
|
||||||
Assert.That(errors, Has.Count.EqualTo(1));
|
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<VariantWireFixture>(
|
var removed = new CultNetDatabaseChange<VariantWireFixture>(
|
||||||
CultNetDatabaseChangeKind.Removed, VariantKey, schemaId, database.Shards[0], document: null, previousDocument: cache.Get<VariantWireFixture>(VariantKey)!);
|
CultNetDatabaseChangeKind.Removed, VariantKey, schemaId, database.Shards[0], document: null, previousDocument: cache.Get<VariantWireFixture>(VariantKey)!);
|
||||||
|
|
|
||||||
|
|
@ -178,46 +178,6 @@ namespace GameCult.Networking.Tests
|
||||||
Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1));
|
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<CultNetHelloMessage>();
|
|
||||||
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<string>();
|
|
||||||
Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.WriteAdmitted));
|
|
||||||
Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.ErrorV1));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
client.Socket.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<CultNetErrorMessage> ErrorsReceivedBy(Client client)
|
private static List<CultNetErrorMessage> ErrorsReceivedBy(Client client)
|
||||||
{
|
{
|
||||||
var errors = new List<CultNetErrorMessage>();
|
var errors = new List<CultNetErrorMessage>();
|
||||||
|
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text.Json;
|
|
||||||
using NUnit.Framework;
|
|
||||||
|
|
||||||
namespace GameCult.Networking.Tests
|
|
||||||
{
|
|
||||||
// contracts/cultnet/interop/write-receipt-vectors.json `admittedMessageId`: a write_admitted messageId needs a letter or
|
|
||||||
// number and no Default_Ignorable code point, compared raw (never trimmed). The frame is built with this runtime's own
|
|
||||||
// encoder; the case with `wireHex` (a lone surrogate is invalid UTF-8) supplies the whole frame.
|
|
||||||
public sealed class CultNetWriteAdmittedMessageIdTests
|
|
||||||
{
|
|
||||||
private static JsonElement Vectors()
|
|
||||||
{
|
|
||||||
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
|
|
||||||
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
|
|
||||||
directory = directory.Parent;
|
|
||||||
var path = Path.Combine(directory!.FullName, "contracts", "cultnet", "interop", "write-receipt-vectors.json");
|
|
||||||
return JsonDocument.Parse(File.ReadAllText(path)).RootElement.Clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static IEnumerable<string> Cases() =>
|
|
||||||
Vectors().GetProperty("admittedMessageId").GetProperty("cases").EnumerateArray().Select(c => c.GetProperty("name").GetString()!);
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void TheFileDeclaresHowManyCasesThisRunnerMustRun() =>
|
|
||||||
Assert.That(Cases().Count(), Is.EqualTo(Vectors().GetProperty("counts").GetProperty("admittedMessageIdCases").GetInt32()));
|
|
||||||
|
|
||||||
[TestCaseSource(nameof(Cases))]
|
|
||||||
public void AWriteAdmittedMessageIdIsRefusedOrAcceptedRaw(string name)
|
|
||||||
{
|
|
||||||
var vector = Vectors().GetProperty("admittedMessageId").GetProperty("cases").EnumerateArray()
|
|
||||||
.Single(c => c.GetProperty("name").GetString() == name);
|
|
||||||
var id = string.Concat(Enumerable.Repeat(
|
|
||||||
string.Concat(vector.GetProperty("codePoints").EnumerateArray()
|
|
||||||
.Select(cp => Convert.ToInt32(cp.GetString(), 16)).Where(point => point < 0xD800 || point > 0xDFFF).Select(char.ConvertFromUtf32)),
|
|
||||||
vector.TryGetProperty("repeat", out var repeat) ? repeat.GetInt32() : 1));
|
|
||||||
var bytes = vector.TryGetProperty("wireHex", out var wire)
|
|
||||||
? Convert.FromHexString(wire.GetString()!)
|
|
||||||
: CultNetSchemaMessageSerialization.Serialize(new CultNetWriteAdmittedMessage
|
|
||||||
{ MessageId = id, Operation = "put", SchemaId = "note.v0", RecordKey = "note:1" });
|
|
||||||
|
|
||||||
CultNetWriteAdmittedMessage? decoded = null;
|
|
||||||
try { decoded = (CultNetWriteAdmittedMessage)CultNetSchemaMessageSerialization.Deserialize(bytes); }
|
|
||||||
catch (Exception) { /* a refusal */ }
|
|
||||||
|
|
||||||
if (vector.GetProperty("expect").GetString() == "refuse")
|
|
||||||
Assert.That(decoded, Is.Null, vector.GetProperty("why").GetString());
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Assert.That(decoded, Is.Not.Null, vector.GetProperty("why").GetString());
|
|
||||||
Assert.That(decoded!.MessageId, Is.EqualTo(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The generated ranges file is the independent copy of the embedded tables: first, last and the points either side of every range.
|
|
||||||
[Test]
|
|
||||||
public void EveryRangeOfTheUnicodeTablesIsPinnedAtBothEnds()
|
|
||||||
{
|
|
||||||
var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory);
|
|
||||||
while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln")))
|
|
||||||
directory = directory.Parent;
|
|
||||||
var ranges = JsonDocument.Parse(File.ReadAllText(Path.Combine(directory!.FullName, "contracts", "cultnet", "interop", "write-admitted-message-id-ranges.json"))).RootElement;
|
|
||||||
var letters = ranges.GetProperty("letterOrNumber").EnumerateArray().Select(r => (r[0].GetInt32(), r[1].GetInt32())).ToArray();
|
|
||||||
var ignorable = ranges.GetProperty("defaultIgnorable").EnumerateArray().Select(r => (r[0].GetInt32(), r[1].GetInt32())).ToArray();
|
|
||||||
Assert.That(letters.Length, Is.GreaterThan(700));
|
|
||||||
Assert.That(ignorable.Length, Is.EqualTo(17));
|
|
||||||
static bool Accepted(string id)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
CultNetSchemaMessageSerialization.Deserialize(CultNetSchemaMessageSerialization.Serialize(
|
|
||||||
new CultNetWriteAdmittedMessage { MessageId = id, Operation = "put", SchemaId = "note.v0", RecordKey = "note:1" }));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception) { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
var failures = new List<string>();
|
|
||||||
foreach (var (low, high) in letters)
|
|
||||||
foreach (var (point, want) in new[] { (low, true), (high, true), (low - 1, false), (high + 1, false) })
|
|
||||||
if (Accepted(char.ConvertFromUtf32(point)) != want) failures.Add($"U+{point:X} alone should be {(want ? "accepted" : "refused")}");
|
|
||||||
foreach (var (low, high) in ignorable)
|
|
||||||
foreach (var (point, want) in new[] { (low, false), (high, false), (low - 1, true), (high + 1, true) })
|
|
||||||
if (Accepted("a" + char.ConvertFromUtf32(point)) != want) failures.Add($"a + U+{point:X} should be {(want ? "accepted" : "refused")}");
|
|
||||||
Assert.That(failures, Is.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
// admittedMessageIdDuplicates: C# reads the raw map, so it sees a repeated messageId key and refuses it whatever the values.
|
|
||||||
[Test]
|
|
||||||
public void ARepeatedMessageIdKeyIsRefusedWhateverTheValues()
|
|
||||||
{
|
|
||||||
var cases = Vectors().GetProperty("admittedMessageIdDuplicates").GetProperty("cases").EnumerateArray().ToArray();
|
|
||||||
Assert.That(cases.Length, Is.EqualTo(Vectors().GetProperty("counts").GetProperty("admittedMessageIdDuplicateCases").GetInt32()));
|
|
||||||
foreach (var vector in cases)
|
|
||||||
{
|
|
||||||
Assert.That(vector.GetProperty("seesDuplicate").GetString(), Is.EqualTo("refuse"));
|
|
||||||
Assert.That(() => CultNetSchemaMessageSerialization.Deserialize(Convert.FromHexString(vector.GetProperty("wireHex").GetString()!)),
|
|
||||||
Throws.Exception, vector.GetProperty("name").GetString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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<ICultNetSchemaMessage> _received = new();
|
|
||||||
public int Closed;
|
|
||||||
|
|
||||||
public Client(int port, string key)
|
|
||||||
{
|
|
||||||
var listener = new EventBasedNetListener();
|
|
||||||
var connected = new TaskCompletionSource<bool>(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>(T message) where T : ICultNetSchemaMessage =>
|
|
||||||
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.ReliableOrdered);
|
|
||||||
|
|
||||||
public void SendUnreliable<T>(T message) where T : ICultNetSchemaMessage =>
|
|
||||||
_peer.Send(CultNetSchemaMessageSerialization.Serialize(message), DeliveryMethod.Unreliable);
|
|
||||||
|
|
||||||
public void Send<T>(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<T>() where T : ICultNetSchemaMessage
|
|
||||||
{
|
|
||||||
if (!_received.TryTake(out var message, Cap)) Assert.Fail($"no {typeof(T).Name} arrived within {Cap}");
|
|
||||||
Assert.That(message, Is.InstanceOf<T>());
|
|
||||||
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<NetworkSchemaNote>().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<NetworkSchemaNote>(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<CultNetHelloMessage>();
|
|
||||||
|
|
||||||
client.SendRaw(garbage);
|
|
||||||
|
|
||||||
var error = client.Next<CultNetErrorV1Message>();
|
|
||||||
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<CultNetHelloMessage>();
|
|
||||||
receipt.SendRaw(new byte[] { 0xC1 });
|
|
||||||
receipt.Next<CultNetErrorV1Message>();
|
|
||||||
|
|
||||||
receipt.Send(running.PutOf("r1", KeyA, "one"));
|
|
||||||
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().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<CultNetErrorMessage>();
|
|
||||||
Assert.That(refusal, Is.TypeOf<CultNetErrorMessage>());
|
|
||||||
Assert.That(refusal.Code, Is.EqualTo("write_not_authorized"));
|
|
||||||
legacy.Send(running.PutOf("l2", KeyA, "three"));
|
|
||||||
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "three");
|
|
||||||
legacy.ExpectNothingMore("a legacy peer is never sent a receipt or error.v1");
|
|
||||||
Assert.That(running.Cache.Get<NetworkSchemaNote>(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<CultNetHelloMessage>();
|
|
||||||
|
|
||||||
client.SendUnreliable(running.PutOf("u1", KeyA, "lost"));
|
|
||||||
|
|
||||||
var error = client.Next<CultNetErrorV1Message>();
|
|
||||||
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<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("u1"));
|
|
||||||
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(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<CultNetHelloMessage>();
|
|
||||||
|
|
||||||
client.Send(running.PutOf("c1", KeyA, "text"), method);
|
|
||||||
|
|
||||||
if (refused)
|
|
||||||
{
|
|
||||||
var error = client.Next<CultNetErrorV1Message>();
|
|
||||||
Assert.That(error.Details!.Field, Is.EqualTo("channel"));
|
|
||||||
Assert.That(error.MessageId, Is.EqualTo("c1"));
|
|
||||||
Assert.That(running.Cache.Get<NetworkSchemaNote>(KeyA), Is.Null);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Assert.That(client.Next<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("c1"));
|
|
||||||
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(KeyA)?.Text == "text");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void ADeleteOnAnUnreliableChannelOfAReceiptPeerIsRefusedWithItsId()
|
|
||||||
{
|
|
||||||
using var running = Running.Start();
|
|
||||||
using var client = running.Connect();
|
|
||||||
client.Send(HelloOf(BothFrames));
|
|
||||||
client.Next<CultNetHelloMessage>();
|
|
||||||
|
|
||||||
client.SendUnreliable(new CultNetDocumentDeleteMessage
|
|
||||||
{
|
|
||||||
MessageId = "ud1", SchemaId = running.SchemaId, RecordKey = KeyA.Value, ShardId = ShardId, ShardEpoch = 1
|
|
||||||
});
|
|
||||||
|
|
||||||
var error = client.Next<CultNetErrorV1Message>();
|
|
||||||
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<CultNetHelloMessage>();
|
|
||||||
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<CultNetWriteAdmittedMessage>().MessageId, Is.EqualTo("r1"), "nothing else was sent ahead of the receipt");
|
|
||||||
receipt.Send(running.PutOf("r2", KeyA, "two"));
|
|
||||||
Assert.That(receipt.Next<CultNetWriteAdmittedMessage>().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<CultNetErrorMessage>().Code, Is.EqualTo("write_not_authorized"));
|
|
||||||
legacy.SendUnreliable(running.PutOf("l2", KeyA, "three"));
|
|
||||||
await WaitFor(() => running.Cache.Get<NetworkSchemaNote>(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<bool> 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -5189,9 +5189,7 @@ namespace GameCult.Networking.Tests
|
||||||
[Test]
|
[Test]
|
||||||
public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort()
|
public async Task CultNetSchemaWriteForwarder_Uses_SchemaClientPort()
|
||||||
{
|
{
|
||||||
var fake = new CapturingSchemaClient(message => message is CultNetDocumentDeleteMessage delete
|
var fake = new CapturingSchemaClient(_ => null);
|
||||||
? new CultNetWriteAdmittedMessage { MessageId = delete.MessageId, Operation = "delete", SchemaId = delete.SchemaId, RecordKey = delete.RecordKey }
|
|
||||||
: null);
|
|
||||||
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
|
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
|
||||||
{
|
{
|
||||||
CreateClient = () => fake
|
CreateClient = () => fake
|
||||||
|
|
@ -5205,16 +5203,11 @@ namespace GameCult.Networking.Tests
|
||||||
|
|
||||||
await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage
|
await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage
|
||||||
{
|
{
|
||||||
MessageId = "fwd-del",
|
|
||||||
SchemaId = "note.v0",
|
SchemaId = "note.v0",
|
||||||
RecordKey = "note:1"
|
RecordKey = "note:1"
|
||||||
});
|
});
|
||||||
|
|
||||||
var hello = (CultNetHelloMessage)fake.SentMessages[0];
|
var sent = (CultNetDocumentDeleteMessage)fake.SentMessages.Single();
|
||||||
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");
|
|
||||||
Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test"));
|
Assert.That(fake.ConnectedHost, Is.EqualTo("primary.example.test"));
|
||||||
Assert.That(sent.ShardId, Is.EqualTo("notes"));
|
Assert.That(sent.ShardId, Is.EqualTo("notes"));
|
||||||
Assert.That(sent.ShardEpoch, Is.EqualTo(3));
|
Assert.That(sent.ShardEpoch, Is.EqualTo(3));
|
||||||
|
|
@ -5248,7 +5241,7 @@ namespace GameCult.Networking.Tests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void CultNetSchemaWriteForwarder_SilenceIsUnconfirmedNotSuccess()
|
public async Task CultNetSchemaWriteForwarder_DoesNotCompleteOnTheSendReturning()
|
||||||
{
|
{
|
||||||
var fake = new CapturingSchemaClient(_ => null);
|
var fake = new CapturingSchemaClient(_ => null);
|
||||||
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
|
var forwarder = new CultNetSchemaWriteForwarder(new CultNetSchemaWriteForwarderOptions
|
||||||
|
|
@ -5261,51 +5254,11 @@ namespace GameCult.Networking.Tests
|
||||||
primaryEndpoints: ["cultnet://primary.example.test:4075"]);
|
primaryEndpoints: ["cultnet://primary.example.test:4075"]);
|
||||||
var started = DateTime.UtcNow;
|
var started = DateTime.UtcNow;
|
||||||
|
|
||||||
var unconfirmed = Assert.ThrowsAsync<CultNetWriteUnconfirmedException>(() =>
|
await forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { SchemaId = "note.v0", RecordKey = "note:1" });
|
||||||
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)));
|
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<CultNetWriteUnconfirmedException>(() =>
|
|
||||||
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<CultNetWriteRefusedException>(() =>
|
|
||||||
forwarder.ForwardDeleteAsync(shard, new CultNetDocumentDeleteMessage { MessageId = "fwd-del", SchemaId = "note.v0", RecordKey = "note:1" }));
|
|
||||||
|
|
||||||
Assert.That(refused!.Code, Is.EqualTo("write_not_authorized"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets()
|
public void CultMeshVerseCatalog_Finds_CompatibleTransferTargets()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue