TS: write_admitted / error.v1 decoder, WriteTracker, vector replay runner #62
17 changed files with 1110 additions and 83 deletions
|
|
@ -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 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -5,8 +5,13 @@ 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, `session_ended`, or `local_send_failed`) when nothing settles it. Without the option, or against a server that did not advertise, there is no hello and the window is 500 ms; a put nothing settled in it is unconfirmed too, no longer "published" (**behaviour change**: a server that applies a put but answers nothing now yields `CultMeshRudpWriteUnconfirmedError`, as the spec requires). A legacy server's echo of the put's id is not admission: only `write_admitted` is. The flush that sends the put is bounded by the result window, and a send not acknowledged by then is `local_send_failed`. `resultWindowMs` overrides either window. `publishRudpDocumentAndWaitForReceipt` is not changed.
|
||||
|
||||
### Breaking
|
||||
|
||||
- `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default.
|
||||
- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`.
|
||||
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.
|
||||
- `CultMeshDocumentCatalog` (`document`, `tryDocument`, `latest`, `replace`, `submitPrediction`, `authoritativeWriter`, `predictionWriter`, `watch`) and `CultMeshCollectionCatalog` (`collection`, `tryCollection`, `latest`, `watchChanges`) take `<TDocument = never, TSchema>` and yield `CultMeshCatalogValue<TDocument, TSchema>`: an explicit type argument wins, a CultCache document definition names its own value type, and a bare schema descriptor yields `unknown`. A value type is no longer inferred from a value or an expected result. The `parse` option is removed from these calls, and a lookup by type answers with that type's handle or none, not an alias of a compatible schema.
|
||||
|
|
|
|||
|
|
@ -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<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>;
|
||||
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" }>) {
|
||||
readonly response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>;
|
||||
constructor(response: Extract<CultNetMessage, { schemaVersion: "cultnet.error.v0" | "cultnet.error.v1" }>) {
|
||||
super(response.error);
|
||||
this.name = "CultMeshRudpWriteRefusedError";
|
||||
this.code = response.code ?? undefined;
|
||||
|
|
@ -4198,14 +4204,49 @@ export class CultMeshRudpWriteRefusedError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nothing settled the write (write-receipt.md, "unconfirmed"): the result window ran out with no answer, the
|
||||
* session ended first, or the client's own send failed. Not success and not refusal; a caller that needs admission treats it as not admitted.
|
||||
*/
|
||||
export class CultMeshRudpWriteUnconfirmedError extends Error {
|
||||
readonly reason: WriteUnconfirmedReason;
|
||||
readonly messageId: string;
|
||||
constructor(messageId: string, reason: WriteUnconfirmedReason) {
|
||||
super(`Write ${messageId} was not confirmed: ${reason}.`);
|
||||
this.name = "CultMeshRudpWriteUnconfirmedError";
|
||||
this.reason = reason;
|
||||
this.messageId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CultMeshRudpDocumentPublishOptions extends CultMeshRudpPeerOptions {
|
||||
/**
|
||||
* 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. A put nothing settled within the window is unconfirmed, never
|
||||
* published, whatever the server advertised: only a `write_admitted` is admission. The send itself is also
|
||||
* bounded by the window (`flushTimeoutMs` is the upper bound); a send not acknowledged by then is
|
||||
* unconfirmed as `local_send_failed`.
|
||||
*/
|
||||
resultWindowMs?: number;
|
||||
/**
|
||||
* Declare the write-receipt capability: send a hello listing `cultnet.write_admitted.v0` and `cultnet.error.v1`
|
||||
* before the put. A server that lists both back is receipt-capable: the put then resolves only on its
|
||||
* `write_admitted`, rejects with `CultMeshRudpWriteRefusedError` on its `error.v1`, and rejects with
|
||||
* `CultMeshRudpWriteUnconfirmedError` when nothing settles it. Unset, no hello is sent and the server answers
|
||||
* as it always did.
|
||||
*/
|
||||
writeReceipts?: boolean;
|
||||
/** With `writeReceipts`, how long to wait for the server's hello before treating it as legacy. Default 500 ms. */
|
||||
helloWaitMs?: number;
|
||||
/**
|
||||
* Send the put and report only that it was sent: no hello, no wait for a result frame. Resolves `"sent"` once the
|
||||
* transport has acknowledged the put (`flushTimeoutMs`), rejects `CultMeshRudpWriteUnconfirmedError`
|
||||
* (`local_send_failed`) if it cannot be sent, and never resolves `"admitted"`: whether the server applied or
|
||||
* refused it is unknown. For callers whose server cannot answer a write yet. Opt-in per call; not combinable with
|
||||
* `writeReceipts`.
|
||||
*/
|
||||
sendOnly?: boolean;
|
||||
messageId?: string;
|
||||
sourceRuntimeId?: string;
|
||||
sourceAgentId?: string;
|
||||
|
|
@ -6175,44 +6216,88 @@ export class CultMesh {
|
|||
recordKey: string,
|
||||
value: CultCacheDocumentValue<TDefinition>,
|
||||
options: CultMeshRudpDocumentPublishOptions = {},
|
||||
): Promise<void> {
|
||||
): 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}`;
|
||||
// Built before any connection and outside every try: a put that cannot be built is the caller's error, with its cause.
|
||||
const put = registry.createRawDocumentPutMessage(
|
||||
binding,
|
||||
messageId,
|
||||
recordKey,
|
||||
value,
|
||||
{
|
||||
sourceRuntimeId: options.sourceRuntimeId ?? runtimeId,
|
||||
sourceAgentId: options.sourceAgentId,
|
||||
sourceRole: options.sourceRole,
|
||||
tags: options.tags,
|
||||
},
|
||||
);
|
||||
const peer = await CultMesh.createRudpPeer(runtimeId, connectionId, endpoint, options);
|
||||
try {
|
||||
// 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;
|
||||
if (options.sendOnly) {
|
||||
try {
|
||||
peer.send(put);
|
||||
await peer.flush(options.flushTimeoutMs ?? 30_000);
|
||||
} catch {
|
||||
throw new CultMeshRudpWriteUnconfirmedError(messageId, "local_send_failed");
|
||||
}
|
||||
return "sent";
|
||||
}
|
||||
// A transport ack is not admission: the put completes on its result frame, and the WriteTracker alone decides
|
||||
// how it settles (write-receipt.md, "Client rules"). This function only maps the settlement to resolve/throw.
|
||||
let receiptCapable = false;
|
||||
let tracker: WriteTracker | undefined;
|
||||
let settlement: WriteSettlement | undefined;
|
||||
let wake: (() => void) | undefined;
|
||||
let serverHello: (() => void) | undefined;
|
||||
peer.on("message", (message: CultNetMessage) => {
|
||||
if (message.schemaVersion === "cultnet.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 {
|
||||
tracker?.frame(message, 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);
|
||||
if (!refusal && !succeeded) {
|
||||
peer.on("close", () => tracker?.sessionEnd(Date.now()));
|
||||
if (options.writeReceipts) {
|
||||
const answered = new Promise<void>((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<void>((resolve) => { helloTimer = setTimeout(resolve, options.helloWaitMs ?? 500); })]);
|
||||
clearTimeout(helloTimer);
|
||||
}
|
||||
const writeTracker = new WriteTracker({
|
||||
windowMs: options.resultWindowMs ?? resultWindowMs(receiptCapable),
|
||||
onSettle: (_id, result) => { settlement = result; wake?.(); },
|
||||
});
|
||||
tracker = writeTracker;
|
||||
writeTracker.send(messageId, Date.now());
|
||||
try {
|
||||
peer.send(put);
|
||||
// The flush may not outlast the result window: a send nobody acknowledged by then is a local send failure.
|
||||
const windowRemaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0;
|
||||
await peer.flush(Math.min(options.flushTimeoutMs ?? 30_000, Math.max(windowRemaining, 1)));
|
||||
} catch {
|
||||
// Our own send failed or was never acknowledged: the put may or may not have left. Never a refusal.
|
||||
writeTracker.sendFailed(messageId, Date.now());
|
||||
}
|
||||
while (!settlement) {
|
||||
const remaining = writeTracker.nextDeadlineMs(Date.now()) ?? 0;
|
||||
await new Promise<void>((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);
|
||||
if (settlement.outcome === "unconfirmed") throw new CultMeshRudpWriteUnconfirmedError(messageId, settlement.reason);
|
||||
return "admitted";
|
||||
} finally {
|
||||
peer.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
243
packages/cultmesh-ts/test/write-receipt-client.test.ts
Normal file
243
packages/cultmesh-ts/test/write-receipt-client.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// Client side of contracts/cultnet/interop/write-receipt.md in the one-shot writer: with `writeReceipts` the put
|
||||
// completes only on a write_admitted carrying its id; a refusal, a silent window and a legacy server each end as the
|
||||
// spec says. Each test serves the client a fake RUDP peer that acknowledges the put and answers as scripted.
|
||||
import assert from "node:assert/strict";
|
||||
import { createSocket, type Socket } from "node:dgram";
|
||||
import test from "node:test";
|
||||
import { z } from "zod";
|
||||
import { defineDocumentType } from "@gamecult/cultcache-ts";
|
||||
import {
|
||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
||||
CultNetRudpSession,
|
||||
decodeRudpPacket,
|
||||
defineCultNetDocumentBinding,
|
||||
encodeCultNetMessageForWire,
|
||||
encodeRudpPacket,
|
||||
type CultNetMessage,
|
||||
} from "@gamecult/cultnet-ts";
|
||||
import { decodeWireValue, encodeWireValue } from "@gamecult/cultnet-ts/wire";
|
||||
import { CultMesh, CultMeshRudpWriteRefusedError, CultMeshRudpWriteUnconfirmedError } from "../src/index";
|
||||
|
||||
const note = defineDocumentType({
|
||||
type: "cultmesh.receipt_note",
|
||||
schemaId: "cultmesh.receipt_note.v0",
|
||||
schema: z.object({ noteId: z.string(), text: z.string() }),
|
||||
name: "noteId",
|
||||
});
|
||||
const binding = defineCultNetDocumentBinding({ definition: note });
|
||||
let nextConnection = 0x10206000;
|
||||
|
||||
interface Script {
|
||||
/** Does the server's hello list both receipt frames? */
|
||||
advertise: boolean;
|
||||
/** What the server answers a put with, `delayMs` after it arrives. */
|
||||
answer: "admit" | "refuse" | "wrong-id" | "silence" | "goodbye" | "echo";
|
||||
delayMs?: number;
|
||||
/** Acknowledge the reliable packets it receives? Default true. */
|
||||
ack?: boolean;
|
||||
}
|
||||
|
||||
async function fakeServer(connectionId: number, script: Script) {
|
||||
const socket: Socket = createSocket("udp4");
|
||||
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
|
||||
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
|
||||
const timers = new Set<NodeJS.Timeout>();
|
||||
const seen: string[] = [];
|
||||
let closed = false;
|
||||
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
|
||||
if (!closed) socket.send(encodeRudpPacket(packet), port, address);
|
||||
};
|
||||
const say = (message: CultNetMessage, port: number, address: string) => {
|
||||
const packets = session.sendMany("schema", encodeWireValue(encodeCultNetMessageForWire(message)), { reliable: true, ordered: true, nowMs: Date.now() });
|
||||
packets.forEach((p) => send(p, port, address));
|
||||
};
|
||||
socket.on("message", (wire, remote) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
const now = Date.now();
|
||||
if (packet.packetType === "connect") {
|
||||
send(session.acceptConnect(packet, now), remote.port, remote.address);
|
||||
return;
|
||||
}
|
||||
const result = session.receive(packet, now);
|
||||
if (result.reply) send(result.reply, remote.port, remote.address);
|
||||
for (const frame of result.delivered) {
|
||||
if (frame.channelId !== "schema") continue;
|
||||
const message = decodeWireValue(frame.payload) as { schemaVersion: string; messageId?: string };
|
||||
seen.push(message.schemaVersion);
|
||||
if (message.schemaVersion === "cultnet.hello.v0") {
|
||||
say({
|
||||
schemaVersion: "cultnet.hello.v0", runtimeId: "fake", runtimeKind: "fake",
|
||||
...(script.advertise ? { supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] } : {}),
|
||||
}, remote.port, remote.address);
|
||||
continue;
|
||||
}
|
||||
if (message.schemaVersion !== "cultnet.document_put_raw.v0" || script.answer === "silence") continue;
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
if (script.answer === "echo") {
|
||||
say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: message.messageId!, document: { schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } } as unknown as CultNetMessage, remote.port, remote.address);
|
||||
} else if (script.answer === "goodbye") {
|
||||
send(session.endUnsendableSession(new Error("scripted goodbye")), remote.port, remote.address);
|
||||
} else if (script.answer === "refuse") {
|
||||
say({ schemaVersion: "cultnet.error.v1", error: "write_not_authorized", routingHint: null, code: "write_not_authorized", details: null, messageId: message.messageId! }, remote.port, remote.address);
|
||||
} else {
|
||||
const messageId = script.answer === "wrong-id" ? "someone-else" : message.messageId!;
|
||||
say({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "cultmesh.receipt_note.v0", recordKey: "tests:receipt:a" }, remote.port, remote.address);
|
||||
}
|
||||
}, script.delayMs ?? 20);
|
||||
timers.add(timer);
|
||||
}
|
||||
if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
|
||||
});
|
||||
return { endpoint: `rudp://127.0.0.1:${socket.address().port}`, seen, close: () => { closed = true; timers.forEach(clearTimeout); socket.close(); } };
|
||||
}
|
||||
|
||||
const transport = { resendDelayMs: 50, resendPollMs: 5, flushTimeoutMs: 2_000 };
|
||||
const publish = (server: { endpoint: string }, options: Record<string, unknown>) =>
|
||||
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: "a", text: "x" }, { ...transport, ...options });
|
||||
|
||||
test("a receipt-capable server's write_admitted completes the put, past the legacy 500 ms window", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "admit", delayMs: 700 });
|
||||
try {
|
||||
await publish(server, { writeReceipts: true });
|
||||
assert.deepEqual(server.seen.slice(0, 2), ["cultnet.hello.v0", "cultnet.document_put_raw.v0"]);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("a receipt-capable server's error.v1 refuses the put with its code", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "refuse" });
|
||||
try {
|
||||
await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) =>
|
||||
error instanceof CultMeshRudpWriteRefusedError && error.code === "write_not_authorized" && error.response.schemaVersion === "cultnet.error.v1");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("a receipt-capable server that stays silent leaves the put unconfirmed, never published", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" });
|
||||
try {
|
||||
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), (error: unknown) =>
|
||||
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("a receipt for another id settles nothing", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "wrong-id" });
|
||||
try {
|
||||
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 250 }), CultMeshRudpWriteUnconfirmedError);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("a server whose hello does not advertise leaves a silent put unconfirmed after the 500 ms window, never published", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
|
||||
const started = Date.now();
|
||||
try {
|
||||
await assert.rejects(publish(server, { writeReceipts: true }), (error: unknown) =>
|
||||
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
|
||||
assert.ok(Date.now() - started >= 500, "the legacy window ran from the send");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("a session that ends before any result leaves the put unconfirmed (session_ended), against a legacy server too", async () => {
|
||||
for (const advertise of [false, true]) {
|
||||
const server = await fakeServer(nextConnection, { advertise, answer: "goodbye" });
|
||||
try {
|
||||
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 3_000 }), (error: unknown) =>
|
||||
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "session_ended");
|
||||
} finally { server.close(); }
|
||||
}
|
||||
});
|
||||
|
||||
test("a put whose send is never acknowledged is unconfirmed as local_send_failed, not an untyped error", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
|
||||
const started = Date.now();
|
||||
try {
|
||||
await assert.rejects(publish(server, { writeReceipts: true, flushTimeoutMs: 300 }), (error: unknown) =>
|
||||
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
|
||||
assert.ok(Date.now() - started < 2_500, "flushTimeoutMs bounds the send inside the 5 s window");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("the flush is bounded by the result window: a send nobody acknowledges is local_send_failed at the window, not at the flush timeout", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
|
||||
const started = Date.now();
|
||||
try {
|
||||
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300, flushTimeoutMs: 20_000 }), (error: unknown) =>
|
||||
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
|
||||
assert.ok(Date.now() - started < 3_000, "it did not wait out the 20 s flush timeout");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("a server that does not advertise receipts and echoes the put's id back has not admitted it", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: false, answer: "echo" });
|
||||
try {
|
||||
await assert.rejects(publish(server, { writeReceipts: true, resultWindowMs: 300 }), (error: unknown) =>
|
||||
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "window_elapsed");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("a silent server never yields admitted, in either mode: the default is unconfirmed, sendOnly is the distinct outcome sent", async () => {
|
||||
const dflt = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
|
||||
try {
|
||||
await assert.rejects(publish(dflt, { resultWindowMs: 200 }), CultMeshRudpWriteUnconfirmedError);
|
||||
} finally { dflt.close(); }
|
||||
const sendOnly = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
|
||||
try {
|
||||
const outcome = await publish(sendOnly, { sendOnly: true });
|
||||
assert.equal(outcome, "sent");
|
||||
assert.notEqual(outcome, "admitted");
|
||||
assert.deepEqual(sendOnly.seen, ["cultnet.document_put_raw.v0"], "sendOnly sends no hello");
|
||||
} finally { sendOnly.close(); }
|
||||
});
|
||||
|
||||
test("only a write_admitted resolves admitted, and sendOnly does not wait for or act on an answer", async () => {
|
||||
const admitting = await fakeServer(nextConnection, { advertise: true, answer: "admit" });
|
||||
try {
|
||||
assert.equal(await publish(admitting, { writeReceipts: true }), "admitted");
|
||||
} finally { admitting.close(); }
|
||||
const refusing = await fakeServer(nextConnection, { advertise: true, answer: "refuse", delayMs: 1 });
|
||||
try {
|
||||
assert.equal(await publish(refusing, { sendOnly: true }), "sent", "a refusal is not awaited: sendOnly says only that the put was sent");
|
||||
} finally { refusing.close(); }
|
||||
});
|
||||
|
||||
test("sendOnly fails local_send_failed when the send is never acknowledged", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence", ack: false });
|
||||
try {
|
||||
await assert.rejects(publish(server, { sendOnly: true, flushTimeoutMs: 300 }), (error: unknown) =>
|
||||
error instanceof CultMeshRudpWriteUnconfirmedError && error.reason === "local_send_failed");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("sendOnly keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: false, answer: "silence" });
|
||||
try {
|
||||
await assert.rejects(
|
||||
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, sendOnly: true }),
|
||||
(error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error,
|
||||
);
|
||||
assert.deepEqual(server.seen, [], "nothing was sent");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("the receipt path keeps the cause of a put that cannot be built: it is not reported as local_send_failed", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "admit" });
|
||||
try {
|
||||
await assert.rejects(
|
||||
CultMesh.publishRudpDocumentOnce("receipt-client", nextConnection++, server.endpoint, binding, "tests:receipt:a", { noteId: 1 } as unknown as { noteId: string; text: string }, { ...transport, writeReceipts: true }),
|
||||
(error: unknown) => !(error instanceof CultMeshRudpWriteUnconfirmedError) && error instanceof Error,
|
||||
);
|
||||
assert.ok(!server.seen.includes("cultnet.document_put_raw.v0"), "no put was sent");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
test("sendOnly cannot be combined with writeReceipts", async () => {
|
||||
await assert.rejects(publish({ endpoint: "rudp://127.0.0.1:9" }, { sendOnly: true, writeReceipts: true }), TypeError);
|
||||
});
|
||||
|
||||
test("without writeReceipts no hello is sent and the receipt frames are never asked for", async () => {
|
||||
const server = await fakeServer(nextConnection, { advertise: true, answer: "silence" });
|
||||
try {
|
||||
await assert.rejects(publish(server, { resultWindowMs: 100 }), CultMeshRudpWriteUnconfirmedError);
|
||||
assert.deepEqual(server.seen, ["cultnet.document_put_raw.v0"]);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
@ -16,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<CultNetErrorMessage, "schemaVersion" | "code"> {
|
||||
schemaVersion: "cultnet.error.v1";
|
||||
code: CultNetErrorV1Code | null;
|
||||
messageId: string | null;
|
||||
}
|
||||
|
||||
/** The receipt for one admitted put or delete: every key written, the write's `messageId` echoed byte for byte. */
|
||||
export interface CultNetWriteAdmittedMessage {
|
||||
schemaVersion: "cultnet.write_admitted.v0";
|
||||
messageId: string;
|
||||
operation: "put" | "delete";
|
||||
schemaId: string;
|
||||
recordKey: string;
|
||||
}
|
||||
|
||||
/** The two frames a receipt-capable hello lists in `supportedMessageVersions`; a peer lists both or neither. */
|
||||
export const CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS = ["cultnet.write_admitted.v0", "cultnet.error.v1"] as const;
|
||||
|
||||
/** True when a hello lists both receipt frames: the only thing that makes a connection receipt-capable. */
|
||||
export function helloAdvertisesWriteReceipts(hello: Pick<CultNetHelloMessage, "supportedMessageVersions">): boolean {
|
||||
const listed = hello.supportedMessageVersions ?? [];
|
||||
return CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS.every((version) => listed.includes(version));
|
||||
}
|
||||
|
||||
export interface CultNetSampleChangeNameMessage {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
): Promise<void> {
|
||||
): Promise<"sent"> {
|
||||
const socket = dgram.createSocket(endpoint.host.includes(":") ? "udp6" : "udp4");
|
||||
await new Promise<void>((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<void> {
|
||||
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.`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
144
packages/cultnet-ts/src/write-tracker.ts
Normal file
144
packages/cultnet-ts/src/write-tracker.ts
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// The client half of the write receipt (contracts/cultnet/interop/write-receipt.md, "Client rules"): a pure state
|
||||
// machine with an abstract clock. It owns exactly one decision, how each sent write settles. Transport, timers and
|
||||
// frame decoding belong to the caller; the replay runner (tools/replay-write-receipt-vectors.mjs) drives this same
|
||||
// interface from write-receipt-vectors.json.
|
||||
|
||||
import type { CultNetErrorMessage, CultNetErrorV1Message, CultNetMessage } from "./contracts";
|
||||
|
||||
/** The result window against a server whose hello lists both receipt frames. */
|
||||
export const RECEIPT_RESULT_WINDOW_MS = 5000;
|
||||
/** The result window against a server whose hello does not list them (or that sent none). */
|
||||
export const LEGACY_RESULT_WINDOW_MS = 500;
|
||||
|
||||
export function resultWindowMs(serverAdvertisesReceipts: boolean): number {
|
||||
return serverAdvertisesReceipts ? RECEIPT_RESULT_WINDOW_MS : LEGACY_RESULT_WINDOW_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why nothing settled a write: its window ran out, the session ended first, or the client's own send of it failed (it may or
|
||||
* may not have left the machine, and is never a refusal).
|
||||
*/
|
||||
export type WriteUnconfirmedReason = "window_elapsed" | "session_ended" | "local_send_failed";
|
||||
/** What a late answer is recorded as on a write that already settled unconfirmed. */
|
||||
export type WriteRecorded = "admitted_late" | "refused_late";
|
||||
|
||||
/** What the caller was told. Settled once; never changed by a later frame. */
|
||||
export type WriteSettlement =
|
||||
| { outcome: "admitted" }
|
||||
| { outcome: "refused"; code: string | null; error: string; response: CultNetErrorMessage | CultNetErrorV1Message }
|
||||
| { outcome: "unconfirmed"; reason: WriteUnconfirmedReason };
|
||||
|
||||
/** A write's state: unanswered, or its settlement plus (after unconfirmed only) the late answer. */
|
||||
export type WriteState =
|
||||
| { outcome: "pending"; recorded?: undefined }
|
||||
| (WriteSettlement & { recorded?: WriteRecorded });
|
||||
|
||||
export interface WriteTrackerOptions {
|
||||
/** The result window, from the send. Use `resultWindowMs(serverAdvertisesReceipts)`. */
|
||||
windowMs: number;
|
||||
/** Called once per write, at the moment it settles. */
|
||||
onSettle?: (messageId: string, settlement: WriteSettlement) => void;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
sentAtMs: number;
|
||||
state: WriteState;
|
||||
}
|
||||
|
||||
export class WriteTracker {
|
||||
readonly #windowMs: number;
|
||||
readonly #onSettle?: (messageId: string, settlement: WriteSettlement) => void;
|
||||
readonly #writes = new Map<string, Entry>();
|
||||
|
||||
constructor(options: WriteTrackerOptions) {
|
||||
this.#windowMs = options.windowMs;
|
||||
this.#onSettle = options.onSettle;
|
||||
}
|
||||
|
||||
/** Registers a write sent at `atMs`. A messageId is minted once per session: re-sending a known id is the same write. */
|
||||
send(messageId: string, atMs: number): void {
|
||||
this.tick(atMs);
|
||||
if (!this.#writes.has(messageId)) this.#writes.set(messageId, { sentAtMs: atMs, state: { outcome: "pending" } });
|
||||
}
|
||||
|
||||
/** Feeds one decoded frame received at `atMs`. Frames that are not answers to a write are ignored. */
|
||||
frame(message: CultNetMessage, atMs: number): void {
|
||||
this.tick(atMs);
|
||||
switch (message.schemaVersion) {
|
||||
case "cultnet.write_admitted.v0":
|
||||
this.#answer(message.messageId, { outcome: "admitted" }, "admitted_late");
|
||||
return;
|
||||
case "cultnet.error.v1":
|
||||
if (message.messageId !== null) {
|
||||
this.#answer(message.messageId, refusalOf(message), "refused_late");
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case "cultnet.error.v0":
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
// An error that names no write is attributable only when exactly one write is unanswered.
|
||||
const unanswered = [...this.#writes].filter(([, entry]) => entry.state.outcome === "pending");
|
||||
if (unanswered.length === 1) this.#settle(unanswered[0][0], unanswered[0][1], refusalOf(message));
|
||||
}
|
||||
|
||||
/** Advances the clock: every unanswered write whose window, counted from its send, has run settles unconfirmed. */
|
||||
tick(atMs: number): void {
|
||||
for (const [messageId, entry] of this.#writes) {
|
||||
if (entry.state.outcome === "pending" && atMs - entry.sentAtMs >= this.#windowMs) {
|
||||
this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "window_elapsed" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The session ended: every unanswered write settles unconfirmed. */
|
||||
sessionEnd(atMs: number): void {
|
||||
this.tick(atMs);
|
||||
for (const [messageId, entry] of this.#writes) {
|
||||
if (entry.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "session_ended" });
|
||||
}
|
||||
}
|
||||
|
||||
/** This client's own send of `messageId` failed: that write settles unconfirmed; the others are untouched. */
|
||||
sendFailed(messageId: string, atMs: number): void {
|
||||
const entry = this.#writes.get(messageId);
|
||||
if (entry?.state.outcome === "pending") this.#settle(messageId, entry, { outcome: "unconfirmed", reason: "local_send_failed" });
|
||||
this.tick(atMs);
|
||||
}
|
||||
|
||||
state(messageId: string): WriteState | undefined {
|
||||
return this.#writes.get(messageId)?.state;
|
||||
}
|
||||
|
||||
/** Milliseconds from `atMs` until the next unanswered write's window runs out; undefined when none is unanswered. */
|
||||
nextDeadlineMs(atMs: number): number | undefined {
|
||||
let next: number | undefined;
|
||||
for (const entry of this.#writes.values()) {
|
||||
if (entry.state.outcome !== "pending") continue;
|
||||
const remaining = Math.max(0, entry.sentAtMs + this.#windowMs - atMs);
|
||||
if (next === undefined || remaining < next) next = remaining;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
#answer(messageId: string, settlement: WriteSettlement, late: WriteRecorded): void {
|
||||
const entry = this.#writes.get(messageId);
|
||||
if (!entry) return; // names a write that is not in flight: settles nothing
|
||||
if (entry.state.outcome === "pending") {
|
||||
this.#settle(messageId, entry, settlement);
|
||||
} else if (entry.state.outcome === "unconfirmed" && entry.state.recorded === undefined) {
|
||||
entry.state = { ...entry.state, recorded: late };
|
||||
}
|
||||
}
|
||||
|
||||
#settle(messageId: string, entry: Entry, settlement: WriteSettlement): void {
|
||||
entry.state = settlement;
|
||||
this.#onSettle?.(messageId, settlement);
|
||||
}
|
||||
}
|
||||
|
||||
function refusalOf(message: CultNetErrorMessage | CultNetErrorV1Message): WriteSettlement {
|
||||
return { outcome: "refused", code: message.code, error: message.error, response: message };
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
// The presence publisher is send-only (write-receipt.md): Odin answers no write with a receipt yet, so no frame, whatever
|
||||
// id it carries, is admission. Each test serves it a fake Odin that acknowledges the put and answers as scripted.
|
||||
import assert from "node:assert/strict";
|
||||
import { createSocket, type Socket } from "node:dgram";
|
||||
import test from "node:test";
|
||||
import { encode, decode } from "@msgpack/msgpack";
|
||||
|
||||
import { encodeCultNetMessageForWire, type CultNetMessage } from "../src/contracts";
|
||||
import { IdunnPresenceRefusedError, sendPresenceDocument } from "../src/idunn-odin-presence-publisher";
|
||||
import { CultNetRudpSession, decodeRudpPacket, encodeRudpPacket } from "../src/rudp";
|
||||
|
||||
interface Script {
|
||||
answer: "silence" | "echo" | "admitted" | "refuse-v0" | "refuse-v1-other" | "refuse-v1-same" | "refuse-v1-none";
|
||||
/** Acknowledge the put? Default true. */
|
||||
ack?: boolean;
|
||||
}
|
||||
|
||||
async function fakeOdin(connectionId: number, script: Script) {
|
||||
const socket: Socket = createSocket("udp4");
|
||||
await new Promise<void>((resolve) => socket.bind(0, "127.0.0.1", resolve));
|
||||
const session = new CultNetRudpSession({ connectionId, resendDelayMs: 50 });
|
||||
const send = (packet: Parameters<typeof encodeRudpPacket>[0], port: number, address: string) => {
|
||||
try { socket.send(encodeRudpPacket(packet), port, address); } catch { /* closed */ }
|
||||
};
|
||||
const say = (message: CultNetMessage, port: number, address: string) => {
|
||||
const packets = session.sendMany("schema", encode(encodeCultNetMessageForWire(message, "cultnet.schema.v0")), { reliable: true, ordered: true, nowMs: Date.now() });
|
||||
packets.forEach((p) => send(p, port, address));
|
||||
};
|
||||
socket.on("message", (wire, remote) => {
|
||||
const packet = decodeRudpPacket(wire);
|
||||
const now = Date.now();
|
||||
if (packet.packetType === "connect") {
|
||||
send(session.acceptConnect(packet, now), remote.port, remote.address);
|
||||
return;
|
||||
}
|
||||
const result = session.receive(packet, now);
|
||||
if (result.reply) send(result.reply, remote.port, remote.address);
|
||||
for (const frame of result.delivered) {
|
||||
if (frame.channelId !== "schema") continue;
|
||||
const put = decode(frame.payload) as { messageId: string; document: unknown };
|
||||
if (script.answer === "echo") say({ schemaVersion: "cultnet.document_put_raw.v0", messageId: put.messageId, document: put.document } as unknown as CultNetMessage, remote.port, remote.address);
|
||||
else if (script.answer === "admitted") say({ schemaVersion: "cultnet.write_admitted.v0", messageId: put.messageId, operation: "put", schemaId: "s", recordKey: "k" }, remote.port, remote.address);
|
||||
else if (script.answer === "refuse-v0") say({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null }, remote.port, remote.address);
|
||||
else if (script.answer === "refuse-v1-other") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: "someone-else" }, remote.port, remote.address);
|
||||
else if (script.answer === "refuse-v1-same") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: put.messageId }, remote.port, remote.address);
|
||||
else if (script.answer === "refuse-v1-none") say({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code: "write_not_authorized", details: null, messageId: null }, remote.port, remote.address);
|
||||
}
|
||||
if (packet.reliable && script.ack !== false) send(session.createAckForReceived(packet.sequence), remote.port, remote.address);
|
||||
});
|
||||
return { port: socket.address().port, close: () => socket.close() };
|
||||
}
|
||||
|
||||
let nextConnection = 0x0d1d1000;
|
||||
const put = (id: string) => ({ schemaVersion: "cultnet.document_put_raw.v0", messageId: id, document: { schemaId: "s", recordKey: "k", storedAt: "t", payloadEncoding: "messagepack", payload: new Uint8Array([1]) } });
|
||||
const sendTo = (server: { port: number }, id: string, connectionId: number) =>
|
||||
sendPresenceDocument({ host: "127.0.0.1", port: server.port }, connectionId, put(id));
|
||||
|
||||
for (const answer of ["silence", "echo", "admitted", "refuse-v1-other"] as const) {
|
||||
test(`a ${answer} Odin yields sent, never admitted: no frame carrying the put's id is success`, async () => {
|
||||
const connectionId = nextConnection++;
|
||||
const server = await fakeOdin(connectionId, { answer });
|
||||
try {
|
||||
const outcome = await sendTo(server, "presence-1", connectionId);
|
||||
assert.equal(outcome, "sent");
|
||||
assert.notEqual(outcome as string, "admitted");
|
||||
} finally { server.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("a refusal that arrives by the transport ack rejects the put", async () => {
|
||||
const connectionId = nextConnection++;
|
||||
const server = await fakeOdin(connectionId, { answer: "refuse-v0" });
|
||||
try {
|
||||
await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
||||
for (const answer of ["refuse-v1-same", "refuse-v1-none"] as const) {
|
||||
test(`an error.v1 refusal that ${answer === "refuse-v1-same" ? "names this put" : "names no put"} rejects the put`, async () => {
|
||||
const connectionId = nextConnection++;
|
||||
const server = await fakeOdin(connectionId, { answer });
|
||||
try {
|
||||
await assert.rejects(sendTo(server, "presence-1", connectionId), IdunnPresenceRefusedError);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
}
|
||||
|
||||
test("a put the transport never acknowledges is an error, not sent", async () => {
|
||||
const connectionId = nextConnection++;
|
||||
const server = await fakeOdin(connectionId, { answer: "silence", ack: false });
|
||||
try {
|
||||
await assert.rejects(sendTo(server, "presence-1", connectionId), /Timed out/);
|
||||
} finally { server.close(); }
|
||||
});
|
||||
|
|
@ -345,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!;
|
||||
|
|
|
|||
229
packages/cultnet-ts/test/write-receipt.test.ts
Normal file
229
packages/cultnet-ts/test/write-receipt.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
CULTNET_ERROR_CODES,
|
||||
CULTNET_ERROR_V1_CODES,
|
||||
CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS,
|
||||
LEGACY_RESULT_WINDOW_MS,
|
||||
RECEIPT_RESULT_WINDOW_MS,
|
||||
WriteTracker,
|
||||
cultNetSchemas,
|
||||
helloAdvertisesWriteReceipts,
|
||||
parseCultNetMessage,
|
||||
resultWindowMs,
|
||||
type CultNetErrorV1Message,
|
||||
type CultNetMessage,
|
||||
type CultNetWriteAdmittedMessage,
|
||||
type WriteSettlement,
|
||||
} from "../src";
|
||||
|
||||
const admitted = (messageId: string): CultNetWriteAdmittedMessage =>
|
||||
({ schemaVersion: "cultnet.write_admitted.v0", messageId, operation: "put", schemaId: "note.v0", recordKey: "note:1" });
|
||||
const refusedV1 = (messageId: string | null, code: CultNetErrorV1Message["code"] = "write_not_authorized"): CultNetErrorV1Message =>
|
||||
({ schemaVersion: "cultnet.error.v1", error: "no", routingHint: null, code, details: null, messageId });
|
||||
const errorV0 = (): CultNetMessage =>
|
||||
({ schemaVersion: "cultnet.error.v0", error: "no", routingHint: null, code: "write_not_authorized", details: null });
|
||||
|
||||
test("error.v1 accepts every code of its closed enum, limit_reached included, and refuses any other", () => {
|
||||
const schemaCodes = ((cultNetSchemas.errorV1Schema.properties.code as { enum: (string | null)[] }).enum).filter((c) => c !== null);
|
||||
assert.deepEqual([...CULTNET_ERROR_V1_CODES].sort(), [...schemaCodes].sort());
|
||||
for (const code of CULTNET_ERROR_V1_CODES) parseCultNetMessage({ ...refusedV1("w1"), code });
|
||||
assert.throws(() => parseCultNetMessage({ ...refusedV1("w1"), code: "invented" }));
|
||||
});
|
||||
|
||||
test("error.v1 echoes messageId byte for byte, allows nil, and requires the key", () => {
|
||||
assert.equal((parseCultNetMessage(refusedV1("w-é 1")) as CultNetErrorV1Message).messageId, "w-é 1");
|
||||
assert.equal((parseCultNetMessage(refusedV1(null)) as CultNetErrorV1Message).messageId, null);
|
||||
const { messageId: _omitted, ...withoutId } = refusedV1("w1");
|
||||
assert.throws(() => parseCultNetMessage(withoutId));
|
||||
assert.throws(() => parseCultNetMessage(refusedV1("")));
|
||||
});
|
||||
|
||||
test("error.v0 is unaffected: still closed, no messageId, no limit_reached", () => {
|
||||
assert.deepEqual([...CULTNET_ERROR_CODES].includes("limit_reached" as never), false);
|
||||
assert.throws(() => parseCultNetMessage({ ...errorV0(), code: "limit_reached" }));
|
||||
assert.throws(() => parseCultNetMessage({ ...errorV0(), messageId: "w1" }));
|
||||
assert.equal(parseCultNetMessage(errorV0()).schemaVersion, "cultnet.error.v0");
|
||||
});
|
||||
|
||||
test("write_admitted needs all five keys, a known operation and no error key", () => {
|
||||
parseCultNetMessage(admitted("w1"));
|
||||
parseCultNetMessage({ ...admitted("w1"), operation: "delete" });
|
||||
assert.throws(() => parseCultNetMessage({ ...admitted("w1"), operation: "upsert" }));
|
||||
assert.throws(() => parseCultNetMessage({ ...admitted("w1"), error: "nope" }));
|
||||
const { recordKey: _omitted, ...short } = admitted("w1");
|
||||
assert.throws(() => parseCultNetMessage(short));
|
||||
});
|
||||
|
||||
test("a hello is receipt-capable only when it lists both frames", () => {
|
||||
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] }), true);
|
||||
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.write_admitted.v0"] }), false);
|
||||
assert.equal(helloAdvertisesWriteReceipts({ supportedMessageVersions: ["cultnet.error.v1"] }), false);
|
||||
assert.equal(helloAdvertisesWriteReceipts({}), false);
|
||||
const hello = parseCultNetMessage({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k", supportedMessageVersions: [...CULTNET_WRITE_RECEIPT_MESSAGE_VERSIONS] });
|
||||
assert.equal(hello.schemaVersion, "cultnet.hello.v0");
|
||||
});
|
||||
|
||||
test("the window is 5000 ms for a receipt-capable server and 500 ms otherwise", () => {
|
||||
assert.equal(resultWindowMs(true), RECEIPT_RESULT_WINDOW_MS);
|
||||
assert.equal(resultWindowMs(false), LEGACY_RESULT_WINDOW_MS);
|
||||
assert.equal(RECEIPT_RESULT_WINDOW_MS, 5000);
|
||||
assert.equal(LEGACY_RESULT_WINDOW_MS, 500);
|
||||
});
|
||||
|
||||
function tracker(windowMs = 500) {
|
||||
const told: [string, WriteSettlement][] = [];
|
||||
return { told, t: new WriteTracker({ windowMs, onSettle: (id, s) => told.push([id, s]) }) };
|
||||
}
|
||||
|
||||
test("tracker: the window is inclusive at exactly windowMs and open one ms before", () => {
|
||||
const { t, told } = tracker();
|
||||
t.send("w1", 100);
|
||||
t.tick(599);
|
||||
assert.equal(t.state("w1")?.outcome, "pending");
|
||||
t.tick(600);
|
||||
assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]);
|
||||
});
|
||||
|
||||
test("tracker: each write's window runs from its own send", () => {
|
||||
const { t } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.send("w2", 300);
|
||||
t.tick(500);
|
||||
assert.equal(t.state("w1")?.outcome, "unconfirmed");
|
||||
assert.equal(t.state("w2")?.outcome, "pending");
|
||||
});
|
||||
|
||||
test("tracker: a frame arriving after the window cannot admit the write; it is recorded late", () => {
|
||||
const { t, told } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.frame(admitted("w1"), 500);
|
||||
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed", recorded: "admitted_late" });
|
||||
assert.equal(told.length, 1);
|
||||
});
|
||||
|
||||
test("tracker: a write settles once; later frames neither change it nor flip a late record", () => {
|
||||
const { t, told } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.frame(refusedV1("w1"), 10);
|
||||
t.frame(admitted("w1"), 20);
|
||||
t.frame(refusedV1("w1", "message_invalid"), 30);
|
||||
t.sessionEnd(40);
|
||||
assert.equal(told.length, 1);
|
||||
assert.deepEqual(told[0][1], { outcome: "refused", code: "write_not_authorized", error: "no", response: refusedV1("w1") });
|
||||
t.send("w2", 100);
|
||||
t.tick(600);
|
||||
t.frame(refusedV1("w2"), 700);
|
||||
t.frame(admitted("w2"), 800);
|
||||
assert.equal(t.state("w2")?.outcome === "unconfirmed" && t.state("w2")?.recorded, "refused_late");
|
||||
});
|
||||
|
||||
test("tracker: an id-less error refuses the single unanswered write only, counting answered writes out", () => {
|
||||
const { t } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.send("w2", 1);
|
||||
t.frame(admitted("w1"), 5);
|
||||
t.frame(refusedV1(null), 6);
|
||||
assert.equal(t.state("w1")?.outcome, "admitted");
|
||||
assert.equal(t.state("w2")?.outcome, "refused");
|
||||
});
|
||||
|
||||
test("tracker: an id-less error never reaches a write that already ended unconfirmed", () => {
|
||||
const { t } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.tick(500);
|
||||
t.frame(errorV0(), 501);
|
||||
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
|
||||
});
|
||||
|
||||
test("tracker: sessionEnd settles unanswered writes only, and an expired window keeps its own reason", () => {
|
||||
const { t } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.send("w2", 0);
|
||||
t.send("w3", 400);
|
||||
t.frame(admitted("w2"), 10);
|
||||
t.sessionEnd(600);
|
||||
assert.deepEqual(t.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
|
||||
assert.equal(t.state("w2")?.outcome, "admitted");
|
||||
assert.deepEqual(t.state("w3"), { outcome: "unconfirmed", reason: "session_ended" });
|
||||
});
|
||||
|
||||
test("tracker: re-sending a known id is the same write and does not restart its window", () => {
|
||||
const { t } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.send("w1", 400);
|
||||
t.tick(500);
|
||||
assert.equal(t.state("w1")?.outcome, "unconfirmed");
|
||||
});
|
||||
|
||||
test("tracker: a send past another write's window settles that write at once", () => {
|
||||
const { t, told } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.send("w2", 600);
|
||||
assert.deepEqual(told, [["w1", { outcome: "unconfirmed", reason: "window_elapsed" }]]);
|
||||
});
|
||||
|
||||
test("tracker: only receipt and error frames answer; any other frame naming the id settles nothing", () => {
|
||||
const { t } = tracker();
|
||||
t.send("w1", 0);
|
||||
t.frame({ schemaVersion: "cultnet.hello.v0", runtimeId: "r", runtimeKind: "k" }, 1);
|
||||
t.frame({ schemaVersion: "cultnet.document_put_raw.v0", messageId: "w1" } as unknown as CultNetMessage, 2);
|
||||
assert.equal(t.state("w1")?.outcome, "pending");
|
||||
});
|
||||
|
||||
test("tracker: nextDeadlineMs reports the nearest unanswered window, none when all settled", () => {
|
||||
const { t } = tracker();
|
||||
assert.equal(t.nextDeadlineMs(0), undefined);
|
||||
t.send("w1", 0);
|
||||
t.send("w2", 100);
|
||||
assert.equal(t.nextDeadlineMs(50), 450);
|
||||
assert.equal(t.nextDeadlineMs(900), 0);
|
||||
t.frame(admitted("w1"), 60);
|
||||
assert.equal(t.nextDeadlineMs(150), 450);
|
||||
t.frame(admitted("w2"), 160);
|
||||
assert.equal(t.nextDeadlineMs(170), undefined);
|
||||
});
|
||||
|
||||
test("the replay runner passes every vector against this runtime", () => {
|
||||
const script = join(__dirname, "..", "..", "tools", "replay-write-receipt-vectors.mjs");
|
||||
const run = spawnSync(process.execPath, [script], { encoding: "utf8" });
|
||||
assert.equal(run.status, 0, run.stdout + run.stderr);
|
||||
assert.match(run.stdout, /^PASS scenario\/receipt_admits$/m);
|
||||
assert.doesNotMatch(run.stdout, /^FAIL/m);
|
||||
});
|
||||
|
||||
test("a failed local send settles only that write unconfirmed as local_send_failed, and an answer after it is recorded late", () => {
|
||||
const settled: [string, WriteSettlement][] = [];
|
||||
const tracker = new WriteTracker({ windowMs: 1000, onSettle: (id, settlement) => settled.push([id, settlement]) });
|
||||
tracker.send("w1", 0);
|
||||
tracker.send("w2", 0);
|
||||
tracker.sendFailed("w1", 10);
|
||||
assert.deepEqual(settled, [["w1", { outcome: "unconfirmed", reason: "local_send_failed" }]]);
|
||||
assert.equal(tracker.state("w2")?.outcome, "pending");
|
||||
tracker.frame(admitted("w1"), 20);
|
||||
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed", recorded: "admitted_late" });
|
||||
tracker.sendFailed("w2", 30);
|
||||
tracker.sendFailed("w2", 31);
|
||||
assert.equal(settled.length, 2, "a write settles once");
|
||||
tracker.sendFailed("unknown", 40);
|
||||
assert.equal(tracker.state("unknown"), undefined);
|
||||
});
|
||||
|
||||
test("a failed local send advances the clock: other writes whose windows have run settle window_elapsed", () => {
|
||||
const tracker = new WriteTracker({ windowMs: 1000 });
|
||||
tracker.send("w1", 0);
|
||||
tracker.send("w2", 500);
|
||||
tracker.sendFailed("w2", 1200);
|
||||
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "window_elapsed" });
|
||||
assert.deepEqual(tracker.state("w2"), { outcome: "unconfirmed", reason: "local_send_failed" });
|
||||
});
|
||||
|
||||
test("a write whose own send failed settles local_send_failed even when its window has also run", () => {
|
||||
const tracker = new WriteTracker({ windowMs: 1000 });
|
||||
tracker.send("w1", 0);
|
||||
tracker.sendFailed("w1", 1000);
|
||||
assert.deepEqual(tracker.state("w1"), { outcome: "unconfirmed", reason: "local_send_failed" });
|
||||
});
|
||||
116
packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs
Normal file
116
packages/cultnet-ts/tools/replay-write-receipt-vectors.mjs
Normal file
|
|
@ -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);
|
||||
Loading…
Reference in a new issue