From a1065cabe03e1b5f6c655c3e21015fa0b19a70c1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 30 Sep 2026 18:37:40 +0200 Subject: [PATCH] cultmesh-ts: admission sizes only the served reply, never a receipt Admission sized the larger of the snapshot reply and a receipt echoing the put, so a put servable at the bound was refused, within 89-125 bytes of it, for a receipt an application that sends none never sends. Whether a receipt is sent is onDocumentPutRaw's to decide after it stores the put, so admission cannot know it (ruling: Self, fork A option a). Admission now sizes only the snapshot reply serving the document alone. A receipt that cannot be sent takes the existing path: onError hears why and the peer is told with a cultnet.error.v0 that it is not coming. This removes the receipt bound of 9757ed54 and its test. The new test pins the band at its boundary with puts whose echoed receipt is over the limit: exactly at the limit is admitted and served, with no receipt and with an echoed one, and one byte over is refused. Co-Authored-By: Claude Opus 5.5 --- packages/cultmesh-ts/src/index.ts | 63 ++++++++----------- packages/cultmesh-ts/test/cultmesh.test.ts | 71 ++++++++++++---------- 2 files changed, 66 insertions(+), 68 deletions(-) diff --git a/packages/cultmesh-ts/src/index.ts b/packages/cultmesh-ts/src/index.ts index d3972cac..689fbed2 100644 --- a/packages/cultmesh-ts/src/index.ts +++ b/packages/cultmesh-ts/src/index.ts @@ -4289,22 +4289,21 @@ export interface CultMeshRudpDocumentReceipt encodeSchemaMessage(reply).byteLength)); + const responseBytes = encodeSchemaMessage(reply).byteLength; const fragmentCount = Math.max(1, Math.ceil(responseBytes / maxFragmentBytes)); if ((maxPayloadBytes === undefined || responseBytes <= maxPayloadBytes) && fragmentCount <= maxReplyFragments) { diff --git a/packages/cultmesh-ts/test/cultmesh.test.ts b/packages/cultmesh-ts/test/cultmesh.test.ts index 8167e41d..78c83864 100644 --- a/packages/cultmesh-ts/test/cultmesh.test.ts +++ b/packages/cultmesh-ts/test/cultmesh.test.ts @@ -3246,7 +3246,7 @@ const servedBoundRegistry = () => /** * A put's own storedAt, kept by the served record and replaced in the receipt by the server's * 24-character clock. It is long enough that the snapshot reply is always the larger reply, so - * fixtures built with it pin the served bound; `RECEIPT_STORED_AT` puts pin the receipt bound. + * fixtures built with it pin the served bound; `RECEIPT_STORED_AT` puts make the receipt the larger reply. */ const SERVED_STORED_AT = `2026-09-30T00:00:00.000Z${"~".repeat(200)}`; const RECEIPT_STORED_AT = "2026-09-30T00:00:00.000Z"; @@ -3320,16 +3320,6 @@ function bodyLengthForReplyBytes(recordKey: string, bytes: number): number { return length; } -/** The body length whose echoed receipt, under `messageId`, is exactly `bytes`, and the larger reply. */ -function bodyLengthForReceiptBytes(messageId: string, recordKey: string, bytes: number): number { - const put = (body: number) => notePut(messageId, recordKey, body, undefined, RECEIPT_STORED_AT); - const probe = 2_000; - const length = probe + bytes - echoReceiptBytes(put(probe)); - assert.equal(echoReceiptBytes(put(length)), bytes, "fixture: receipt size is linear in the body"); - assert.ok(servedAloneBytes(put(length)) < bytes, "fixture: the receipt is the larger reply"); - return length; -} - async function withServedBoundRig( connectionId: number, serverLimits: { maxFragmentBytes?: number; maxPendingReliablePackets?: number; maxPayloadBytes?: number }, @@ -3510,29 +3500,46 @@ test("CultMesh TS RUDP document server honours maxPayloadBytes for replies and j }); }); -test("CultMesh TS RUDP document server refuses a put whose receipt could not be sent", async () => { - // The receipt is the larger reply here: a put whose receipt is one byte over the limit is - // refused before it is stored; one whose receipt is exactly at the limit is stored and gets it. +test("CultMesh TS RUDP document server admits a put served at the bound, whatever receipt its handler returns", async () => { + // These puts keep a short storedAt, so the receipt echoing one is larger than its served reply, + // and over the limit. Admission sizes only the served reply: exactly at the limit is admitted + // with or without a receipt, one byte over is refused. An echoed receipt that cannot be sent + // goes the way of any unsendable receipt: the put is stored and the peer is told. const limit = 4_000; - const overBound = bodyLengthForReceiptBytes("put-ov", "note:ro", limit + 1); - const atBound = bodyLengthForReceiptBytes("put-at", "note:ra", limit); - await withServedBoundRig(0x10203065, { maxFragmentBytes: 1024, maxPayloadBytes: limit }, async (rig) => { - rig.peer.send(notePut("put-ov", "note:ro", overBound, undefined, RECEIPT_STORED_AT)); - const refusal = await waitForError(rig, "the put whose receipt is over the limit"); - assert.ok(refusal instanceof CultMeshRudpUnservableDocumentError, refusal.message); - assert.equal(refusal.responseBytes, limit + 1); - assert.equal(refusal.maxPayloadBytes, limit); - assert.deepEqual(rig.admitted, []); - await waitForPeerErrors(rig, 1); + const put = (messageId: string, recordKey: string, body: number) => + notePut(messageId, recordKey, body, undefined, RECEIPT_STORED_AT); + const bodyServedAt = (messageId: string, recordKey: string, bytes: number): number => { + const probe = 2_000; + const length = probe + bytes - servedAloneBytes(put(messageId, recordKey, probe)); + assert.equal(servedAloneBytes(put(messageId, recordKey, length)), bytes, "fixture: reply size is linear in the body"); + assert.ok(echoReceiptBytes(put(messageId, recordKey, length)) > limit, "fixture: the echoed receipt is over the limit"); + return length; + }; + const atBound = bodyServedAt("put-at", "note:at", limit); + const overBound = bodyServedAt("put-ov", "note:ov", limit + 1); + for (const [connectionId, receipt] of [[0x10203066, undefined], [0x10203067, "echo"]] as const) { + await withServedBoundRig(connectionId, { maxFragmentBytes: 1024, maxPayloadBytes: limit }, async (rig) => { + rig.peer.send(put("put-at", "note:at", atBound)); + await waitForAdmission(rig, "note:at"); + if (receipt === "echo") { + const failure = await waitForError(rig, "the echoed receipt"); + assert.match(failure.message, /reply cultnet\.document_put_raw\.v0 is \d+ bytes; maxPayloadBytes is 4000/); + await waitForPeerErrors(rig, 1); + assert.ok(!rig.messages.some(isDocumentPut)); + } + assert.deepEqual(servedBodies(await snapshotUnderShortestId(rig, "note:at")), [["note:at", atBound]]); + const errorsBefore = rig.errors.length; + assert.equal(errorsBefore, receipt === "echo" ? 1 : 0, rig.errors.map((error) => error.message).join("; ")); - rig.peer.send(notePut("put-at", "note:ra", atBound, undefined, RECEIPT_STORED_AT)); - await waitForAdmission(rig, "note:ra"); - const startedAt = Date.now(); - while (!rig.messages.some(isDocumentPut) && Date.now() - startedAt < 3_000) await delay(5); - const receipt = rig.messages.find(isDocumentPut) as CultNetDocumentPutRawMessage | undefined; - assert.equal(receipt?.messageId, "put-at", "the stored put gets its receipt"); - assert.equal(rig.errors.length, 1); - }, { receipt: "echo" }); + rig.peer.send(put("put-ov", "note:ov", overBound)); + const startedAt = Date.now(); + while (rig.errors.length === errorsBefore && Date.now() - startedAt < 3_000) await delay(5); + const refusal = rig.errors[errorsBefore]; + assert.ok(refusal instanceof CultMeshRudpUnservableDocumentError, refusal?.message); + assert.equal(refusal.responseBytes, limit + 1); + assert.deepEqual(rig.admitted, ["note:at"]); + }, receipt ? { receipt } : {}); + } }); test("CultMesh TS RUDP document server tells the peer when a stored put's receipt cannot be sent", async () => {