cultmesh-ts: judge a put by what serving it sends, and tell the peer every refusal
The document server refused any delivered frame larger than maxPayloadBytes, reporting it only to onError: the peer's put, already acknowledged by the transport, vanished. The frame had been reassembled by then, so the check protected no memory; it only refused puts whose frame was over the limit because of provenance or a long message id although their served reply fit. It is gone. maxPayloadBytes bounds what the server sends; a put is judged by its served reply, and every refusal reaches the peer as a cultnet.error.v0. A stored put whose receipt cannot be sent (a receipt from onDocumentPutRaw larger than maxPayloadBytes) now tells the peer so with a cultnet.error.v0, as well as onError. The unservable refusal names sizes only, never the record key, so a long key cannot make the refusal itself too large to send. The options document states that the served-size judgement is exact only for an application that stores through the registry it was given. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b1d7d31f59
commit
6cf19b136a
2 changed files with 96 additions and 36 deletions
|
|
@ -4285,12 +4285,23 @@ export interface CultMeshRudpDocumentReceipt<TDefinition extends AnyCultCacheDoc
|
|||
* Options for `CultMesh.createRudpDocumentServer`.
|
||||
*
|
||||
* The server fragments its replies by `maxFragmentBytes` (default
|
||||
* `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES`) and refuses any
|
||||
* inbound frame or reply larger than `maxPayloadBytes`. A put whose snapshot
|
||||
* reply could never be sent within those limits, 65535 fragments and
|
||||
* `CULTMESH_RUDP_DOCUMENT_SERVER_DEFAULT_MAX_FRAGMENT_BYTES`) and sends no
|
||||
* reply larger than `maxPayloadBytes`. A put is judged by what serving it
|
||||
* would send, not by the size of its own frame: a put whose snapshot reply
|
||||
* could never be sent within those limits, 65535 fragments and
|
||||
* `maxPendingReliablePackets`, is refused before `onDocumentPutRaw` sees it:
|
||||
* `onError` receives a `CultMeshRudpUnservableDocumentError` and the peer a
|
||||
* `cultnet.error.v0`. Every failed send reaches `onError`.
|
||||
* `cultnet.error.v0`.
|
||||
*
|
||||
* That reply is sized as `documents` would store and serve the put's record.
|
||||
* The judgement is exact only for an application whose `onDocumentPutRaw`
|
||||
* stores the put through that registry (`applyRawDocumentPutMessage`) and
|
||||
* whose `getCache` serves that store; a handler that stores another record
|
||||
* is judged by the registry's record, not its own.
|
||||
*
|
||||
* A reply that cannot be sent, such as a receipt from `onDocumentPutRaw`
|
||||
* larger than `maxPayloadBytes`, reaches `onError`, and the peer receives a
|
||||
* `cultnet.error.v0` in its place. Every failed datagram send reaches `onError`.
|
||||
*/
|
||||
export interface CultMeshRudpDocumentServerOptions extends CultMeshRudpSocketOptions {
|
||||
documents: CultNetDocumentRegistry;
|
||||
|
|
@ -4330,8 +4341,10 @@ export class CultMeshRudpUnservableDocumentError extends Error {
|
|||
readonly fragmentCount: number,
|
||||
readonly maxFragmentCount: number,
|
||||
) {
|
||||
// The sentence names sizes only: it is sent to the peer, and a record key of
|
||||
// any length would make the refusal itself too large to send.
|
||||
super(
|
||||
`Document ${recordKey} can never be served: its snapshot reply is ${responseBytes} bytes in `
|
||||
`Document can never be served: its snapshot reply is ${responseBytes} bytes in `
|
||||
+ `${fragmentCount} fragments; limits are ${maxPayloadBytes ?? "unbounded"} bytes and `
|
||||
+ `${maxFragmentCount} fragments.`,
|
||||
);
|
||||
|
|
@ -5884,13 +5897,6 @@ export class CultMesh {
|
|||
if (frame.channelId !== "schema") {
|
||||
continue;
|
||||
}
|
||||
if (maxPayloadBytes !== undefined && frame.payload.byteLength > maxPayloadBytes) {
|
||||
reportError(new Error(
|
||||
`CultMesh RUDP document server refused a ${frame.payload.byteLength}-byte frame from `
|
||||
+ `${record.sessionId}; maxPayloadBytes is ${maxPayloadBytes}.`,
|
||||
));
|
||||
continue;
|
||||
}
|
||||
record.work = record.work
|
||||
.then(() => handleDocumentServerFrame(record, frame.payload))
|
||||
.catch(reportError);
|
||||
|
|
@ -5949,15 +5955,24 @@ export class CultMesh {
|
|||
}
|
||||
const receipt = await options.onDocumentPutRaw(normalizeRudpDocumentPut(message, record.remote));
|
||||
if (receipt) {
|
||||
sendSchemaMessage(record, options.documents.createRawDocumentPutMessage(
|
||||
receipt.binding, receipt.messageId ?? message.messageId, receipt.recordKey, receipt.value,
|
||||
{
|
||||
sourceRuntimeId: receipt.sourceRuntimeId ?? runtimeId,
|
||||
sourceAgentId: receipt.sourceAgentId,
|
||||
sourceRole: receipt.sourceRole ?? "provider",
|
||||
tags: receipt.tags ?? ["receipt"],
|
||||
},
|
||||
));
|
||||
try {
|
||||
sendSchemaMessage(record, options.documents.createRawDocumentPutMessage(
|
||||
receipt.binding, receipt.messageId ?? message.messageId, receipt.recordKey, receipt.value,
|
||||
{
|
||||
sourceRuntimeId: receipt.sourceRuntimeId ?? runtimeId,
|
||||
sourceAgentId: receipt.sourceAgentId,
|
||||
sourceRole: receipt.sourceRole ?? "provider",
|
||||
tags: receipt.tags ?? ["receipt"],
|
||||
},
|
||||
));
|
||||
} catch (error) {
|
||||
// The put is stored: the peer must hear that its receipt is not coming.
|
||||
reportError(error);
|
||||
sendSchemaMessage(record, {
|
||||
schemaVersion: "cultnet.error.v0",
|
||||
error: `CultMesh RUDP document server stored a put but could not send its receipt.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
|
@ -6058,8 +6073,9 @@ export class CultMesh {
|
|||
* Refuse a put whose document could never be served: the snapshot reply
|
||||
* carrying it alone, under the shortest message id CultNet parses, sized
|
||||
* as `sendSchemaMessage` would send it. The document is sized as the
|
||||
* registry would store and serve it; a document the registry has no
|
||||
* binding for is sized as received.
|
||||
* registry would store and serve it, which is exact only when the
|
||||
* application stores through the registry; a document the registry has
|
||||
* no binding for is sized as received.
|
||||
*/
|
||||
function unservablePut(message: CultNetMessage): CultMeshRudpUnservableDocumentError | undefined {
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0") return undefined;
|
||||
|
|
|
|||
|
|
@ -3287,6 +3287,7 @@ async function withServedBoundRig(
|
|||
connectionId: number,
|
||||
serverLimits: { maxFragmentBytes?: number; maxPendingReliablePackets?: number; maxPayloadBytes?: number },
|
||||
body: (rig: ServedBoundRig) => Promise<void>,
|
||||
handler: { echoReceipt?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const node = await CultMesh.startNode(
|
||||
join(await mkdtemp(join(tmpdir(), "cultmesh-ts-served-bound-")), "node.ccmp"),
|
||||
|
|
@ -3318,6 +3319,9 @@ async function withServedBoundRig(
|
|||
payload: encode(document.payload),
|
||||
},
|
||||
});
|
||||
return handler.echoReceipt
|
||||
? { binding: defineCultNetDocumentBinding({ definition: noteDocument }), recordKey: document.recordKey, value: document.payload as { noteId: string; body: string } }
|
||||
: undefined;
|
||||
},
|
||||
onError: (error) => errors.push(error),
|
||||
});
|
||||
|
|
@ -3407,12 +3411,10 @@ test("CultMesh TS RUDP document server refuses a put whose reply would overflow
|
|||
});
|
||||
});
|
||||
|
||||
test("CultMesh TS RUDP document server honours maxPayloadBytes for replies, puts and inbound frames", async () => {
|
||||
test("CultMesh TS RUDP document server honours maxPayloadBytes for replies and judges puts by what it would serve", async () => {
|
||||
const limit = 4_000;
|
||||
const atBound = bodyLengthForReplyBytes("note:at", limit);
|
||||
const overBound = bodyLengthForReplyBytes("note:ov", limit + 1);
|
||||
// A put's own frame is only a few bytes under its reply, so a long message id would push an
|
||||
// at-bound put over the inbound limit before admission is asked. Keep these ids short.
|
||||
await withServedBoundRig(0x10203062, { maxFragmentBytes: 1024, maxPayloadBytes: limit }, async (rig) => {
|
||||
rig.peer.send(notePut("put-over", "note:ov", overBound));
|
||||
const refusal = await waitForError(rig, "the over-bound put");
|
||||
|
|
@ -3421,20 +3423,24 @@ test("CultMesh TS RUDP document server honours maxPayloadBytes for replies, puts
|
|||
assert.equal(refusal.maxPayloadBytes, limit);
|
||||
assert.equal(refusal.fragmentCount, 4);
|
||||
assert.deepEqual(rig.admitted, []);
|
||||
// The peer hears the refusal, and it names sizes, never the record.
|
||||
assert.deepEqual(await waitForPeerErrors(rig, 1), [refusal.message]);
|
||||
assert.ok(!refusal.message.includes("note:ov"), refusal.message);
|
||||
|
||||
rig.peer.send(notePut("put-at", "note:at", atBound));
|
||||
// A put whose own frame is over the limit only by the provenance and message id it carries
|
||||
// is served at the bound, so it is admitted and served.
|
||||
const carried = notePut(`put-at-${"m".repeat(200)}`, "note:at", atBound, `runtime-${"r".repeat(200)}`);
|
||||
assert.ok(encode(carried).byteLength > limit, "fixture: the put's frame is over the limit");
|
||||
rig.peer.send(carried);
|
||||
await waitForAdmission(rig, "note:at");
|
||||
assert.deepEqual(servedBodies(await snapshotUnderShortestId(rig, "note:at")), [["note:at", atBound]]);
|
||||
assert.equal(rig.errors.length, 1);
|
||||
|
||||
// A frame over the limit is refused on arrival and never offered as a put.
|
||||
rig.peer.send(notePut("put-huge", "note:huge", 2 * limit));
|
||||
await delay(50);
|
||||
const startedAt = Date.now();
|
||||
while (rig.errors.length < 2 && Date.now() - startedAt < 3_000) await delay(5);
|
||||
assert.equal(rig.errors.length, 2);
|
||||
assert.ok(!(rig.errors[1] instanceof CultMeshRudpUnservableDocumentError));
|
||||
assert.match(rig.errors[1]!.message, /maxPayloadBytes is 4000/);
|
||||
// A record key longer than the limit: the refusal is still small enough to reach the peer.
|
||||
const longKey = `note:${"k".repeat(2 * limit)}`;
|
||||
rig.peer.send(notePut("put-long-key", longKey, 10));
|
||||
assert.equal((await waitForPeerErrors(rig, 2))[1], rig.errors[1]!.message);
|
||||
assert.ok(rig.errors[1] instanceof CultMeshRudpUnservableDocumentError, rig.errors[1]!.message);
|
||||
assert.deepEqual(rig.admitted, ["note:at"]);
|
||||
|
||||
// Two servable documents make a reply over the limit: it is not sent, and onError hears why.
|
||||
|
|
@ -3448,6 +3454,44 @@ test("CultMesh TS RUDP document server honours maxPayloadBytes for replies, puts
|
|||
});
|
||||
});
|
||||
|
||||
test("CultMesh TS RUDP document server tells the peer when a stored put's receipt cannot be sent", async () => {
|
||||
const limit = 4_000;
|
||||
const small = bodyLengthForReplyBytes("note:small", 1_000);
|
||||
const atBound = bodyLengthForReplyBytes("note:at", limit);
|
||||
await withServedBoundRig(0x10203064, { maxFragmentBytes: 1024, maxPayloadBytes: limit }, async (rig) => {
|
||||
rig.peer.send(notePut("put-small", "note:small", small));
|
||||
await waitForAdmission(rig, "note:small");
|
||||
const startedAt = Date.now();
|
||||
while (!rig.messages.some(isDocumentPut) && Date.now() - startedAt < 3_000) await delay(5);
|
||||
assert.ok(rig.messages.some(isDocumentPut), "a receipt that fits is sent");
|
||||
|
||||
// Served at the bound, the put is admitted and stored; its echoed receipt carries the
|
||||
// server's provenance and so is over the limit. The peer hears that it is not coming.
|
||||
rig.peer.send(notePut("put-at", "note:at", atBound));
|
||||
await waitForAdmission(rig, "note:at");
|
||||
const [refusal] = await waitForPeerErrors(rig, 1);
|
||||
assert.match(refusal!, /stored a put but could not send its receipt/);
|
||||
assert.equal(rig.errors.length, 1);
|
||||
assert.match(rig.errors[0]!.message, /reply cultnet\.document_put_raw\.v0 is \d+ bytes; maxPayloadBytes is 4000/);
|
||||
assert.deepEqual(servedBodies(await snapshotUnderShortestId(rig, "note:at")), [["note:at", atBound]]);
|
||||
}, { echoReceipt: true });
|
||||
});
|
||||
|
||||
function isDocumentPut(message: CultNetMessage): boolean {
|
||||
return message.schemaVersion === "cultnet.document_put_raw.v0";
|
||||
}
|
||||
|
||||
/** Waits until the peer has received `count` cultnet.error.v0 replies, and returns their text. */
|
||||
async function waitForPeerErrors(rig: ServedBoundRig, count: number): Promise<string[]> {
|
||||
const errors = () => rig.messages
|
||||
.filter((message) => message.schemaVersion === "cultnet.error.v0")
|
||||
.map((message) => (message as { error: string }).error);
|
||||
const startedAt = Date.now();
|
||||
while (errors().length < count && Date.now() - startedAt < 3_000) await delay(5);
|
||||
assert.equal(errors().length, count, `peer errors: ${errors().join("; ")}`);
|
||||
return errors();
|
||||
}
|
||||
|
||||
test("CultMesh TS RUDP document server reports a datagram that fails to leave", async () => {
|
||||
// Fragments larger than any UDP datagram: the reply's one packet cannot be sent.
|
||||
await withServedBoundRig(0x10203063, { maxFragmentBytes: 100_000, maxPendingReliablePackets: 512 }, async (rig) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue