mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-06 23:44:36 +00:00
The owner's audit decisions (decisions.md, "Simplification audit"): cut the reload-only installing state (staging is synchronous) and the static registry cycle edges (the runtime guard stays); retry propagation by a visited set and a hop budget (retry.hops) instead of operation ids and through lists; a pure swap sends holds a fresh bound; one rule after a live failure (every member followed until the admitted commits published); parked_long for any long park, naming the first blocker in declaration order with its state; one seal token per attempt. Keys follow the old instance during a restart's drain like members. src/next: about 255 code lines fewer.
629 lines
26 KiB
TypeScript
629 lines
26 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
|
import {
|
|
binaryCodec,
|
|
checkMessage,
|
|
DeflatedPart,
|
|
describeMessage,
|
|
jsonPart,
|
|
type Message,
|
|
parsePart,
|
|
parsePayload,
|
|
payloadPart,
|
|
textCodec,
|
|
} from "../../../src/next/link/internal.ts";
|
|
import type { Part } from "../../../src/next/link/part.ts";
|
|
import { forEachCodec, testCodec } from "../support.ts";
|
|
|
|
/** A part as JSON text: a received part's bytes decoded (a deflated part stays as it is). */
|
|
function partText(part: Part): Part {
|
|
return part instanceof Uint8Array ? new TextDecoder().decode(part) : part;
|
|
}
|
|
|
|
const incarnation = "0190f5a2-7c3e-7d4a-9b1c-2d3e4f5a6b7c";
|
|
|
|
/** The address a hold names: `Server()` on the wire. */
|
|
const server = { kind: "server", params: {} };
|
|
const error = { kind: "chord", error: { code: "released", message: "gone" } } as const;
|
|
|
|
/** One message of every kind, with the optional fields both present and absent; parts are JSON text. */
|
|
const EVERY_KIND: Message[] = [
|
|
{ kind: "hello", protocol: 1, incarnation, calls: 256 },
|
|
{
|
|
kind: "hold",
|
|
hold: 300,
|
|
type: "app.notes",
|
|
version: 2,
|
|
fingerprint: "0123456789abcdef",
|
|
params: '{"id":1}',
|
|
address: server,
|
|
},
|
|
{
|
|
kind: "hold",
|
|
hold: 1,
|
|
type: "app.notes",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: '{"session":"S1"}',
|
|
address: { kind: "session", params: { id: "S1" } },
|
|
principal: { kind: "guest" },
|
|
incarnation,
|
|
},
|
|
{
|
|
kind: "hold",
|
|
hold: 2,
|
|
type: "app.notes",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: "{}",
|
|
address: server,
|
|
incarnation,
|
|
},
|
|
{ kind: "release", hold: 0 },
|
|
{ kind: "call", call: 3, hold: 0, method: 1, args: '{"text":"x"}' },
|
|
{ kind: "call", call: 4, hold: 0, method: 0 },
|
|
{ kind: "abort", call: 3 },
|
|
{
|
|
kind: "bound",
|
|
holds: [{ hold: 0, variant: 1, members: [{ member: 0, value: '[1,"a"]' }], failed: [{ member: 1, error }] }],
|
|
},
|
|
{
|
|
kind: "bound",
|
|
holds: [
|
|
{ hold: 0, variant: 0, members: [], failed: [] },
|
|
{ hold: 5, variant: 2, members: [{ member: 1, value: "null" }], failed: [] },
|
|
],
|
|
},
|
|
{
|
|
kind: "state",
|
|
holds: [
|
|
{ hold: 0, members: [{ member: 2, ops: '[["s",["n"],1]]' }], failed: [], retired: [] },
|
|
{ hold: 1, members: [], failed: [{ member: 0, error }], retired: [] },
|
|
// Retired members (decision "One retirement mechanism"), alone and with a failure.
|
|
{ hold: 2, members: [{ member: 1, ops: '[["r",{}]]' }], failed: [], retired: [1, 3] },
|
|
{ hold: 3, members: [], failed: [{ member: 0, error }], retired: [2] },
|
|
],
|
|
},
|
|
{
|
|
kind: "failed",
|
|
hold: 0,
|
|
cause: { kind: "transient", error: { kind: "chord", error: { code: "unavailable", message: "m" } } },
|
|
},
|
|
{
|
|
kind: "failed",
|
|
hold: 0,
|
|
cause: { kind: "broken", error: { kind: "chord", error: { code: "unavailable", message: "m" } } },
|
|
},
|
|
{
|
|
kind: "failed",
|
|
hold: 0,
|
|
cause: { kind: "transient", error: { kind: "chord", error: { code: "unavailable", message: "m" } } },
|
|
retryIn: 0,
|
|
},
|
|
// Not the object's fault (dependency model §2): no backoff window, an event wakes it.
|
|
{
|
|
kind: "failed",
|
|
hold: 0,
|
|
cause: { kind: "waiting", error: { kind: "chord", error: { code: "contract_mismatch", message: "m" } } },
|
|
},
|
|
// The wire error format in `failed` and `gone`: a plugin's error travels too.
|
|
{ kind: "failed", hold: 0, cause: { kind: "broken", error: { kind: "plugin", code: "config", message: "m" } } },
|
|
{ kind: "gone", hold: 1, error: { kind: "plugin", code: "config", message: "m", details: { at: 1 } } },
|
|
{
|
|
kind: "failed",
|
|
hold: 9,
|
|
cause: { kind: "transient", error: { kind: "chord", error: { code: "unavailable", message: "m" } } },
|
|
retryIn: 60_000,
|
|
},
|
|
// With its hop budget (audit A5): the first host's, and the last one passed on.
|
|
{ kind: "retry", hold: 300, hops: 16 },
|
|
{ kind: "retry", hold: 300, hops: 1 },
|
|
{ kind: "loading", hold: 300 },
|
|
{ kind: "result", call: 3, value: '{"n":1}' },
|
|
{ kind: "result", call: 3 },
|
|
{ kind: "error", call: 3, error: { kind: "plugin", code: "c", message: "m", details: [1] } },
|
|
{ kind: "gone", hold: 0, error: { kind: "chord", error: { code: "released", message: "m" } } },
|
|
{ kind: "ack" },
|
|
{ kind: "ack", origin: 1 },
|
|
{ kind: "closed", origin: 300 },
|
|
{
|
|
kind: "hold",
|
|
origin: 2,
|
|
hold: 0,
|
|
type: "t",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: "{}",
|
|
address: server,
|
|
},
|
|
{ kind: "release", origin: 2, hold: 3 },
|
|
{ kind: "call", origin: 2, call: 1, hold: 3, method: 0, args: "[1]" },
|
|
{ kind: "abort", origin: 2, call: 1 },
|
|
{ kind: "retry", origin: 2, hold: 3, hops: 16 },
|
|
{ kind: "bound", origin: 2, holds: [{ hold: 3, variant: 0, members: [], failed: [] }] },
|
|
{ kind: "state", origin: 2, holds: [] },
|
|
{
|
|
kind: "failed",
|
|
origin: 2,
|
|
hold: 3,
|
|
cause: { kind: "broken", error: { kind: "chord", error: { code: "unavailable", message: "m" } } },
|
|
},
|
|
{ kind: "loading", origin: 2, hold: 3 },
|
|
{ kind: "result", origin: 2, call: 1 },
|
|
{ kind: "error", origin: 2, call: 1, error: { kind: "plugin", code: "c", message: "m" } },
|
|
{ kind: "gone", origin: 2, hold: 3, error: { kind: "chord", error: { code: "released", message: "m" } } },
|
|
{
|
|
kind: "hold",
|
|
hold: 3,
|
|
type: "app.notes",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: "{}",
|
|
address: server,
|
|
chain: [incarnation, incarnation],
|
|
limit: 65_536,
|
|
},
|
|
{
|
|
kind: "hold",
|
|
hold: 4,
|
|
type: "t",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: "{}",
|
|
address: server,
|
|
limit: 0,
|
|
},
|
|
{ kind: "revoke", revocation: { grant: "g1" } },
|
|
{ kind: "ping" },
|
|
{ kind: "pong" },
|
|
];
|
|
|
|
/** A decoded message with every part as its text, to compare with one built from text parts. */
|
|
function plain(message: Message): Message {
|
|
switch (message.kind) {
|
|
case "hold":
|
|
return { ...message, params: partText(message.params) };
|
|
case "call":
|
|
return message.args === undefined ? message : { ...message, args: partText(message.args) };
|
|
case "result":
|
|
return message.value === undefined ? message : { ...message, value: partText(message.value) };
|
|
case "bound":
|
|
return {
|
|
...message,
|
|
holds: message.holds.map((entry) => ({
|
|
...entry,
|
|
members: entry.members.map(({ member, value }) => ({ member, value: partText(value) })),
|
|
})),
|
|
};
|
|
case "state":
|
|
return {
|
|
...message,
|
|
holds: message.holds.map((entry) => ({
|
|
...entry,
|
|
members: entry.members.map(({ member, ops }) => ({ member, ops: partText(ops) })),
|
|
})),
|
|
};
|
|
default:
|
|
return message;
|
|
}
|
|
}
|
|
|
|
forEachCodec(() => {
|
|
describe("messages", () => {
|
|
it.each(EVERY_KIND)("round-trip %j and pass the message schema", (message) => {
|
|
const codec = testCodec();
|
|
const decoded = checkMessage(codec.decode(codec.encode(message)), context);
|
|
expect(plain(decoded)).toEqual(message);
|
|
});
|
|
|
|
it("round-trip deflated parts in every part's place, still deflated (a relay forwards them so)", () => {
|
|
const codec = testCodec();
|
|
const deflated = () => new DeflatedPart(new Uint8Array([0, 1, 2, 255]));
|
|
const messages: Message[] = [
|
|
{
|
|
kind: "hold",
|
|
hold: 0,
|
|
type: "t",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: deflated(),
|
|
address: server,
|
|
},
|
|
{ kind: "call", call: 0, hold: 0, method: 0, args: deflated() },
|
|
{ kind: "result", call: 0, value: deflated(), segments: [new Uint8Array([7])] },
|
|
{
|
|
kind: "bound",
|
|
holds: [{ hold: 0, variant: 0, members: [{ member: 0, value: deflated() }], failed: [] }],
|
|
},
|
|
{ kind: "state", holds: [{ hold: 0, members: [{ member: 0, ops: deflated() }], failed: [], retired: [] }] },
|
|
];
|
|
for (const message of messages) {
|
|
const decoded = checkMessage(codec.decode(codec.encode(message)), context);
|
|
expect(decoded).toEqual(message);
|
|
}
|
|
});
|
|
|
|
it("round-trip parts received as bytes (a relay forwards them as they came)", () => {
|
|
const codec = testCodec();
|
|
const message: Message = { kind: "result", call: 0, value: new TextEncoder().encode('{"n":1}') };
|
|
expect(plain(codec.decode(codec.encode(message)))).toEqual({ kind: "result", call: 0, value: '{"n":1}' });
|
|
});
|
|
|
|
it("carry byte segments beside a payload, as views of the message", () => {
|
|
const codec = testCodec();
|
|
const data = new Uint8Array(1024).fill(7);
|
|
const thumbs = [new Uint8Array([1, 2]), new Uint8Array([3])];
|
|
const { json, segments } = payloadPart({ name: "a", data, thumbs }, true);
|
|
const message: Message = { kind: "call", call: 0, hold: 0, method: 0, args: json, segments };
|
|
const bytes = codec.encode(message);
|
|
const decoded = codec.decode(bytes) as Extract<Message, { kind: "call" }>;
|
|
expect(decoded.segments).toHaveLength(3);
|
|
expect(decoded.segments![0]!.buffer).toBe(bytes.buffer);
|
|
const args = parsePayload(decoded.args as Uint8Array, decoded.segments) as {
|
|
data: Uint8Array;
|
|
thumbs: Uint8Array[];
|
|
};
|
|
expect(args.data).toEqual(data);
|
|
expect(args.data.buffer).toBe(bytes.buffer);
|
|
expect(args.thumbs).toEqual(thumbs);
|
|
// The segments travel raw: the message is barely larger than the bytes it carries.
|
|
expect(bytes.byteLength).toBeLessThan(1024 + 3 + 100);
|
|
});
|
|
|
|
it("refuse an empty message, an unknown kind and trailing bytes", () => {
|
|
const codec = testCodec();
|
|
const release = codec.encode({ kind: "release", hold: 1 });
|
|
const trailing = new Uint8Array([...release, ...(codec === binaryCodec ? [0] : [0x20, 0x31])]);
|
|
expect(() => codec.decode(new Uint8Array())).toThrow();
|
|
expect(() =>
|
|
codec.decode(codec === binaryCodec ? new Uint8Array([99]) : new TextEncoder().encode('["?"]')),
|
|
).toThrow("unknown kind");
|
|
expect(() => codec.decode(trailing)).toThrow();
|
|
});
|
|
|
|
it("refuse an origin on a kind without one, an origin 0, and a closed without one (the message schema)", () => {
|
|
const codec = testCodec();
|
|
const check = (message: unknown) => () =>
|
|
checkMessage(codec.decode(codec.encode(message as Message)), context);
|
|
expect(check({ kind: "ping", origin: 1 })).toThrow("message schema");
|
|
expect(check({ kind: "ack", origin: 0 })).toThrow("message schema");
|
|
const closed = codec === binaryCodec ? new Uint8Array([14]) : new TextEncoder().encode('["C"]');
|
|
expect(() => checkMessage(codec.decode(closed), context)).toThrow("message schema");
|
|
});
|
|
|
|
it("keep a leading U+FEFF in strings and parts (it is data, not a byte order mark)", () => {
|
|
const codec = testCodec();
|
|
const message: Message = {
|
|
kind: "hold",
|
|
hold: 0,
|
|
type: "\uFEFFthing",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: jsonPart({ "\uFEFFkey": "\uFEFFvalue" }),
|
|
address: server,
|
|
};
|
|
const decoded = codec.decode(codec.encode(message)) as Extract<Message, { kind: "hold" }>;
|
|
expect(plain(decoded)).toEqual(message);
|
|
expect(parsePart(decoded.params as string | Uint8Array)).toEqual({ "\uFEFFkey": "\uFEFFvalue" });
|
|
const result: Message = { kind: "result", call: 0, value: jsonPart("\uFEFF") };
|
|
expect(plain(codec.decode(codec.encode(result)))).toEqual(result);
|
|
});
|
|
|
|
it("leave the envelope to the message schema: a misshapen error passes the codec and fails the schema", () => {
|
|
const codec = testCodec();
|
|
const decoded = codec.decode(codec.encode({ kind: "error", call: 0, error: { kind: "nope" } } as never));
|
|
expect(() => checkMessage(decoded, context)).toThrow("message: fails the message schema");
|
|
expect(() => checkMessage({ kind: "release", hold: -1 }, context)).toThrow(
|
|
"message: fails the message schema",
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("the binary codec", () => {
|
|
it("writes ids as varints, the incarnation as 16 and the fingerprint as 8 raw bytes, no outer length", () => {
|
|
expect(binaryCodec.encode({ kind: "hello", protocol: 1, incarnation, calls: 1 })).toHaveLength(1 + 1 + 16 + 1);
|
|
expect([...binaryCodec.encode({ kind: "release", hold: 300 })]).toEqual([2, 0xac, 0x02]);
|
|
const hold = binaryCodec.encode({
|
|
kind: "hold",
|
|
hold: 0,
|
|
type: "t",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: "{}",
|
|
address: server,
|
|
});
|
|
// kind, id, type, version, fingerprint, params, the address's kind and params, the optional fields' flags
|
|
expect(hold).toHaveLength(1 + 1 + 2 + 1 + 8 + 3 + 7 + 3 + 1);
|
|
expect([...binaryCodec.encode({ kind: "ack" })]).toEqual([13]);
|
|
// An origin: the kind byte's high bit, then the origin as a varint.
|
|
expect([...binaryCodec.encode({ kind: "ack", origin: 300 })]).toEqual([13 | 0x80, 0xac, 0x02]);
|
|
expect([...binaryCodec.encode({ kind: "closed", origin: 1 })]).toEqual([14 | 0x80, 1]);
|
|
});
|
|
|
|
it("writes a failure's cause as one byte, 0 transient, 1 broken, 2 waiting (dependency model §2); refuses another", () => {
|
|
const error = { kind: "chord", error: { code: "unavailable", message: "m" } } as const;
|
|
const failed = (kind: "transient" | "broken" | "waiting"): Message => ({
|
|
kind: "failed",
|
|
hold: 0,
|
|
cause: { kind, error },
|
|
});
|
|
expect(
|
|
["transient", "broken", "waiting"].map((kind) => binaryCodec.encode(failed(kind as "waiting"))[2]),
|
|
).toEqual([0, 1, 2]);
|
|
const unknown = binaryCodec.encode(failed("waiting"));
|
|
unknown[2] = 3;
|
|
expect(() => binaryCodec.decode(unknown)).toThrow("an unknown failure kind");
|
|
});
|
|
|
|
it("costs a state entry without retirements no byte: the failure count is doubled, plus one when they follow", () => {
|
|
const state = (retired: number[]): Message => ({
|
|
kind: "state",
|
|
holds: [{ hold: 0, members: [], failed: [], retired }],
|
|
});
|
|
// kind, entry count, hold, member count, failures (0 doubled)
|
|
expect([...binaryCodec.encode(state([]))]).toEqual([6, 1, 0, 0, 0]);
|
|
// the low bit set, then the retired count and member
|
|
expect([...binaryCodec.encode(state([3]))]).toEqual([6, 1, 0, 0, 1, 1, 3]);
|
|
const first = binaryCodec.decode(binaryCodec.encode(state([])));
|
|
const second = binaryCodec.decode(binaryCodec.encode(state([])));
|
|
const retired = (message: Message) => (message as Extract<Message, { kind: "state" }>).holds[0]!.retired;
|
|
// No allocation per message: decoded entries without retirements share one empty list.
|
|
expect(retired(first)).toBe(retired(second));
|
|
});
|
|
|
|
it("prefixes a part with its length doubled, plus one when it is deflated", () => {
|
|
const plain = binaryCodec.encode({ kind: "result", call: 0, value: "12" });
|
|
const deflated = binaryCodec.encode({ kind: "result", call: 0, value: new DeflatedPart(new Uint8Array([7, 8])) });
|
|
// kind, call, payload flags, prefix, bytes
|
|
expect([...plain]).toEqual([8, 0, 1, 4, 0x31, 0x32]);
|
|
expect([...deflated]).toEqual([8, 0, 1, 5, 7, 8]);
|
|
});
|
|
|
|
it("decodes parts as views of the message, never parsing them: a malformed part decodes", () => {
|
|
const part = new TextEncoder().encode("{not json");
|
|
const bytes = binaryCodec.encode({ kind: "result", call: 0, value: part });
|
|
const decoded = binaryCodec.decode(bytes) as Extract<Message, { kind: "result" }>;
|
|
const value = decoded.value as Uint8Array;
|
|
expect(value.buffer).toBe(bytes.buffer);
|
|
expect(() => parsePart(value)).toThrow();
|
|
});
|
|
|
|
it("refuses varints past a safe integer and lengths past the message", () => {
|
|
expect(() => binaryCodec.decode(new Uint8Array([2, ...new Array(8).fill(0xff), 0x7f]))).toThrow(
|
|
"a varint does not fit",
|
|
);
|
|
expect(() => binaryCodec.decode(new Uint8Array([1, 0, 9, 0x61]))).toThrow("the message ends early");
|
|
});
|
|
|
|
it("refuses a varint of more than 8 bytes, which would read as no number", () => {
|
|
const state = new Uint8Array([6, ...new Array(150).fill(0x80), 0]);
|
|
expect(() => binaryCodec.decode(state)).toThrow("a varint does not fit");
|
|
});
|
|
|
|
it("refuses unknown hold flags", () => {
|
|
const hold = binaryCodec.encode({
|
|
kind: "hold",
|
|
hold: 0,
|
|
type: "t",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: "{}",
|
|
address: server,
|
|
});
|
|
const forged = hold.slice();
|
|
forged[forged.length - 1] = 32;
|
|
expect(() => binaryCodec.decode(forged)).toThrow("unknown hold flags");
|
|
});
|
|
|
|
it("returns a right-sized message: a large one keeps no larger backing store", () => {
|
|
const value = jsonPart("x".repeat(500_000));
|
|
// Small fields after the large value: they must not double the buffer the message keeps.
|
|
const members = [
|
|
{ member: 0, value },
|
|
{ member: 1, value: "1" },
|
|
];
|
|
const bytes = binaryCodec.encode({ kind: "bound", holds: [{ hold: 0, variant: 0, members, failed: [] }] });
|
|
expect(bytes.buffer.byteLength - bytes.byteLength).toBeLessThanOrEqual(4096);
|
|
const small = binaryCodec.encode({ kind: "release", hold: 1 });
|
|
expect(small.buffer.byteLength).toBe(small.byteLength);
|
|
// A message of about two thirds of the starting buffer keeps no spare room either.
|
|
const medium = binaryCodec.encode({ kind: "result", call: 0, value: jsonPart("x".repeat(160)) });
|
|
expect(medium.buffer.byteLength).toBe(medium.byteLength);
|
|
});
|
|
|
|
it("refuses invalid UTF-8 in strings", () => {
|
|
expect(() => binaryCodec.decode(new Uint8Array([1, 0, 1, 0xff]))).toThrow();
|
|
});
|
|
|
|
it("writes retry as its kind, hold and hops, loading as its kind and hold, and failed's retryIn as one more than it (0: absent)", () => {
|
|
expect([...binaryCodec.encode({ kind: "retry", hold: 300, hops: 16 })]).toEqual([11, 0xac, 0x02, 16]);
|
|
expect([...binaryCodec.encode({ kind: "loading", hold: 1 })]).toEqual([12, 1]);
|
|
const cause = { kind: "broken", error: { kind: "chord", error: { code: "released", message: "" } } } as const;
|
|
const absent = binaryCodec.encode({ kind: "failed", hold: 0, cause });
|
|
const present = binaryCodec.encode({ kind: "failed", hold: 0, cause, retryIn: 0 });
|
|
expect(absent.at(-1)).toBe(0);
|
|
expect(present.at(-1)).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("the text codec", () => {
|
|
const text = (message: Message) => new TextDecoder().decode(textCodec.encode(message));
|
|
|
|
it("writes one JSON tuple per message with short tags and positional fields, parts spliced in", () => {
|
|
expect(text({ kind: "release", hold: 3 })).toBe('["R",3]');
|
|
expect(text({ kind: "call", call: 1, hold: 0, method: 2 })).toBe('["c",1,0,2]');
|
|
const members = [{ member: 0, value: "5" }];
|
|
expect(text({ kind: "bound", holds: [{ hold: 0, variant: 1, members, failed: [] }] })).toBe(
|
|
'["b",[[0,1,[[0,[0,5]]]]]]',
|
|
);
|
|
expect(text({ kind: "retry", hold: 2, hops: 16 })).toBe('["T",2,16]');
|
|
expect(text({ kind: "loading", hold: 2 })).toBe('["l",2]');
|
|
const cause = {
|
|
kind: "transient",
|
|
error: { kind: "chord", error: { code: "unavailable", message: "m" } },
|
|
} as const;
|
|
expect(text({ kind: "failed", hold: 2, cause })).toBe(
|
|
'["f",2,"t",{"kind":"chord","error":{"code":"unavailable","message":"m"}}]',
|
|
);
|
|
expect(text({ kind: "failed", hold: 2, cause, retryIn: 750 })).toBe(
|
|
'["f",2,"t",{"kind":"chord","error":{"code":"unavailable","message":"m"}},750]',
|
|
);
|
|
expect(text({ kind: "failed", hold: 2, cause: { ...cause, kind: "waiting" } })).toBe(
|
|
'["f",2,"w",{"kind":"chord","error":{"code":"unavailable","message":"m"}}]',
|
|
);
|
|
expect(() => textCodec.decode(new TextEncoder().encode('["f",2,"x",{}]'))).toThrow("an unknown failure kind");
|
|
expect(text({ kind: "ack" })).toBe('["a"]');
|
|
// An origin wraps the message's own tuple.
|
|
expect(text({ kind: "release", origin: 4, hold: 3 })).toBe('["@",4,["R",3]]');
|
|
expect(text({ kind: "closed", origin: 4 })).toBe('["@",4,["C"]]');
|
|
});
|
|
|
|
it("tags every part with its codec, [0, json] or [1, base64] for a deflated one, so no value is reserved", () => {
|
|
const message: Message = { kind: "result", call: 1, value: new DeflatedPart(new Uint8Array([1, 2, 3])) };
|
|
expect(text(message)).toBe('["r",1,[1,"AQID"]]');
|
|
expect(textCodec.decode(textCodec.encode(message))).toEqual(message);
|
|
// A value of any shape is data, the one-key `$deflated` object the first builder reserved included.
|
|
const shaped: Message = { kind: "result", call: 1, value: '{"$deflated":"AQID"}' };
|
|
expect(text(shaped)).toBe('["r",1,[0,{"$deflated":"AQID"}]]');
|
|
expect(textCodec.decode(textCodec.encode(shaped))).toEqual(shaped);
|
|
expect(text({ kind: "revoke", revocation: { grant: "g" } })).toBe('["V",{"grant":"g"}]');
|
|
});
|
|
|
|
it("refuses a part that is no [codec, payload] pair, or of an unknown codec", () => {
|
|
const decode = (tuple: string) => textCodec.decode(new TextEncoder().encode(tuple));
|
|
expect(() => decode('["r",1,{"n":1}]')).toThrow("no [codec, payload] pair");
|
|
expect(() => decode('["r",1,[2,"AQID"]]')).toThrow("unknown codec");
|
|
expect(() => decode('["r",1,[1,5]]')).toThrow("unknown codec");
|
|
});
|
|
|
|
it("carries a binary link's deflated part over a text hop and back unchanged (a relay between codecs)", () => {
|
|
const part = new DeflatedPart(new Uint8Array([9, 8, 7]));
|
|
const message: Message = {
|
|
kind: "state",
|
|
holds: [{ hold: 0, members: [{ member: 0, ops: part }], failed: [], retired: [] }],
|
|
};
|
|
const overText = textCodec.decode(textCodec.encode(binaryCodec.decode(binaryCodec.encode(message))));
|
|
expect(binaryCodec.decode(binaryCodec.encode(overText))).toEqual(message);
|
|
});
|
|
|
|
it("leaves out a hold's absent optional fields at the end, null in the middle", () => {
|
|
const hold = {
|
|
kind: "hold",
|
|
hold: 0,
|
|
type: "t",
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: "{}",
|
|
address: server,
|
|
} as const;
|
|
const head = '["H",0,"t",1,"0123456789abcdef",[0,{}],["server",{}]';
|
|
expect(text(hold)).toBe(`${head}]`);
|
|
expect(text({ ...hold, incarnation })).toBe(`${head},null,"${incarnation}"]`);
|
|
expect(text({ ...hold, limit: 5 })).toBe(`${head},null,null,null,5]`);
|
|
});
|
|
|
|
it("puts byte segments after the tuple and a newline, their lengths the tuple's last field", () => {
|
|
const bytes = textCodec.encode({
|
|
kind: "result",
|
|
call: 0,
|
|
value: '{"$bytes":0}',
|
|
segments: [new Uint8Array([1, 2])],
|
|
});
|
|
expect([...bytes]).toEqual([...new TextEncoder().encode('["r",0,[0,{"$bytes":0}],[2]]\n'), 1, 2]);
|
|
});
|
|
|
|
it("refuses a tuple of the wrong arity and segments without a payload carrying them", () => {
|
|
expect(() => textCodec.decode(new TextEncoder().encode('["R",1,2]'))).toThrow('a "R" message with 2 fields');
|
|
expect(() => textCodec.decode(new TextEncoder().encode('["s",[[0,[],[],[],9]]]'))).toThrow(
|
|
"a state entry with 5 fields",
|
|
);
|
|
expect(() => textCodec.decode(new TextEncoder().encode('["s",[[0]]]'))).toThrow("a state entry with 1 fields");
|
|
expect(() => textCodec.decode(new Uint8Array([...new TextEncoder().encode('["R",1]\n'), 1]))).toThrow(
|
|
"byte segments without a payload",
|
|
);
|
|
expect(() => textCodec.decode(new TextEncoder().encode('["@",1,["@",2,["a"]]]'))).toThrow("wraps one message");
|
|
expect(() => textCodec.decode(new TextEncoder().encode('["@",1]'))).toThrow("wraps one message");
|
|
});
|
|
|
|
it("refuses a hold without its address: every hold names one", () => {
|
|
const decode = (tuple: string) => textCodec.decode(new TextEncoder().encode(tuple));
|
|
expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}]]')).toThrow('a "H" message with 5 fields');
|
|
expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}],null]')).toThrow("expected a list");
|
|
expect(() => decode('["H",0,"t",1,"0123456789abcdef",[0,{}],["server"]]')).toThrow("an address is a pair");
|
|
const hold = { kind: "hold", hold: 0, type: "t", version: 1, fingerprint: "0123456789abcdef", params: "{}" };
|
|
expect(() => checkMessage(hold, context)).toThrow("message schema");
|
|
});
|
|
|
|
it("refuses a retry without its hop budget, or with one outside 1..16 (audit A5)", () => {
|
|
const decode = (tuple: string) => textCodec.decode(new TextEncoder().encode(tuple));
|
|
expect(() => decode('["T",2]')).toThrow('a "T" message with 1 fields');
|
|
for (const hops of [0, 17]) {
|
|
expect(() => checkMessage({ kind: "retry", hold: 2, hops }, context)).toThrow("message schema");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("describeMessage", () => {
|
|
it("prints a message in the text tuple form", () => {
|
|
expect(describeMessage({ kind: "abort", call: 7 })).toBe('["x",7]');
|
|
expect(describeMessage({ kind: "result", call: 0, value: '{"$bytes":0}', segments: [new Uint8Array(3)] })).toBe(
|
|
'["r",0,[0,{"$bytes":0}],[3]]',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("a codec mismatch", () => {
|
|
it("decodes the other codec's hello as no message of its own", () => {
|
|
const hello: Message = { kind: "hello", protocol: 1, incarnation, calls: 256 };
|
|
expect(() => binaryCodec.decode(textCodec.encode(hello))).toThrow();
|
|
expect(() => textCodec.decode(binaryCodec.encode(hello))).toThrow();
|
|
});
|
|
});
|
|
|
|
describe("the binary codec's strings", () => {
|
|
it("survive the buffer growing around them, at every offset near the initial size", () => {
|
|
for (let length = 0; length < 400; length++) {
|
|
// A long value of three-byte characters (filling what it reserved), then a short one that lands at every offset around the buffer's first growth.
|
|
const members = [
|
|
{ member: 0, value: jsonPart("€".repeat(length)) },
|
|
{ member: 1, value: "1" },
|
|
];
|
|
const message: Message = { kind: "bound", holds: [{ hold: 1, variant: 0, members, failed: [] }] };
|
|
expect(plain(binaryCodec.decode(binaryCodec.encode(message)))).toEqual(message);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the binary codec's long strings", () => {
|
|
const texts = [
|
|
["ASCII", "x".repeat(10_000)],
|
|
["three-byte characters", "€".repeat(10_000)],
|
|
["surrogate pairs", "😀".repeat(5_000)],
|
|
["mixed, at the in-place threshold", `${"a".repeat(4_093)}😀€`],
|
|
["a leading U+FEFF", `\uFEFF${"y".repeat(5_000)}`],
|
|
];
|
|
|
|
it.each(texts)("round-trip %s, encoded in place behind a padded length", (_, text) => {
|
|
const message: Message = { kind: "result", call: 0, value: jsonPart(text) };
|
|
const bytes = binaryCodec.encode(message);
|
|
expect(plain(binaryCodec.decode(bytes))).toEqual(message);
|
|
expect(bytes.buffer.byteLength - bytes.byteLength).toBeLessThanOrEqual(4096);
|
|
});
|
|
});
|
|
|
|
describe("the binary codec's short strings", () => {
|
|
it.each([0, 1, 41, 42, 43])("round-trip at %i three-byte characters, around the one-byte length", (length) => {
|
|
const type = "€".repeat(length);
|
|
const message: Message = {
|
|
kind: "hold",
|
|
hold: 0,
|
|
type,
|
|
version: 1,
|
|
fingerprint: "0123456789abcdef",
|
|
params: jsonPart(type),
|
|
address: server,
|
|
};
|
|
expect(plain(binaryCodec.decode(binaryCodec.encode(message)))).toEqual(message);
|
|
});
|
|
});
|