RPC-RR-02. The server-stream path captured the generation fence outside its protected boundary and raceWithin invoked clock.sleep outside a promise boundary, so a synchronous throw from either escaped the Result contract and skipped the listener and timer release. Both now run inside the boundary, and release moved to finally. RPC-RR-03. The runtime snapshotted its transports only after validating the caller's raw objects, which ran their accessors first. It now decodes the registry from own data descriptors before anything reads it — refusing an accessor without invoking it and rejecting extra, inherited and symbol-keyed fields — and validates that snapshot. Every installed binding registry is a read facade over a private store instead of a frozen Map whose set, delete and clear still worked. RPC-RR-04. Transport results and stream frames are decoded per union variant from own data descriptors into new frozen values. A throwing getter, an inherited or extra field, a symbol key, an unknown failure code and an out-of-range retryAfterMs all close as protocol failures instead of escaping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
316 lines
9.2 KiB
TypeScript
316 lines
9.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
createBrowserRpcRuntime,
|
|
defineBrowserRpcTransport,
|
|
type BrowserRpcStreamFrame,
|
|
type BrowserRpcTransport,
|
|
} from "../../../src/adapters/browser-rpc/index.ts";
|
|
import { installBrowserRpcContractBindings } from "../../../src/contracts/browser-rpc.ts";
|
|
import {
|
|
MAPPERS,
|
|
SCHEMA_CODECS,
|
|
STREAM_ENCODER,
|
|
UNARY_ENCODER,
|
|
isResourceView,
|
|
streamOperation,
|
|
streamProfile,
|
|
unaryOperation,
|
|
unaryProfile,
|
|
} from "./fixture.ts";
|
|
|
|
function unaryRuntime(
|
|
transport: BrowserRpcTransport,
|
|
extra: Record<string, unknown> = {},
|
|
) {
|
|
return createBrowserRpcRuntime({
|
|
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
|
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
|
schemaCodecs: SCHEMA_CODECS,
|
|
mappers: MAPPERS,
|
|
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
|
transports: { CONNECT_REFERENCE_UNARY: transport },
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
function streamingRuntime(
|
|
transport: BrowserRpcTransport,
|
|
extra: Record<string, unknown> = {},
|
|
) {
|
|
return createBrowserRpcRuntime({
|
|
operations: { WATCH_RPC_RESOURCES: streamOperation() },
|
|
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
|
schemaCodecs: SCHEMA_CODECS,
|
|
mappers: MAPPERS,
|
|
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
|
transports: { CONNECT_REFERENCE_STREAM: transport },
|
|
...extra,
|
|
});
|
|
}
|
|
|
|
function unaryTransport(
|
|
invokeUnary: BrowserRpcTransport["invokeUnary"],
|
|
): BrowserRpcTransport {
|
|
return defineBrowserRpcTransport({
|
|
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
|
providerId: "REFERENCE_RPC",
|
|
protocol: "CONNECT_HTTP",
|
|
rpcKind: "UNARY",
|
|
invokeUnary,
|
|
});
|
|
}
|
|
|
|
function streamTransport(
|
|
frames: readonly BrowserRpcStreamFrame[],
|
|
): BrowserRpcTransport {
|
|
return defineBrowserRpcTransport({
|
|
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
|
providerId: "REFERENCE_RPC",
|
|
protocol: "CONNECT_HTTP",
|
|
rpcKind: "SERVER_STREAM",
|
|
openServerStream: () => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const frame of frames) yield frame;
|
|
},
|
|
}),
|
|
});
|
|
}
|
|
|
|
async function collect(
|
|
source: AsyncIterable<unknown>,
|
|
): Promise<readonly unknown[]> {
|
|
const seen: unknown[] = [];
|
|
for await (const value of source) seen.push(value);
|
|
return seen;
|
|
}
|
|
|
|
/**
|
|
* RPC-RR-02. A collaborator that throws synchronously must not escape the
|
|
* Result contract. A generation fence captured outside the protected boundary
|
|
* and a `clock.sleep` invoked outside a promise boundary both did exactly that,
|
|
* and the second also skipped the listener and timer release.
|
|
*/
|
|
describe("RPC-RR-02 synchronous collaborator throws stay inside Result", () => {
|
|
const throwingFence = {
|
|
capture: () => {
|
|
throw new TypeError("fence exploded");
|
|
},
|
|
isCurrent: () => true,
|
|
};
|
|
|
|
it("closes a unary call whose fence throws", async () => {
|
|
const runtime = unaryRuntime(
|
|
unaryTransport(async () => ({
|
|
ok: true,
|
|
message: { id: "a", name: "A" },
|
|
encodedBytes: 8,
|
|
})),
|
|
{ generationFence: throwingFence },
|
|
);
|
|
|
|
const result = await runtime
|
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
|
.execute({ resourceId: "resource-1" });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("closes a stream whose fence throws", async () => {
|
|
const runtime = streamingRuntime(
|
|
streamTransport([{ kind: "TERMINAL", ok: true }]),
|
|
{ generationFence: throwingFence },
|
|
);
|
|
|
|
const results = await collect(
|
|
runtime
|
|
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
|
.open({ topic: "resources" }),
|
|
);
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("closes a unary call whose clock throws synchronously", async () => {
|
|
const runtime = unaryRuntime(
|
|
unaryTransport(() => new Promise(() => {})),
|
|
{
|
|
clock: {
|
|
now: () => 0,
|
|
sleep: () => {
|
|
throw new TypeError("clock exploded");
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
const result = await runtime
|
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
|
.execute({ resourceId: "resource-1" });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* RPC-RR-03. The runtime reads a validated snapshot, never the caller's
|
|
* objects: an accessor is refused without being invoked, and no installed
|
|
* registry exposes a mutator.
|
|
*/
|
|
describe("RPC-RR-03 transport and binding registries are snapshots", () => {
|
|
it("never invokes a transport accessor", () => {
|
|
let getterCalls = 0;
|
|
const hostile = {} as Record<string, unknown>;
|
|
Object.defineProperties(hostile, {
|
|
runtimeProfileId: {
|
|
enumerable: true,
|
|
get: () => {
|
|
getterCalls += 1;
|
|
return "CONNECT_REFERENCE_UNARY";
|
|
},
|
|
},
|
|
providerId: { enumerable: true, value: "REFERENCE_RPC" },
|
|
protocol: { enumerable: true, value: "CONNECT_HTTP" },
|
|
rpcKind: { enumerable: true, value: "UNARY" },
|
|
invokeUnary: {
|
|
enumerable: true,
|
|
value: async () => ({ ok: true, message: {}, encodedBytes: 1 }),
|
|
},
|
|
});
|
|
|
|
expect(() =>
|
|
unaryRuntime(hostile as unknown as BrowserRpcTransport),
|
|
).toThrow(TypeError);
|
|
expect(getterCalls).toBe(0);
|
|
});
|
|
|
|
it("rejects an unexpected own field on a transport row", () => {
|
|
const transport = unaryTransport(async () => ({
|
|
ok: true,
|
|
message: { id: "a", name: "A" },
|
|
encodedBytes: 8,
|
|
}));
|
|
const widened = { ...transport, injected: true };
|
|
expect(() =>
|
|
unaryRuntime(widened as unknown as BrowserRpcTransport),
|
|
).toThrow(TypeError);
|
|
});
|
|
|
|
it("exposes no mutation API on any installed binding registry", () => {
|
|
const installed = installBrowserRpcContractBindings({
|
|
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
|
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
|
schemaCodecs: SCHEMA_CODECS,
|
|
mappers: MAPPERS,
|
|
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
|
});
|
|
for (const registry of Object.values(installed)) {
|
|
const record = registry as unknown as Record<string, unknown>;
|
|
for (const mutator of ["set", "delete", "clear"]) {
|
|
expect(record[mutator]).toBeUndefined();
|
|
}
|
|
expect(() =>
|
|
Map.prototype.clear.call(registry as never),
|
|
).toThrow();
|
|
}
|
|
expect(installed.operations.get("GET_RPC_RESOURCE")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
/**
|
|
* RPC-RR-04. Transport values are decoded, not adopted. `in` and a direct
|
|
* property read run accessors and admit inherited or extra fields, and keeping
|
|
* the caller's object lets it change after validation.
|
|
*/
|
|
describe("RPC-RR-04 transport results and frames are exactly decoded", () => {
|
|
const hostileResults: readonly (readonly [string, () => unknown])[] = [
|
|
["extra own field", () => ({ ok: true, message: {}, encodedBytes: 1, injected: 1 })],
|
|
[
|
|
"inherited fields",
|
|
() =>
|
|
Object.create({ ok: true, message: {}, encodedBytes: 1 }) as object,
|
|
],
|
|
[
|
|
"throwing getter",
|
|
() => {
|
|
const value: Record<string, unknown> = { message: {}, encodedBytes: 1 };
|
|
Object.defineProperty(value, "ok", {
|
|
enumerable: true,
|
|
get: () => {
|
|
throw new TypeError("hostile getter");
|
|
},
|
|
});
|
|
return value;
|
|
},
|
|
],
|
|
[
|
|
"symbol key",
|
|
() => ({
|
|
ok: true,
|
|
message: {},
|
|
encodedBytes: 1,
|
|
[Symbol("injected")]: 1,
|
|
}),
|
|
],
|
|
];
|
|
|
|
for (const [label, build] of hostileResults) {
|
|
it(`refuses a unary result with ${label}`, async () => {
|
|
const runtime = unaryRuntime(
|
|
unaryTransport(async () => build() as never),
|
|
);
|
|
const result = await runtime
|
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
|
.execute({ resourceId: "resource-1" });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
}
|
|
|
|
it("refuses an unknown transport failure code", async () => {
|
|
const runtime = unaryRuntime(
|
|
unaryTransport(async () => ({
|
|
ok: false,
|
|
failure: { code: "MADE_UP_CODE" },
|
|
}) as never),
|
|
);
|
|
const result = await runtime
|
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
|
.execute({ resourceId: "resource-1" });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("refuses a retryAfterMs outside the hard ceiling", async () => {
|
|
const runtime = unaryRuntime(
|
|
unaryTransport(async () => ({
|
|
ok: false,
|
|
failure: { code: "NETWORK_UNREACHABLE", retryAfterMs: -1 },
|
|
}) as never),
|
|
);
|
|
const result = await runtime
|
|
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
|
.execute({ resourceId: "resource-1" });
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("refuses a stream frame with an extra own field", async () => {
|
|
const runtime = streamingRuntime(
|
|
streamTransport([
|
|
{
|
|
kind: "MESSAGE",
|
|
message: { id: "a", name: "A" },
|
|
encodedBytes: 4,
|
|
injected: 1,
|
|
} as never,
|
|
]),
|
|
);
|
|
const results = await collect(
|
|
runtime
|
|
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
|
.open({ topic: "resources" }),
|
|
);
|
|
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
|
|
true,
|
|
);
|
|
});
|
|
});
|