From 6eea66c57f18b6e6a753c248f673c03e5ff0c699 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 14:59:59 +0000 Subject: [PATCH 01/10] cultnet-ts, cultmesh-ts: write receipt client (frames, WriteTracker, replay runner, opt-in writer) Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/CHANGELOG.md | 4 + packages/cultmesh-ts/src/index.ts | 95 +++++++-- .../test/write-receipt-client.test.ts | 139 ++++++++++++++ packages/cultnet-ts/CHANGELOG.md | 1 + packages/cultnet-ts/README.md | 27 +++ packages/cultnet-ts/src/contracts.ts | 43 +++++ packages/cultnet-ts/src/index.ts | 18 ++ packages/cultnet-ts/src/write-tracker.ts | 133 +++++++++++++ .../cultnet-ts/test/write-receipt.test.ts | 181 ++++++++++++++++++ .../tools/replay-write-receipt-vectors.mjs | 116 +++++++++++ 10 files changed, 740 insertions(+), 17 deletions(-) create mode 100644 packages/cultmesh-ts/test/write-receipt-client.test.ts create mode 100644 packages/cultnet-ts/src/write-tracker.ts create mode 100644 packages/cultnet-ts/test/write-receipt.test.ts create mode 100644 packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index d06e753b..f803476a 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ## 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, or `session_ended`) when nothing settles it. Without the option, or against a server that did not advertise, nothing changes: no hello, a 500 ms window, and a window with no refusal still reads as published (interim; not admission). `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. + ### Breaking - The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`. diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index f065dd83..eab2e6dc 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -20,6 +20,10 @@ import { CultNetRudpSocketTransportConnection, rudpClientBindHost, CultNetSchemaCatalog, + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, + WriteTracker, + helloAdvertisesWriteReceipts, + resultWindowMs, cultNetMessageInvalidError, cultNetTextError, cultNetWriteNotAuthorizedError, @@ -44,6 +48,8 @@ import { type CultNetSchemaCatalogOptions, type CultNetSnapshotResponseRawMessage, type CultNetWireContract, + type WriteSettlement, + type WriteUnconfirmedReason, } from "@gamecult/cultnet-ts"; import { @@ -4189,8 +4195,8 @@ export interface CultMeshRudpDocumentServer { */ export class CultMeshRudpWriteRefusedError extends Error { readonly code: string | undefined; - readonly response: Extract; - constructor(response: Extract) { + readonly response: Extract; + constructor(response: Extract) { super(response.error); this.name = "CultMeshRudpWriteRefusedError"; this.code = response.code ?? undefined; @@ -4198,14 +4204,40 @@ export class CultMeshRudpWriteRefusedError extends Error { } } +/** + * Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, or the + * session ended first. Not success and not refusal; a caller that needs admission treats it as not admitted. + */ +export class CultMeshRudpWriteUnconfirmedError extends Error { + readonly reason: WriteUnconfirmedReason; + readonly messageId: string; + constructor(messageId: string, reason: WriteUnconfirmedReason) { + super(`Write ${messageId} was not confirmed: ${reason}.`); + this.name = "CultMeshRudpWriteUnconfirmedError"; + this.reason = reason; + this.messageId = messageId; + } +} + export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions { /** - * How long after the put's transport ack a refusal may still arrive. A reliable-transport ack is not - * admission; the outcome is the peer's result frame. A server that answers a put only on refusal sends no - * success frame, so the put is reported published once this window passes with no refusal; a success reply - * ends the wait early. Default 500 ms. + * How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not + * admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the + * write-receipt frames, 500 ms otherwise. Against a server that did not advertise them the put is reported + * published once the window passes with no refusal (interim; the caller must not read it as admission), and + * a success reply ends the wait early. */ 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; messageId?: string; sourceRuntimeId?: string; sourceAgentId?: string; @@ -6182,17 +6214,42 @@ export class CultMesh { const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { - // A transport ack is not admission: the put completes on its result frame. A refusal rejects with the - // typed error whether it arrives before the ack, after it, or inside the result window. - let refusal: CultMeshRudpWriteRefusedError | undefined; - let succeeded = false; + // A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides + // how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw. + let receiptCapable = false; + let tracker: WriteTracker | undefined; + let settlement: WriteSettlement | undefined; let wake: (() => void) | undefined; + let serverHello: (() => void) | undefined; peer.on("message", (message: CultNetMessage) => { - if (message.schemaVersion === "cultnet.error.v0") refusal ??= new CultMeshRudpWriteRefusedError(message); - else if ("messageId" in message && message.messageId === messageId) succeeded = true; - else return; - wake?.(); + if (message.schemaVersion === "cultnet.hello.v0") { + receiptCapable = helloAdvertisesWriteReceipts(message); + serverHello?.(); + } else if (!receiptCapable && "messageId" in message && message.messageId === messageId + && message.schemaVersion !== "cultnet.error.v1") { + // A server that did not advertise receipts may echo the id back as its success reply. + tracker?.frame({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: binding.definition.schemaId ?? "", recordKey }, Date.now()); + } else { + tracker?.frame(message, Date.now()); + } }); + peer.on("close", () => tracker?.sessionEnd(Date.now())); + if (options.writeReceipts) { + const answered = new Promise((resolve) => { serverHello = resolve; }); + peer.sendHello({ + schemaVersion: "cultnet.hello.v0", runtimeId, runtimeKind: "cultmesh-ts", + supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS], + }); + let helloTimer: NodeJS.Timeout | undefined; + await Promise.race([answered, new Promise((resolve) => { helloTimer = setTimeout(resolve, options.helloWaitMs ?? 500); })]); + clearTimeout(helloTimer); + } + const writeTracker = new WriteTracker({ + windowMs: options.resultWindowMs ?? resultWindowMs(receiptCapable), + onSettle: (_id, result) => { settlement = result; wake?.(); }, + }); + tracker = writeTracker; + writeTracker.send(messageId, Date.now()); peer.send(registry.createRawDocumentPutMessage( binding, messageId, @@ -6206,13 +6263,17 @@ export class CultMesh { }, )); await peer.flush(options.flushTimeoutMs ?? 30_000); - if (!refusal && !succeeded) { + while (!settlement) { + const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; await new Promise((resolve) => { - const timer = setTimeout(resolve, options.resultWindowMs ?? 500); + const timer = setTimeout(resolve, remaining); wake = () => { clearTimeout(timer); resolve(); }; }); + writeTracker.tick(Date.now()); } - if (refusal) throw refusal; + if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response); + // Interim, for a server that did not advertise receipts: a window with no refusal reads as published. + if (settlement.outcome === "unconfirmed" && receiptCapable) throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); } finally { peer.close(); } diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts new file mode 100644 index 00000000..ddc3d41e --- /dev/null +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -0,0 +1,139 @@ +// 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"; + delayMs?: number; +} + +async function fakeServer(connectionId: number, script: Script) { + const socket: Socket = createSocket("udp4"); + await new Promise((resolve) => socket.bind(0, "127.0.0.1", resolve)); + const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 }); + const timers = new Set(); + const seen: string[] = []; + let closed = false; + const send = (packet: Parameters[0], port: number, address: string) => { + if (!closed) socket.send(encodeRudpPacket(packet), port, address); + }; + const say = (message: CultNetMessage, port: number, address: string) => { + const packets = session.sendMany("schema", encodeWireValue(encodeCultNetMessageForWire(message)), { reliable: true, ordered: true, nowMs: Date.now() }); + packets.forEach((p) => send(p, port, address)); + }; + socket.on("message", (wire, remote) => { + const packet = decodeRudpPacket(wire); + const now = Date.now(); + if (packet.packetType === "connect") { + send(session.acceptConnect(packet, now), remote.port, remote.address); + return; + } + const result = session.receive(packet, now); + if (result.reply) send(result.reply, remote.port, remote.address); + for (const frame of result.delivered) { + if (frame.channelId !== "schema") continue; + const message = decodeWireValue(frame.payload) as { schemaVersion: string; messageId?: string }; + seen.push(message.schemaVersion); + if (message.schemaVersion === "cultnet.hello.v0") { + say({ + schemaVersion: "cultnet.hello.v0", runtimeId: "fake", runtimeKind: "fake", + ...(script.advertise ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}), + }, remote.port, remote.address); + continue; + } + if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue; + const timer = setTimeout(() => { + timers.delete(timer); + if (script.answer === "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) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); + }); + return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, seen, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } }; +} + +const transport = { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 }; +const publish = (server: { endpoint: string }, options: Record) => + CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: "a", text: "x" }, { ...transport, ...options }); + +test("a receipt-capable server's write_admitted completes the put, past the legacy 500 ms window", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", delayMs: 700 }); + try { + await publish(server, { writeReceipts: true }); + assert.deepEqual(server.seen.slice(0, 2), ["cultnet.hello.v0", "cultnet.document_put_raw.v0"]); + } finally { server.close(); } +}); + +test("a receipt-capable server's error.v1 refuses the put with its code", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "refuse" }); + try { + await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) => + error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized" && error.response.schemaVersion === "cultnet.error.v1"); + } finally { server.close(); } +}); + +test("a receipt-capable server that stays silent leaves the put unconfirmed, never published", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" }); + try { + await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed"); + } finally { server.close(); } +}); + +test("a receipt for another id settles nothing", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "wrong-id" }); + try { + await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), CultMeshRudpWriteUnconfirmedError); + } finally { server.close(); } +}); + +test("a server whose hello does not advertise keeps the interim 500 ms window-success", async () => { + const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); + const started = Date.now(); + try { + await publish(server, { writeReceipts: true }); + assert.ok(Date.now() - started >= 500, "the legacy window ran from the send"); + } finally { server.close(); } +}); + +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 publish(server, { resultWindowMs: 100 }); + assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0"]); + } finally { server.close(); } +}); diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 739396cd..13dc3dff 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following ### Added +- Write receipt, client side (`contracts/cultnet/interop/write-receipt.md`): `cultnet.write_admitted.v0` and `cultnet.error.v1` (v0 plus `messageId`; its closed code set adds `limit_reached`, `CULTNET_ERROR_V1_CODES`) parse and encode, and `CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS` / `helloAdvertisesWriteReceipts` name the hello capability. `cultnet.error.v0` and its code set are unchanged. `WriteTracker` settles each write as admitted, refused or unconfirmed (windows `RECEIPT_RESULT_WINDOW_MS` 5000 and `LEGACY_RESULT_WINDOW_MS` 500, counted from the send; late answers recorded as `admitted_late` / `refused_late`), and `tools/replay-write-receipt-vectors.mjs` replays the vectors against it (see README, "Write receipt replay runner"). The interop schemas are read from `contracts/cultnet/interop/`. - `message_invalid` joins the closed `cultnet.error.v0` code set, with `cultNetMessageInvalidError(field, reason)` (`details.field` names the request field at fault), byte-identical to the C# `ForMessageInvalid`. - `cultNetWriteNotAuthorizedError(recordKey, reason?)`: the typed `write_not_authorized` refusal (code and `details.recordKey`), byte-identical to the C# `ForRemoteWriteRefused` vector in `error-vectors.json`. `CultNetDocumentRegistry.canonicalSchemaId(schemaId)` resolves the id a put or delete writes under, for a server's write authority. - `cultNetTextError(error)`: the text-only `cultnet.error.v0` refusal, with `routingHint`, `code` and `details` null. The legacy-union error decode uses it, so there is one place that spells the null members. diff --git a/packages/cultnet-ts/README.md b/packages/cultnet-ts/README.md index 1fb37a3f..07996956 100644 --- a/packages/cultnet-ts/README.md +++ b/packages/cultnet-ts/README.md @@ -289,6 +289,33 @@ maps them back to the exact C# union payload shape at the wire boundary. serve and dial commands also exercise schema-v0 over the shared RUDP socket transport and advertise both transport profiles. +## Write receipt replay runner + +`WriteTracker` is the client half of `contracts/cultnet/interop/write-receipt.md`: a pure state machine on an abstract +clock that owns one decision, how each sent write settles. `tools/replay-write-receipt-vectors.mjs` replays +`write-receipt-vectors.json` against it (build first, then `node tools/replay-write-receipt-vectors.mjs`): it prints +`PASS`/`FAIL` per case and exits nonzero on any failure. Rust and C# port the same runner by implementing this interface +and replaying the same file. + +Tracker interface (times are integer milliseconds on the vector's clock; the constructor takes the window): + +| Call | Meaning | +| --- | --- | +| `new WriteTracker({ windowMs, onSettle })` | `windowMs` = 5000 when the server's hello lists both `cultnet.write_admitted.v0` and `cultnet.error.v1`, else 500. `onSettle(id, settlement)` fires once per write. | +| `send(id, atMs)` | A write with that `messageId` was sent. Its window runs from here, not from any transport ack. | +| `frame(message, atMs)` | A decoded frame arrived. `write_admitted` and `error.v1` settle the write they name; `error.v0`, or `error.v1` with nil `messageId`, refuses a write only when exactly one is unanswered; an id not in flight settles nothing. | +| `tick(atMs)` | Advance the clock. Every unanswered write with `atMs - sentAt >= windowMs` settles `unconfirmed(window_elapsed)`. `send`, `frame` and `sessionEnd` tick first. | +| `sessionEnd(atMs)` | Every unanswered write settles `unconfirmed(session_ended)`. | +| `state(id)` | `pending`, or the settlement plus `recorded`: `admitted_late` / `refused_late`, set by the first answer that arrives after `unconfirmed`. A settlement never changes. | + +Runner, per case in `scenarios.cases` (the transport ack is not an input: delivery is not an answer): + +1. The fake server states its capability the only way a real one does, in a hello (`server: "receipt"` lists both frames, `"legacy"` lists none); the window comes from that hello and must equal `constants[case.window]`. +2. Events run in `atMs` order: `send` -> `send(id)`, `frame` -> encode the frame to MessagePack, decode and validate it as the client would, then `frame(message)`, `tick` -> `tick`, `sessionEnd` -> `sessionEnd`, `transportAck` -> nothing. +3. For each id in `expect`, the settlement given to the caller has the expected `outcome` (and `code` or `reason` when given), `state(id).recorded` equals the expected `recorded` (absent when absent), and nothing settled twice or outside `expect`. + +The runner also decodes each `wire[].messagePackHex` and compares the fields and the re-encoded bytes to the vector, and requires every `schemaInvalid[]` frame to be refused by the decoder. `dedupe.cases` and `contentHash` are server vectors and not replayed here. + ## Development ```bash diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index d4bc1b19..6e05c9a5 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -9,6 +9,8 @@ import registerSchema from "../contracts/cultnet/cultnet.register.schema.json"; import verifySchema from "../contracts/cultnet/cultnet.verify.schema.json"; import loginSuccessSchema from "../contracts/cultnet/cultnet.login-success.schema.json"; import errorSchema from "../contracts/cultnet/cultnet.error.schema.json"; +import errorV1Schema from "../contracts/cultnet/interop/cultnet.error.v1.schema.json"; +import writeAdmittedSchema from "../contracts/cultnet/interop/cultnet.write-admitted.schema.json"; import sampleChangeNameSchema from "../contracts/cultnet/cultnet.sample-change-name.schema.json"; import sampleChatSchema from "../contracts/cultnet/cultnet.sample-chat.schema.json"; import documentPutSchema from "../contracts/cultnet/cultnet.document-put.schema.json"; @@ -62,6 +64,8 @@ export type CultNetSchemaVersion = | "cultnet.verify.v0" | "cultnet.login_success.v0" | "cultnet.error.v0" + | "cultnet.error.v1" + | "cultnet.write_admitted.v0" | "cultnet.sample.change_name.v0" | "cultnet.sample.chat.v0" | "cultnet.document_put.v0" @@ -201,6 +205,11 @@ export const CULTNET_ERROR_CODES = [ export type CultNetErrorCode = (typeof CULTNET_ERROR_CODES)[number]; +/** The closed refusal-code set of cultnet.error.v1 (contracts/cultnet/interop/cultnet.error.v1.schema.json): v0's codes plus `limit_reached`. */ +export const CULTNET_ERROR_V1_CODES = [...CULTNET_ERROR_CODES, "limit_reached"] as const; + +export type CultNetErrorV1Code = (typeof CULTNET_ERROR_V1_CODES)[number]; + /** A refusal that carries text only: `routingHint`, `code` and `details` are written as null, as the reference writes an absent member. */ export function cultNetTextError(error: string): CultNetErrorMessage { return { schemaVersion: "cultnet.error.v0", error, routingHint: null, code: null, details: null }; @@ -255,6 +264,34 @@ export interface CultNetErrorMessage { details: CultNetErrorDetails | null; } +/** + * cultnet.error.v0 plus the `messageId` of the request it answers (nil when no id could be read). Sent only to a + * connection whose hello listed both receipt frames (contracts/cultnet/interop/write-receipt.md). + */ +export interface CultNetErrorV1Message extends Omit { + schemaVersion: "cultnet.error.v1"; + code: CultNetErrorV1Code | null; + messageId: string | null; +} + +/** The receipt for one admitted put or delete: every key written, the write's `messageId` echoed byte for byte. */ +export interface CultNetWriteAdmittedMessage { + schemaVersion: "cultnet.write_admitted.v0"; + messageId: string; + operation: "put" | "delete"; + schemaId: string; + recordKey: string; +} + +/** The two frames a receipt-capable hello lists in `supportedMessageVersions`; a peer lists both or neither. */ +export const CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS = ["cultnet.write_admitted.v0", "cultnet.error.v1"] as const; + +/** True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable. */ +export function helloAdvertisesWriteReceipts(hello: Pick): boolean { + const listed = hello.supportedMessageVersions ?? []; + return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.every((version) => listed.includes(version)); +} + export interface CultNetSampleChangeNameMessage { schemaVersion: "cultnet.sample.change_name.v0"; name: string; @@ -770,6 +807,8 @@ export interface GhostlightAgentStateDocument { export type CultNetMessage = | CultNetHelloMessage + | CultNetErrorV1Message + | CultNetWriteAdmittedMessage | CultNetLoginMessage | CultNetRegisterMessage | CultNetVerifyMessage @@ -812,6 +851,8 @@ const CULTNET_MESSAGE_SCHEMAS = [ verifySchema, loginSuccessSchema, errorSchema, + errorV1Schema, + writeAdmittedSchema, sampleChangeNameSchema, sampleChatSchema, documentPutSchema, @@ -1535,6 +1576,8 @@ export const cultNetSchemas = { verifySchema, loginSuccessSchema, errorSchema, + errorV1Schema, + writeAdmittedSchema, sampleChangeNameSchema, sampleChatSchema, documentPutSchema, diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 4fd0014c..a8740cae 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -17,6 +17,12 @@ export { type CultNetRawDocumentRecord, type CultNetRawPayloadEncoding, CULTNET_ERROR_CODES, + CULTNET_ERROR_V1_CODES, + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, + helloAdvertisesWriteReceipts, + type CultNetErrorV1Code, + type CultNetErrorV1Message, + type CultNetWriteAdmittedMessage, cultNetTextError, cultNetMessageInvalidError, cultNetWriteNotAuthorizedError, @@ -249,3 +255,15 @@ export { type IdunnRuntimePresencePublisher, type IdunnRuntimePresencePublisherOptions, } from "./idunn-odin-presence-publisher"; + +export { + LEGACY_RESULT_WINDOW_MS, + RECEIPT_RESULT_WINDOW_MS, + WriteTracker, + resultWindowMs, + type WriteRecorded, + type WriteSettlement, + type WriteState, + type WriteTrackerOptions, + type WriteUnconfirmedReason, +} from "./write-tracker"; diff --git a/packages/cultnet-ts/src/write-tracker.ts b/packages/cultnet-ts/src/write-tracker.ts new file mode 100644 index 00000000..7398afaa --- /dev/null +++ b/packages/cultnet-ts/src/write-tracker.ts @@ -0,0 +1,133 @@ +// 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; +} + +export type WriteUnconfirmedReason = "window_elapsed" | "session_ended"; +/** What a late answer is recorded as on a write that already settled unconfirmed. */ +export type WriteRecorded = "admitted_late" | "refused_late"; + +/** What the caller was told. Settled once; never changed by a later frame. */ +export type WriteSettlement = + | { outcome: "admitted" } + | { outcome: "refused"; code: string | null; error: string; response: CultNetErrorMessage | CultNetErrorV1Message } + | { outcome: "unconfirmed"; reason: WriteUnconfirmedReason }; + +/** A write's state: unanswered, or its settlement plus (after unconfirmed only) the late answer. */ +export type WriteState = + | { outcome: "pending"; recorded?: undefined } + | (WriteSettlement & { recorded?: WriteRecorded }); + +export interface WriteTrackerOptions { + /** The result window, from the send. Use `resultWindowMs(serverAdvertisesReceipts)`. */ + windowMs: number; + /** Called once per write, at the moment it settles. */ + onSettle?: (messageId: string, settlement: WriteSettlement) => void; +} + +interface Entry { + sentAtMs: number; + state: WriteState; +} + +export class WriteTracker { + readonly #windowMs: number; + readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void; + readonly #writes = new Map(); + + constructor(options: WriteTrackerOptions) { + this.#windowMs = options.windowMs; + this.#onSettle = options.onSettle; + } + + /** Registers a write sent at `atMs`. A messageId is minted once per session: re-sending a known id is the same write. */ + send(messageId: string, atMs: number): void { + this.tick(atMs); + if (!this.#writes.has(messageId)) this.#writes.set(messageId, { sentAtMs: atMs, state: { outcome: "pending" } }); + } + + /** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */ + frame(message: CultNetMessage, atMs: number): void { + this.tick(atMs); + switch (message.schemaVersion) { + case "cultnet.write_admitted.v0": + this.#answer(message.messageId, { outcome: "admitted" }, "admitted_late"); + return; + case "cultnet.error.v1": + if (message.messageId !== null) { + this.#answer(message.messageId, refusalOf(message), "refused_late"); + return; + } + break; + case "cultnet.error.v0": + break; + default: + return; + } + // An error that names no write is attributable only when exactly one write is unanswered. + const unanswered = [...this.#writes].filter(([, entry]) => entry.state.outcome === "pending"); + if (unanswered.length === 1) this.#settle(unanswered[0][0], unanswered[0][1], refusalOf(message)); + } + + /** Advances the clock: every unanswered write whose window, counted from its send, has run settles unconfirmed. */ + tick(atMs: number): void { + for (const [messageId, entry] of this.#writes) { + if (entry.state.outcome === "pending" && atMs - entry.sentAtMs >= this.#windowMs) { + this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "window_elapsed" }); + } + } + } + + /** The session ended: every unanswered write settles unconfirmed. */ + sessionEnd(atMs: number): void { + this.tick(atMs); + for (const [messageId, entry] of this.#writes) { + if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" }); + } + } + + state(messageId: string): WriteState | undefined { + return this.#writes.get(messageId)?.state; + } + + /** Milliseconds from `atMs` until the next unanswered write's window runs out; undefined when none is unanswered. */ + nextDeadlineMs(atMs: number): number | undefined { + let next: number | undefined; + for (const entry of this.#writes.values()) { + if (entry.state.outcome !== "pending") continue; + const remaining = Math.max(0, entry.sentAtMs + this.#windowMs - atMs); + if (next === undefined || remaining < next) next = remaining; + } + return next; + } + + #answer(messageId: string, settlement: WriteSettlement, late: WriteRecorded): void { + const entry = this.#writes.get(messageId); + if (!entry) return; // names a write that is not in flight: settles nothing + if (entry.state.outcome === "pending") { + this.#settle(messageId, entry, settlement); + } else if (entry.state.outcome === "unconfirmed" && entry.state.recorded === undefined) { + entry.state = { ...entry.state, recorded: late }; + } + } + + #settle(messageId: string, entry: Entry, settlement: WriteSettlement): void { + entry.state = settlement; + this.#onSettle?.(messageId, settlement); + } +} + +function refusalOf(message: CultNetErrorMessage | CultNetErrorV1Message): WriteSettlement { + return { outcome: "refused", code: message.code, error: message.error, response: message }; +} diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts new file mode 100644 index 00000000..cc524bfd --- /dev/null +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -0,0 +1,181 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; + +import { + CULTNET_ERROR_CODES, + CULTNET_ERROR_V1_CODES, + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, + LEGACY_RESULT_WINDOW_MS, + RECEIPT_RESULT_WINDOW_MS, + WriteTracker, + cultNetSchemas, + helloAdvertisesWriteReceipts, + parseCultNetMessage, + resultWindowMs, + type CultNetErrorV1Message, + type CultNetMessage, + type CultNetWriteAdmittedMessage, + type WriteSettlement, +} from "../src"; + +const admitted = (messageId: string): CultNetWriteAdmittedMessage => + ({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "note.v0", recordKey: "note:1" }); +const refusedV1 = (messageId: string | null, code: CultNetErrorV1Message["code"] = "write_not_authorized"): CultNetErrorV1Message => + ({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code, details: null, messageId }); +const errorV0 = (): CultNetMessage => + ({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }); + +test("error.v1 accepts every code of its closed enum, limit_reached included, and refuses any other", () => { + const schemaCodes = ((cultNetSchemas.errorV1Schema.properties.code as { enum: (string | null)[] }).enum).filter((c) => c !== null); + assert.deepEqual([...CULTNET_ERROR_V1_CODES].sort(), [...schemaCodes].sort()); + for (const code of CULTNET_ERROR_V1_CODES) parseCultNetMessage({ ...refusedV1("w1"), code }); + assert.throws(() => parseCultNetMessage({ ...refusedV1("w1"), code: "invented" })); +}); + +test("error.v1 echoes messageId byte for byte, allows nil, and requires the key", () => { + assert.equal((parseCultNetMessage(refusedV1("w-é 1")) as CultNetErrorV1Message).messageId, "w-é 1"); + assert.equal((parseCultNetMessage(refusedV1(null)) as CultNetErrorV1Message).messageId, null); + const { messageId: _omitted, ...withoutId } = refusedV1("w1"); + assert.throws(() => parseCultNetMessage(withoutId)); + assert.throws(() => parseCultNetMessage(refusedV1(""))); +}); + +test("error.v0 is unaffected: still closed, no messageId, no limit_reached", () => { + assert.deepEqual([...CULTNET_ERROR_CODES].includes("limit_reached" as never), false); + assert.throws(() => parseCultNetMessage({ ...errorV0(), code: "limit_reached" })); + assert.throws(() => parseCultNetMessage({ ...errorV0(), messageId: "w1" })); + assert.equal(parseCultNetMessage(errorV0()).schemaVersion, "cultnet.error.v0"); +}); + +test("write_admitted needs all five keys, a known operation and no error key", () => { + parseCultNetMessage(admitted("w1")); + parseCultNetMessage({ ...admitted("w1"), operation: "delete" }); + assert.throws(() => parseCultNetMessage({ ...admitted("w1"), operation: "upsert" })); + assert.throws(() => parseCultNetMessage({ ...admitted("w1"), error: "nope" })); + const { recordKey: _omitted, ...short } = admitted("w1"); + assert.throws(() => parseCultNetMessage(short)); +}); + +test("a hello is receipt-capable only when it lists both frames", () => { + assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }), true); + assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.write_admitted.v0"] }), false); + assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.error.v1"] }), false); + assert.equal(helloAdvertisesWriteReceipts({}), false); + const hello = parseCultNetMessage({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k", supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }); + assert.equal(hello.schemaVersion, "cultnet.hello.v0"); +}); + +test("the window is 5000 ms for a receipt-capable server and 500 ms otherwise", () => { + assert.equal(resultWindowMs(true), RECEIPT_RESULT_WINDOW_MS); + assert.equal(resultWindowMs(false), LEGACY_RESULT_WINDOW_MS); + assert.equal(RECEIPT_RESULT_WINDOW_MS, 5000); + assert.equal(LEGACY_RESULT_WINDOW_MS, 500); +}); + +function tracker(windowMs = 500) { + const told: [string, WriteSettlement][] = []; + return { told, t: new WriteTracker({ windowMs, onSettle: (id, s) => told.push([id, s]) }) }; +} + +test("tracker: the window is inclusive at exactly windowMs and open one ms before", () => { + const { t, told } = tracker(); + t.send("w1", 100); + t.tick(599); + assert.equal(t.state("w1")?.outcome, "pending"); + t.tick(600); + assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]); +}); + +test("tracker: each write's window runs from its own send", () => { + const { t } = tracker(); + t.send("w1", 0); + t.send("w2", 300); + t.tick(500); + assert.equal(t.state("w1")?.outcome, "unconfirmed"); + assert.equal(t.state("w2")?.outcome, "pending"); +}); + +test("tracker: a frame arriving after the window cannot admit the write; it is recorded late", () => { + const { t, told } = tracker(); + t.send("w1", 0); + t.frame(admitted("w1"), 500); + assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed", recorded: "admitted_late" }); + assert.equal(told.length, 1); +}); + +test("tracker: a write settles once; later frames neither change it nor flip a late record", () => { + const { t, told } = tracker(); + t.send("w1", 0); + t.frame(refusedV1("w1"), 10); + t.frame(admitted("w1"), 20); + t.frame(refusedV1("w1", "message_invalid"), 30); + t.sessionEnd(40); + assert.equal(told.length, 1); + assert.deepEqual(told[0][1], { outcome: "refused", code: "write_not_authorized", error: "no", response: refusedV1("w1") }); + t.send("w2", 100); + t.tick(600); + t.frame(refusedV1("w2"), 700); + t.frame(admitted("w2"), 800); + assert.equal(t.state("w2")?.outcome === "unconfirmed" && t.state("w2")?.recorded, "refused_late"); +}); + +test("tracker: an id-less error refuses the single unanswered write only, counting answered writes out", () => { + const { t } = tracker(); + t.send("w1", 0); + t.send("w2", 1); + t.frame(admitted("w1"), 5); + t.frame(refusedV1(null), 6); + assert.equal(t.state("w1")?.outcome, "admitted"); + assert.equal(t.state("w2")?.outcome, "refused"); +}); + +test("tracker: an id-less error never reaches a write that already ended unconfirmed", () => { + const { t } = tracker(); + t.send("w1", 0); + t.tick(500); + t.frame(errorV0(), 501); + assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); +}); + +test("tracker: sessionEnd settles unanswered writes only, and an expired window keeps its own reason", () => { + const { t } = tracker(); + t.send("w1", 0); + t.send("w2", 0); + t.send("w3", 400); + t.frame(admitted("w2"), 10); + t.sessionEnd(600); + assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); + assert.equal(t.state("w2")?.outcome, "admitted"); + assert.deepEqual(t.state("w3"), { outcome: "unconfirmed", reason: "session_ended" }); +}); + +test("tracker: re-sending a known id is the same write and does not restart its window", () => { + const { t } = tracker(); + t.send("w1", 0); + t.send("w1", 400); + t.tick(500); + assert.equal(t.state("w1")?.outcome, "unconfirmed"); +}); + +test("tracker: 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); +}); diff --git a/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs b/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs new file mode 100644 index 00000000..2c4bb403 --- /dev/null +++ b/packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Replays contracts/cultnet/interop/write-receipt-vectors.json against this runtime: every `scenarios.cases` event +// replay through a fake server and the WriteTracker, and every `wire` / `schemaInvalid` frame through the real +// decoder. Prints PASS/FAIL per case; exit 1 on any failure. Runs against the built package (`npm run build` first). +// +// node tools/replay-write-receipt-vectors.mjs [path/to/write-receipt-vectors.json] +// +// The runner interface a port (Rust, C#) must reproduce is documented in README.md, "Write receipt replay runner". + +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const here = path.dirname(fileURLToPath(import.meta.url)); +const { decode, encode } = require("@msgpack/msgpack"); +const { + CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS, LEGACY_RESULT_WINDOW_MS, RECEIPT_RESULT_WINDOW_MS, WriteTracker, + encodeCultNetMessageForWire, helloAdvertisesWriteReceipts, parseCultNetMessage, resultWindowMs, +} = require(path.join(here, "..", "dist", "index.js")); + +const vectorsPath = process.argv[2] ?? path.join(here, "..", "..", "..", "contracts", "cultnet", "interop", "write-receipt-vectors.json"); +const vectors = JSON.parse(readFileSync(vectorsPath, "utf8")); + +const results = []; +const check = (name, run) => { + try { + const problems = run(); + results.push({ name, problems }); + } catch (error) { + results.push({ name, problems: [`threw: ${error instanceof Error ? error.message : String(error)}`] }); + } +}; +const bytesOf = (hex) => Uint8Array.from(Buffer.from(hex, "hex")); +const hexOf = (bytes) => Buffer.from(bytes).toString("hex").toUpperCase(); +// A frame reaches the client as bytes: the fake server encodes it, the client decodes and validates it. +const delivered = (frame) => parseCultNetMessage(decode(encode(frame))); + +check("constants", () => [ + ...(vectors.constants.RECEIPT_RESULT_WINDOW_MS === RECEIPT_RESULT_WINDOW_MS ? [] : [`RECEIPT window ${RECEIPT_RESULT_WINDOW_MS} != ${vectors.constants.RECEIPT_RESULT_WINDOW_MS}`]), + ...(vectors.constants.LEGACY_RESULT_WINDOW_MS === LEGACY_RESULT_WINDOW_MS ? [] : [`LEGACY window ${LEGACY_RESULT_WINDOW_MS} != ${vectors.constants.LEGACY_RESULT_WINDOW_MS}`]), +]); + +for (const vector of vectors.wire) { + check(`wire/${vector.name}`, () => { + const problems = []; + const parsed = parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); + if (JSON.stringify(sorted(parsed)) !== JSON.stringify(sorted(vector.message))) problems.push(`decoded ${JSON.stringify(parsed)} != ${JSON.stringify(vector.message)}`); + const reencoded = hexOf(encode(encodeCultNetMessageForWire(parsed))); + if (reencoded !== vector.messagePackHex.toUpperCase()) problems.push(`re-encoded bytes ${reencoded} != captured ${vector.messagePackHex}`); + return problems; + }); +} + +for (const vector of vectors.schemaInvalid) { + check(`schemaInvalid/${vector.name}`, () => { + try { + parseCultNetMessage(decode(bytesOf(vector.messagePackHex))); + } catch { + return []; + } + return [`accepted a frame that must be refused (${vector.why})`]; + }); +} + +for (const scenario of vectors.scenarios.cases) { + check(`scenario/${scenario.name}`, () => { + // The fake server states its capability the only way a real one does: in its hello. + const serverHello = { schemaVersion: "cultnet.hello.v0", runtimeId: "fake-server", runtimeKind: "fake", + ...(scenario.server === "receipt" ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}) }; + const windowMs = resultWindowMs(helloAdvertisesWriteReceipts(serverHello)); + const problems = []; + if (windowMs !== vectors.constants[scenario.window]) problems.push(`window ${windowMs} != ${scenario.window}`); + + const told = new Map(); // what the caller was told, at settle time + const tracker = new WriteTracker({ windowMs, onSettle: (id, settlement) => { + if (told.has(id)) problems.push(`${id} settled twice`); + told.set(id, settlement); + } }); + for (const event of [...scenario.events].sort((a, b) => a.atMs - b.atMs)) { + if ("send" in event) tracker.send(event.send, event.atMs); + else if ("frame" in event) tracker.frame(delivered(event.frame), event.atMs); + else if ("tick" in event) tracker.tick(event.atMs); + else if ("sessionEnd" in event) tracker.sessionEnd(event.atMs); + // transportAck: delivery is not an answer; the tracker has no such input and must not need one. + } + for (const [id, want] of Object.entries(scenario.expect)) { + const got = told.get(id); + if (!got) { problems.push(`${id}: never settled, wanted ${want.outcome}`); continue; } + if (got.outcome !== want.outcome) problems.push(`${id}: told ${got.outcome}, wanted ${want.outcome}`); + if (want.code !== undefined && got.code !== want.code) problems.push(`${id}: code ${got.code}, wanted ${want.code}`); + if (want.reason !== undefined && got.reason !== want.reason) problems.push(`${id}: reason ${got.reason}, wanted ${want.reason}`); + const recorded = tracker.state(id)?.recorded; + if (recorded !== want.recorded) problems.push(`${id}: recorded ${recorded}, wanted ${want.recorded}`); + } + for (const id of told.keys()) if (!(id in scenario.expect)) problems.push(`${id}: settled but the scenario expects nothing for it`); + return problems; + }); +} + +function sorted(value) { + if (Array.isArray(value)) return value.map(sorted); + if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sorted(value[k])])); + return value; +} + +let failed = 0; +for (const { name, problems } of results) { + if (problems.length === 0) { console.log(`PASS ${name}`); continue; } + failed++; + console.log(`FAIL ${name}`); + for (const problem of problems) console.log(` ${problem}`); +} +console.log(`${results.length - failed}/${results.length} passed`); +process.exit(failed === 0 ? 0 : 1); -- 2.47.3 From a67faad27c912130dc355afb8c221bbc9b5cc5a1 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 15:04:57 +0000 Subject: [PATCH 02/10] cultnet-ts: tests for two tracker mutants (send expires other windows; non-answer frames settle nothing) Co-Authored-By: Claude Sonnet 5.5 --- packages/cultnet-ts/test/write-receipt.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts index cc524bfd..66b80601 100644 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -159,6 +159,21 @@ test("tracker: re-sending a known id is the same write and does not restart its 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); -- 2.47.3 From 293b4e83bc3e379a0f52c8bfcd8025d374851b03 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 16:40:52 +0000 Subject: [PATCH 03/10] cultmesh-ts, cultnet-ts: an unanswered put is unconfirmed on every server, session_ended before a result is an error, a failed local send is its own reason Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/CHANGELOG.md | 2 +- packages/cultmesh-ts/src/index.ts | 44 +++++++++--------- .../test/client-write-completion.test.ts | 11 +++-- packages/cultmesh-ts/test/cultmesh.test.ts | 45 ++++++++++--------- .../test/write-receipt-client.test.ts | 35 ++++++++++++--- packages/cultnet-ts/src/write-tracker.ts | 13 +++++- .../cultnet-ts/test/write-receipt.test.ts | 26 +++++++++++ 7 files changed, 124 insertions(+), 52 deletions(-) diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index f803476a..e7b432a5 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ### 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, or `session_ended`) when nothing settles it. Without the option, or against a server that did not advertise, nothing changes: no hello, a 500 ms window, and a window with no refusal still reads as published (interim; not admission). `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. +- `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). The legacy success reply (the put's id echoed) still admits it. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. ### Breaking diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index eab2e6dc..43a44c9b 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4205,8 +4205,8 @@ export class CultMeshRudpWriteRefusedError extends Error { } /** - * Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, or the - * session ended first. Not success and not refusal; a caller that needs admission treats it as not admitted. + * 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; @@ -4223,9 +4223,9 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti /** * How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not * admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the - * write-receipt frames, 500 ms otherwise. Against a server that did not advertise them the put is reported - * published once the window passes with no refusal (interim; the caller must not read it as admission), and - * a success reply ends the wait early. + * write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never + * published, whatever the server advertised; a legacy server's success reply (the put's id echoed back) ends + * the wait early as admitted. */ resultWindowMs?: number; /** @@ -6250,19 +6250,24 @@ export class CultMesh { }); tracker = writeTracker; writeTracker.send(messageId, Date.now()); - peer.send(registry.createRawDocumentPutMessage( - binding, - messageId, - recordKey, - value, - { - sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, - sourceAgentId: options.sourceAgentId, - sourceRole: options.sourceRole, - tags: options.tags, - }, - )); - await peer.flush(options.flushTimeoutMs ?? 30_000); + try { + peer.send(registry.createRawDocumentPutMessage( + binding, + messageId, + recordKey, + value, + { + sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, + sourceAgentId: options.sourceAgentId, + sourceRole: options.sourceRole, + tags: options.tags, + }, + )); + await peer.flush(options.flushTimeoutMs ?? 30_000); + } catch { + // Our own send failed or was never acknowledged: the put may or may not have left. Never a refusal. + writeTracker.sendFailed(messageId, Date.now()); + } while (!settlement) { const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0; await new Promise((resolve) => { @@ -6272,8 +6277,7 @@ export class CultMesh { writeTracker.tick(Date.now()); } if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response); - // Interim, for a server that did not advertise receipts: a window with no refusal reads as published. - if (settlement.outcome === "unconfirmed" && receiptCapable) throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); + if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); } finally { peer.close(); } diff --git a/packages/cultmesh-ts/test/client-write-completion.test.ts b/packages/cultmesh-ts/test/client-write-completion.test.ts index 8dd122ed..5c4a1eaa 100644 --- a/packages/cultmesh-ts/test/client-write-completion.test.ts +++ b/packages/cultmesh-ts/test/client-write-completion.test.ts @@ -16,7 +16,7 @@ import { encodeRudpPacket, } from "@gamecult/cultnet-ts"; import { encodeWireValue } from "@gamecult/cultnet-ts/wire"; -import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError } from "../src/index"; +import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index"; const isTheRefusal = (error: unknown): boolean => error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized"; @@ -100,7 +100,7 @@ for (const order of orders) { }); } -test("publishRudpDocumentOnce still publishes a put the server admitted", async () => { +test("a put the server applied but did not answer is unconfirmed, never published", async () => { const connectionId = nextConnection++; const puts: string[] = []; const server = CultMesh.createRudpDocumentServer("completion-admit", connectionId, { @@ -111,7 +111,10 @@ test("publishRudpDocumentOnce still publishes a put the server admitted", async }); await server.start(); try { - await CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 }); - assert.deepEqual(puts, ["tests:completion:a"]); + await assert.rejects( + CultMesh.publishRudpDocumentOnce("completion-admit-client", connectionId, `rudp://127.0.0.1:${server.bind.port}`, binding, "tests:completion:a", { noteId: "a", text: "x" }, { resendDelayMs: 50, resendPollMs: 5, resultWindowMs: 200 }), + (error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed", + ); + assert.deepEqual(puts, ["tests:completion:a"], "the server did apply it; this server just does not answer"); } finally { server.close(); } }); diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 90ff38f9..68429969 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -21,7 +21,7 @@ import { type CultNetMessage, type CultNetSnapshotResponseRawMessage, } from "@gamecult/cultnet-ts"; -import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError } from "../src/index"; +import { CultMesh, CultMeshGrantAllWrites, CultMeshRudpUnservableDocumentError, CultMeshRudpWriteUnconfirmedError } from "../src/index"; // A record's payload as the bytes a peer would put on the wire. const payloadOf = (value: unknown) => documentPayloadFromBytes(encodeWireValue(value)); @@ -2968,25 +2968,30 @@ test("CultMesh TS publishes one registered document to a RUDP catalog", async () try { await server.start(); - await CultMesh.publishRudpDocumentOnce( - "cultmesh-ts-muninn", - connectionId, - `rudp://127.0.0.1:${server.bind.port}`, - defineCultNetDocumentBinding({ definition: noteDocument }), - "note:odin", - { - noteId: "note:odin", - body: "pay respects once", - }, - { - resendDelayMs: 25, - resendPollMs: 5, - maxFragmentBytes: 1024, - maxPendingReliablePackets: 16, - connectTimeoutMs: 1_000, - flushTimeoutMs: 25, - sourceRole: "test-provider", - }, + // This server answers a put with nothing, so the writer cannot know the put landed: unconfirmed, though it did. + await assert.rejects( + CultMesh.publishRudpDocumentOnce( + "cultmesh-ts-muninn", + connectionId, + `rudp://127.0.0.1:${server.bind.port}`, + defineCultNetDocumentBinding({ definition: noteDocument }), + "note:odin", + { + noteId: "note:odin", + body: "pay respects once", + }, + { + resendDelayMs: 25, + resendPollMs: 5, + maxFragmentBytes: 1024, + maxPendingReliablePackets: 16, + connectTimeoutMs: 1_000, + flushTimeoutMs: 25, + resultWindowMs: 100, + sourceRole: "test-provider", + }, + ), + (error: unknown) => error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed", ); await waitFor(() => received !== undefined, "published RUDP document"); diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index ddc3d41e..e6da5bf2 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -31,8 +31,10 @@ 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"; + answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye"; delayMs?: number; + /** Acknowledge the reliable packets it receives? Default true. */ + ack?: boolean; } async function fakeServer(connectionId: number, script: Script) { @@ -72,7 +74,9 @@ async function fakeServer(connectionId: number, script: Script) { if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue; const timer = setTimeout(() => { timers.delete(timer); - if (script.answer === "refuse") { + 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!; @@ -81,7 +85,7 @@ async function fakeServer(connectionId: number, script: Script) { }, script.delayMs ?? 20); timers.add(timer); } - if (packet.reliable) send(session.createAckForReceived(packet.sequence), remote.port, remote.address); + 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(); } }; } @@ -121,19 +125,38 @@ test("a receipt for another id settles nothing", async () => { } finally { server.close(); } }); -test("a server whose hello does not advertise keeps the interim 500 ms window-success", async () => { +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 publish(server, { writeReceipts: true }); + 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 }); + try { + await assert.rejects(publish(server, { writeReceipts: true, flushTimeoutMs: 300 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); + } finally { server.close(); } +}); + 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 publish(server, { resultWindowMs: 100 }); + await assert.rejects(publish(server, { resultWindowMs: 100 }), CultMeshRudpWriteUnconfirmedError); assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0"]); } finally { server.close(); } }); diff --git a/packages/cultnet-ts/src/write-tracker.ts b/packages/cultnet-ts/src/write-tracker.ts index 7398afaa..11229232 100644 --- a/packages/cultnet-ts/src/write-tracker.ts +++ b/packages/cultnet-ts/src/write-tracker.ts @@ -14,7 +14,11 @@ export function resultWindowMs(serverAdvertisesReceipts: boolean): number { return serverAdvertisesReceipts ? RECEIPT_RESULT_WINDOW_MS : LEGACY_RESULT_WINDOW_MS; } -export type WriteUnconfirmedReason = "window_elapsed" | "session_ended"; +/** + * 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"; @@ -97,6 +101,13 @@ export class WriteTracker { } } + /** This client's own send of `messageId` failed: that write settles unconfirmed; the others are untouched. */ + sendFailed(messageId: string, atMs: number): void { + this.tick(atMs); + const entry = this.#writes.get(messageId); + if (entry?.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "local_send_failed" }); + } + state(messageId: string): WriteState | undefined { return this.#writes.get(messageId)?.state; } diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts index 66b80601..31a1c388 100644 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -194,3 +194,29 @@ test("the replay runner passes every vector against this runtime", () => { assert.match(run.stdout, /^PASS scenario\/receipt_admits$/m); assert.doesNotMatch(run.stdout, /^FAIL/m); }); + +test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => { + const settled: [string, WriteSettlement][] = []; + const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) }); + tracker.send("w1", 0); + tracker.send("w2", 0); + tracker.sendFailed("w1", 10); + assert.deepEqual(settled, [["w1", { outcome: "unconfirmed", reason: "local_send_failed" }]]); + assert.equal(tracker.state("w2")?.outcome, "pending"); + tracker.frame(admitted("w1"), 20); + assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed", recorded: "admitted_late" }); + tracker.sendFailed("w2", 30); + tracker.sendFailed("w2", 31); + assert.equal(settled.length, 2, "a write settles once"); + tracker.sendFailed("unknown", 40); + assert.equal(tracker.state("unknown"), undefined); +}); + +test("a failed local send advances the clock: other writes whose windows have run settle window_elapsed", () => { + const tracker = new WriteTracker({ windowMs: 1000 }); + tracker.send("w1", 0); + tracker.send("w2", 500); + tracker.sendFailed("w2", 1200); + assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" }); + assert.deepEqual(tracker.state("w2"), { outcome: "unconfirmed", reason: "local_send_failed" }); +}); -- 2.47.3 From af1ac72359187291489154f156b3a9c5bd7580c9 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 17:05:42 +0000 Subject: [PATCH 04/10] cultmesh-ts, cultnet-ts: only write_admitted admits a put (no echo admission); the send is bounded by the result window and a timeout is local_send_failed Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/CHANGELOG.md | 2 +- packages/cultmesh-ts/src/index.ts | 13 +++++----- .../test/write-receipt-client.test.ts | 24 +++++++++++++++++-- packages/cultnet-ts/src/write-tracker.ts | 2 +- .../cultnet-ts/test/write-receipt.test.ts | 7 ++++++ 5 files changed, 37 insertions(+), 11 deletions(-) diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index e7b432a5..f4612f2e 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ### 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). The legacy success reply (the put's id echoed) still admits it. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed. +- `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 diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 43a44c9b..be053f53 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4224,8 +4224,9 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti * How long, counted from the send, the put may wait for its result frame. A reliable-transport ack is not * admission; the outcome is the peer's result frame. Default: 5000 ms against a server whose hello lists the * write-receipt frames, 500 ms otherwise. A put nothing settled within the window is unconfirmed, never - * published, whatever the server advertised; a legacy server's success reply (the put's id echoed back) ends - * the wait early as admitted. + * published, whatever the server advertised: only a `write_admitted` is admission. The send itself is also + * bounded by the window (`flushTimeoutMs` is the upper bound); a send not acknowledged by then is + * unconfirmed as `local_send_failed`. */ resultWindowMs?: number; /** @@ -6225,10 +6226,6 @@ export class CultMesh { if (message.schemaVersion === "cultnet.hello.v0") { receiptCapable = helloAdvertisesWriteReceipts(message); serverHello?.(); - } else if (!receiptCapable && "messageId" in message && message.messageId === messageId - && message.schemaVersion !== "cultnet.error.v1") { - // A server that did not advertise receipts may echo the id back as its success reply. - tracker?.frame({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: binding.definition.schemaId ?? "", recordKey }, Date.now()); } else { tracker?.frame(message, Date.now()); } @@ -6263,7 +6260,9 @@ export class CultMesh { tags: options.tags, }, )); - await peer.flush(options.flushTimeoutMs ?? 30_000); + // 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()); diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index e6da5bf2..cf07138b 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -31,7 +31,7 @@ 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"; + answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye" | "echo"; delayMs?: number; /** Acknowledge the reliable packets it receives? Default true. */ ack?: boolean; @@ -74,7 +74,9 @@ async function fakeServer(connectionId: number, script: Script) { if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue; const timer = setTimeout(() => { timers.delete(timer); - if (script.answer === "goodbye") { + 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); @@ -153,6 +155,24 @@ test("a put whose send is never acknowledged is unconfirmed as local_send_failed } 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("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 { diff --git a/packages/cultnet-ts/src/write-tracker.ts b/packages/cultnet-ts/src/write-tracker.ts index 11229232..2204d371 100644 --- a/packages/cultnet-ts/src/write-tracker.ts +++ b/packages/cultnet-ts/src/write-tracker.ts @@ -103,9 +103,9 @@ export class WriteTracker { /** This client's own send of `messageId` failed: that write settles unconfirmed; the others are untouched. */ sendFailed(messageId: string, atMs: number): void { - this.tick(atMs); 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 { diff --git a/packages/cultnet-ts/test/write-receipt.test.ts b/packages/cultnet-ts/test/write-receipt.test.ts index 31a1c388..83c31411 100644 --- a/packages/cultnet-ts/test/write-receipt.test.ts +++ b/packages/cultnet-ts/test/write-receipt.test.ts @@ -220,3 +220,10 @@ test("a failed local send advances the clock: other writes whose windows have ru 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" }); +}); -- 2.47.3 From c89c4ee39ab552b2a44a3093600a33c3164d89a0 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 17:07:54 +0000 Subject: [PATCH 05/10] cultmesh-ts: test kills the flush-cap mutant Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/test/write-receipt-client.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index cf07138b..19f27a3c 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -149,9 +149,11 @@ test("a session that ends before any result leaves the put unconfirmed (session_ 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(); } }); -- 2.47.3 From 0caef4d564e695033b179d171d6bf1b10a25c5d4 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 17:38:47 +0000 Subject: [PATCH 06/10] WIP (unverified): publishRudpDocumentOnce sendOnly + presence publisher send-only; tests written, cultmesh-ts receipt-client run hung Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/CHANGELOG.md | 1 + packages/cultmesh-ts/src/index.ts | 46 ++++++++--- .../test/write-receipt-client.test.ts | 31 +++++++ .../src/idunn-odin-presence-publisher.ts | 30 ++++--- .../idunn-odin-presence-send-only.test.ts | 82 +++++++++++++++++++ 5 files changed, 164 insertions(+), 26 deletions(-) create mode 100644 packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts diff --git a/packages/cultmesh-ts/CHANGELOG.md b/packages/cultmesh-ts/CHANGELOG.md index f4612f2e..5df85ad3 100644 --- a/packages/cultmesh-ts/CHANGELOG.md +++ b/packages/cultmesh-ts/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following ### Breaking +- `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default. - The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`. - `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean. - `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `` and yield `CultMeshCatalogValue`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema. diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index be053f53..38c0b34a 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4239,6 +4239,14 @@ export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOpti writeReceipts?: boolean; /** With `writeReceipts`, how long to wait for the server's hello before treating it as legacy. Default 500 ms. */ helloWaitMs?: number; + /** + * Send the put and report only that it was sent: no hello, no wait for a result frame. Resolves `"sent"` once the + * transport has acknowledged the put (`flushTimeoutMs`), rejects `CultMeshRudpWriteUnconfirmedError` + * (`local_send_failed`) if it cannot be sent, and never resolves `"admitted"`: whether the server applied or + * refused it is unknown. For callers whose server cannot answer a write yet. Opt-in per call; not combinable with + * `writeReceipts`. + */ + sendOnly?: boolean; messageId?: string; sourceRuntimeId?: string; sourceAgentId?: string; @@ -6208,13 +6216,35 @@ export class CultMesh { recordKey: string, value: CultCacheDocumentValue, options: CultMeshRudpDocumentPublishOptions = {}, - ): Promise { + ): Promise<"admitted" | "sent"> { requireNonEmpty(runtimeId, "runtimeId"); requireNonEmpty(recordKey, "recordKey"); + if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined."); const registry = new CultNetDocumentRegistry([binding]); const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; + const putMessage = () => registry.createRawDocumentPutMessage( + binding, + messageId, + recordKey, + value, + { + sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, + sourceAgentId: options.sourceAgentId, + sourceRole: options.sourceRole, + tags: options.tags, + }, + ); const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { + if (options.sendOnly) { + try { + peer.send(putMessage()); + await peer.flush(options.flushTimeoutMs ?? 30_000); + } catch { + throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed"); + } + return "sent"; + } // A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides // how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw. let receiptCapable = false; @@ -6248,18 +6278,7 @@ export class CultMesh { tracker = writeTracker; writeTracker.send(messageId, Date.now()); try { - peer.send(registry.createRawDocumentPutMessage( - binding, - messageId, - recordKey, - value, - { - sourceRuntimeId: options.sourceRuntimeId ?? runtimeId, - sourceAgentId: options.sourceAgentId, - sourceRole: options.sourceRole, - tags: options.tags, - }, - )); + peer.send(putMessage()); // 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))); @@ -6277,6 +6296,7 @@ export class CultMesh { } if (settlement.outcome === "refused") throw new CultMeshRudpWriteRefusedError(settlement.response); if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason); + return "admitted"; } finally { peer.close(); } diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index 19f27a3c..f1f05410 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -175,6 +175,37 @@ test("a server that does not advertise receipts and echoes the put's id back has } 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 server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); + try { + await assert.rejects(publish(server, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError); + const outcome = await publish(server, { sendOnly: true }); + assert.equal(outcome, "sent"); + assert.notEqual(outcome, "admitted"); + assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0", "cultnet.document_put_raw.v0"], "sendOnly sends no hello"); + } finally { server.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, and cannot be combined with writeReceipts", async () => { + const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", ack: false }); + try { + await assert.rejects(publish(server, { sendOnly: true, flushTimeoutMs: 300 }), (error: unknown) => + error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed"); + await assert.rejects(publish(server, { sendOnly: true, writeReceipts: true }), TypeError); + } finally { server.close(); } +}); + 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 { diff --git a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts index 209a2881..a5994f56 100644 --- a/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts +++ b/packages/cultnet-ts/src/idunn-odin-presence-publisher.ts @@ -52,7 +52,7 @@ export function createIdunnRuntimePresencePublisher( const publish = async (detail: string) => { const signed = signer.sign(detail); messageSequence += 1; - await publishDocument(endpoint, connectionId, { + await sendPresenceDocument(endpoint, connectionId, { schemaVersion: "cultnet.document_put_raw.v0", messageId: `runtime-presence:${signer.authority.expected.target}:${signer.runtimeInstanceId}:${messageSequence}`, document: signed.document, @@ -86,11 +86,19 @@ 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 }, connectionId: number, message: Record, -): Promise { +): Promise<"sent"> { const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4"); await new Promise((resolve, reject) => { socket.once("error", reject); @@ -107,26 +115,24 @@ async function publishDocument( const wirePayload = encode(encodeCultNetMessageForWire(message as never, "cultnet.schema.v0")); const packets = session.sendMany("schema", wirePayload, { reliable: true, ordered: true, nowMs: Date.now() }); for (const packet of packets) await sendPacket(socket, endpoint, packet); - // The transport ack is not admission: the put completes on Odin's result frame. A refusal rejects whether - // it arrives before the ack, after it, or inside the result window; a success reply ends the wait early. - let succeeded = false; + // The transport ack is delivery, not admission. A refusal that arrives by then rejects; no other frame means anything. const onFrame = (frame: { channelId: string; payload: Uint8Array }) => { if (frame.channelId !== "schema") return; const response = parseCultNetMessage(decode(frame.payload)); if (response.schemaVersion === "cultnet.error.v0") throw new IdunnPresenceRefusedError(response); - succeeded = true; + // An error.v1 refuses this put only when it names it (or names nothing). + if (response.schemaVersion === "cultnet.error.v1" && (response.messageId === null || response.messageId === message.messageId)) { + throw new IdunnPresenceRefusedError(response); + } }; await receiveUntil(receiver, session, endpoint, () => session.outstandingReliablePacketCount === 0, 2000, onFrame); - if (!succeeded) await receiveUntil(receiver, session, endpoint, () => succeeded, RESULT_WINDOW_MS, onFrame, true); + return "sent"; } finally { receiver.close(); socket.close(); } } -/** How long after the transport ack Odin's refusal may still arrive; a success reply ends the wait early. */ -const RESULT_WINDOW_MS = 500; - /** Odin refused the presence put: the typed `cultnet.error.v0` it answered with. */ export class IdunnPresenceRefusedError extends Error { readonly code: string | undefined; @@ -200,7 +206,6 @@ async function receiveUntil( predicate: (packet: CultNetRudpPacket) => boolean, timeoutMs: number, onDelivered?: (frame: { channelId: string; payload: Uint8Array; sequence: number }) => void, - returnOnTimeout = false, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { @@ -215,7 +220,6 @@ async function receiveUntil( } for (const packet of session.dueResends(Date.now())) await sendPacket(receiver.socket, endpoint, packet); } - if (returnOnTimeout) return; throw new Error(`Timed out waiting for CultNet RUDP response after ${timeoutMs} ms.`); } diff --git a/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts new file mode 100644 index 00000000..10a249f1 --- /dev/null +++ b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts @@ -0,0 +1,82 @@ +// 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"; + /** Acknowledge the put? Default true. */ + ack?: boolean; +} + +async function fakeOdin(connectionId: number, script: Script) { + const socket: Socket = createSocket("udp4"); + await new Promise((resolve) => socket.bind(0, "127.0.0.1", resolve)); + const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 }); + const send = (packet: Parameters[0], port: number, address: string) => { + try { socket.send(encodeRudpPacket(packet), port, address); } catch { /* closed */ } + }; + const say = (message: CultNetMessage, port: number, address: string) => { + const packets = session.sendMany("schema", encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0")), { reliable: true, ordered: true, nowMs: Date.now() }); + packets.forEach((p) => send(p, port, address)); + }; + socket.on("message", (wire, remote) => { + const packet = decodeRudpPacket(wire); + const now = Date.now(); + if (packet.packetType === "connect") { + send(session.acceptConnect(packet, now), remote.port, remote.address); + return; + } + const result = session.receive(packet, now); + if (result.reply) send(result.reply, remote.port, remote.address); + for (const frame of result.delivered) { + if (frame.channelId !== "schema") continue; + const put = decode(frame.payload) as { messageId: string; document: unknown }; + if (script.answer === "echo") say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: put.messageId, document: put.document } as unknown as CultNetMessage, remote.port, remote.address); + else if (script.answer === "admitted") say({ schemaVersion: "cultnet.write_admitted.v0", messageId: put.messageId, operation: "put", schemaId: "s", recordKey: "k" }, remote.port, remote.address); + else if (script.answer === "refuse-v0") say({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }, remote.port, remote.address); + else if (script.answer === "refuse-v1-other") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: "someone-else" }, remote.port, remote.address); + } + 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(); } +}); + +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(); } +}); -- 2.47.3 From 206dbe142bf4c448c186d8f39c6b4d9d5b30c011 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:24:52 +0000 Subject: [PATCH 07/10] cultmesh-ts: sendOnly tests use one fake server per publish (the reused one threw on the second connect and hung the run); spec names send-only and the presence publisher The hang was the fake server, not the writer: it is bound to one connection id, and two publishes in one test sent a second connect it rejected from inside the socket handler. The unacknowledged-send test also scripted an admit, whose reply piggybacks the put's ack. Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- .../test/write-receipt-client.test.ts | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index ce708ca6..f927fc00 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -61,7 +61,7 @@ A write settles as exactly one of: ## 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; 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. +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 uses it. 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 diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index f1f05410..797df1cd 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -176,14 +176,17 @@ test("a server that does not advertise receipts and echoes the put's id back has }); test("a silent server never yields admitted, in either mode: the default is unconfirmed, sendOnly is the distinct outcome sent", async () => { - const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); + const dflt = await fakeServer(nextConnection, { advertise: false, answer: "silence" }); try { - await assert.rejects(publish(server, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError); - const outcome = await publish(server, { sendOnly: true }); + 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(server.seen, ["cultnet.document_put_raw.v0", "cultnet.document_put_raw.v0"], "sendOnly sends no hello"); - } finally { server.close(); } + 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 () => { @@ -197,15 +200,18 @@ test("only a write_admitted resolves admitted, and sendOnly does not wait for or } finally { refusing.close(); } }); -test("sendOnly fails local_send_failed when the send is never acknowledged, and cannot be combined with writeReceipts", async () => { - const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", ack: false }); +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"); - await assert.rejects(publish(server, { sendOnly: true, writeReceipts: true }), TypeError); } 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 { -- 2.47.3 From 89ff3d2cbd0e136f8b75faa3f4a408c978d4d934 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:26:25 +0000 Subject: [PATCH 08/10] cultnet-ts test: the Odin presence publisher is send-only, so a refusal after the transport ack is not awaited Co-Authored-By: Claude Sonnet 5.5 --- .../test/idunn-runtime-authority.test.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts index 2e05bbf4..50236782 100644 --- a/packages/cultnet-ts/test/idunn-runtime-authority.test.ts +++ b/packages/cultnet-ts/test/idunn-runtime-authority.test.ts @@ -345,18 +345,27 @@ test("the Odin publisher publishes the signer's document to the endpoint Expecte assert.equal(peer.failure, undefined, peer.failure?.message); }); -for (const order of ["ack-then-refusal", "refusal-then-ack"] as const) { - test(`the Odin publisher does not complete on the ack of a refused presence (${order})`, async (context) => { - const endpoint = expectedFacts("service").odinEndpoint!; - const peer = await startOdinPeer(context, endpoint); - peer.reject = true; - peer.order = order; - const signer = signerFor(context, "service"); - signer.reportHealth("active"); - const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); - await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/); - }); -} +test("the Odin publisher rejects a presence whose refusal arrives before the transport ack", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + peer.reject = true; + peer.order = "refusal-then-ack"; + const signer = signerFor(context, "service"); + signer.reportHealth("active"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + await assert.rejects(publisher.publish("denied presence"), /Odin rejected runtime presence: test admission denied/); +}); + +test("the Odin publisher is send-only: a refusal that follows the transport ack is not awaited", async (context) => { + const endpoint = expectedFacts("service").odinEndpoint!; + const peer = await startOdinPeer(context, endpoint); + peer.reject = true; + peer.order = "ack-then-refusal"; + const signer = signerFor(context, "service"); + signer.reportHealth("active"); + const publisher = createIdunnRuntimePresencePublisher({ signer, endpoint }); + await publisher.publish("denied presence"); +}); test("the Odin publisher leaves a capability shortfall to the Rust correlation instead of refusing to publish", async (context) => { const endpoint = expectedFacts("service").odinEndpoint!; -- 2.47.3 From 0fbf2baea6e146a544059abb0d6bf9ed40b2e3de Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:32:49 +0000 Subject: [PATCH 09/10] cultnet-ts, cultmesh-ts: Heimdall's five Lows on send-only (spec wording, error.v1 ack-time refusal tests, build errors keep their cause, presence changelog, ack-cut anchors) Co-Authored-By: Claude Sonnet 5.5 --- contracts/cultnet/interop/write-receipt.md | 2 +- docs/cultnet-rudp-ack-cut.md | 4 ++-- packages/cultmesh-ts/src/index.ts | 4 +++- .../cultmesh-ts/test/write-receipt-client.test.ts | 11 +++++++++++ packages/cultnet-ts/CHANGELOG.md | 1 + .../test/idunn-odin-presence-send-only.test.ts | 14 +++++++++++++- 6 files changed, 31 insertions(+), 5 deletions(-) diff --git a/contracts/cultnet/interop/write-receipt.md b/contracts/cultnet/interop/write-receipt.md index f927fc00..1971b3b9 100644 --- a/contracts/cultnet/interop/write-receipt.md +++ b/contracts/cultnet/interop/write-receipt.md @@ -61,7 +61,7 @@ A write settles as exactly one of: ## 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 uses it. 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. +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 diff --git a/docs/cultnet-rudp-ack-cut.md b/docs/cultnet-rudp-ack-cut.md index 1b519484..47008c81 100644 --- a/docs/cultnet-rudp-ack-cut.md +++ b/docs/cultnet-rudp-ack-cut.md @@ -441,8 +441,8 @@ consumer that waits wants delivered, which is now just Acknowledged. | `cultmesh-rs/src/lib.rs:781-838` `request_raw_snapshot_from_rudp_catalog` (Idunn `src/drivers.rs:5527` uses it) | a hand-rolled loop until `Acknowledged`, then the response | Cut 4: `wait_acknowledged` | | `cultmesh-rs/src/lib.rs:870-900` `publish_cultnet_message_to_rudp_catalog` (Odin `odin-daemon`) | a hand-rolled loop until `Acknowledged`, then disconnect. The doc at `:869` claims "application admission" | Cut 4: `wait_acknowledged`; the doc says delivered to the document server's sink (`rudp_document_server.rs:343-372`) | | `cultmesh-rs/src/lib.rs:918-938` `publish_cultnet_messages_to_rudp_catalog` (Ghostlight `mesh.rs:323`) | `flush_reliable`, then drop. **Several ordered frames, then close** | None; flush now means it | -| `cultmesh-ts/src/index.ts:6070-6098` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts | -| `cultnet-ts/src/idunn-odin-presence-publisher.ts:89-215` `publishDocument`, behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) | +| `cultmesh-ts/src/index.ts:6211-6303` `publishRudpDocumentOnce` (Heimdall, StreamPixels, Stonks, weksa, Bifrost, VoidBot, Hermodr) | `peer.flush` (`cultnet-ts/src/peer.ts:109-110`), then close | None. Cut 5 moves its connect-and-wait helper (`index.ts:6603-6650`) into cultnet-ts | +| `cultnet-ts/src/idunn-odin-presence-publisher.ts:97-136` `sendPresenceDocument` (formerly `publishDocument`), behind `createIdunnRuntimePresencePublisher`. **StreamPixels' service runs this**: `apps/service/scripts/idunn-entry.mjs` runs `deployment/idunn/runtime-presence.mjs`, which calls `createIdunnRuntimePresencePublisher` (`:46`) from its vendored CultLib `542ddd3` (StreamPixels `a49f7d8`; pass 2's `30ee8b9` is stale), where it lives under its older name `cultnet-ts/src/idunn-runtime-authority.ts:206` | a hand-rolled session loop (`:186-208`) until `outstandingReliablePacketCount === 0`. It does resend (`:205`). It drops `readyToSend` (`:198`), so promoted queued packets wait for the resend timer. It pins `initialSequence: 1` (`:103`) and never sends a Disconnect, so Odin's hub keeps each publish's peer until it times out (each publish binds a new port, so each is a new peer) | Cut 5: the socket transport, `connectAndWait`, `send("schema")`, `flush`, `close` (which sends the Disconnect, `rudp.ts:956-969`) | | `cultnet-ts/src/signed-daemon-health.ts:104-162` `publishSignedDaemonHealth` (Hermodr, Odin `idunn-rudp.cjs`) | a hand-rolled loop: sends the packets `sendMany` admitted, waits up to 500 ms for the **first** Ack packet of any content (`:146-153`), swallows the timeout, closes. Resends run inside that wait (`:303`) but promoted queued packets are never sent | Cut 5, as above. Today a write not acked within 500 ms, or any payload over 32 packets, is reported published | | `cultnet-ts/src/operation-service.ts` | the operation response | none (the response is the proof) | | C# `FlushReliable` callers: Gjallar `Program.cs:3055-3080` (two ordered frames, flush, dispose) | flush | None | diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 38c0b34a..0bf8ceae 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -6237,8 +6237,10 @@ export class CultMesh { const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { if (options.sendOnly) { + // Built outside the try: a message that cannot be serialized is the caller's error, with its cause, not a send failure. + const put = putMessage(); try { - peer.send(putMessage()); + peer.send(put); await peer.flush(options.flushTimeoutMs ?? 30_000); } catch { throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed"); diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index 797df1cd..b5f67de6 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -208,6 +208,17 @@ test("sendOnly fails local_send_failed when the send is never acknowledged", asy } 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("sendOnly cannot be combined with writeReceipts", async () => { await assert.rejects(publish({ endpoint: "rudp://127.0.0.1:9" }, { sendOnly: true, writeReceipts: true }), TypeError); }); diff --git a/packages/cultnet-ts/CHANGELOG.md b/packages/cultnet-ts/CHANGELOG.md index 13dc3dff..be6e2d98 100644 --- a/packages/cultnet-ts/CHANGELOG.md +++ b/packages/cultnet-ts/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following ### Breaking +- The Idunn runtime presence publisher is send-only (`sendPresenceDocument`): it returns `"sent"` once Odin's transport has acknowledged the put and no longer waits up to 500 ms for a success reply, which was never admission. It rejects `IdunnPresenceRefusedError` for a `cultnet.error.v0`, or a `cultnet.error.v1` naming the put or nothing, that arrives by the ack; a refusal after the ack is not awaited, and no other frame, one carrying the put's id included, counts as success. - The package is renamed `cultnet-ts` to `@gamecult/cultnet-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultnet-ts` and its subpaths; the API is unchanged. - `CultNetErrorMessage` carries `routingHint`, `code` and `details` (null when absent), as the reference writes them and `cultnet.error.schema.json` requires; the interface previously dropped them. New `CultNetErrorCode`, `CultNetErrorDetails` and `CultNetShardRoutingHint` types. diff --git a/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts index 10a249f1..fff8e2d5 100644 --- a/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts +++ b/packages/cultnet-ts/test/idunn-odin-presence-send-only.test.ts @@ -10,7 +10,7 @@ import { IdunnPresenceRefusedError, sendPresenceDocument } from "../src/idunn-od import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp"; interface Script { - answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other"; + answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other" | "refuse-v1-same" | "refuse-v1-none"; /** Acknowledge the put? Default true. */ ack?: boolean; } @@ -42,6 +42,8 @@ async function fakeOdin(connectionId: number, script: Script) { 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); }); @@ -73,6 +75,16 @@ test("a refusal that arrives by the transport ack rejects the put", async () => } 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 }); -- 2.47.3 From dc1ae51e928b6b1d9c03a13a379849f8e54d5786 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 19:37:08 +0000 Subject: [PATCH 10/10] cultmesh-ts: publishRudpDocumentOnce builds the put before connecting, so a put that cannot be built keeps its cause on both paths Co-Authored-By: Claude Sonnet 5.5 --- packages/cultmesh-ts/src/index.ts | 7 +++---- .../cultmesh-ts/test/write-receipt-client.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index 0bf8ceae..2f734cfd 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -6222,7 +6222,8 @@ export class CultMesh { if (options.sendOnly && options.writeReceipts) throw new TypeError("sendOnly and writeReceipts cannot be combined."); const registry = new CultNetDocumentRegistry([binding]); const messageId = options.messageId ?? `${runtimeId}:${binding.definition.type}:${recordKey}`; - const putMessage = () => registry.createRawDocumentPutMessage( + // 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, @@ -6237,8 +6238,6 @@ export class CultMesh { const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options); try { if (options.sendOnly) { - // Built outside the try: a message that cannot be serialized is the caller's error, with its cause, not a send failure. - const put = putMessage(); try { peer.send(put); await peer.flush(options.flushTimeoutMs ?? 30_000); @@ -6280,7 +6279,7 @@ export class CultMesh { tracker = writeTracker; writeTracker.send(messageId, Date.now()); try { - peer.send(putMessage()); + 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))); diff --git a/packages/cultmesh-ts/test/write-receipt-client.test.ts b/packages/cultmesh-ts/test/write-receipt-client.test.ts index b5f67de6..072b89a3 100644 --- a/packages/cultmesh-ts/test/write-receipt-client.test.ts +++ b/packages/cultmesh-ts/test/write-receipt-client.test.ts @@ -219,6 +219,17 @@ test("sendOnly keeps the cause of a put that cannot be built: it is not reported } 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); }); -- 2.47.3