428 lines
15 KiB
JavaScript
428 lines
15 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const test = require("node:test");
|
||
|
|
|
||
|
|
const { PluginRpcError, PluginRpcRouter, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||
|
|
const { PLUGIN_RPC_MAX_JSON_BYTES } = require("./constants.cjs");
|
||
|
|
|
||
|
|
function createRouter(options = {}) {
|
||
|
|
const sent = [];
|
||
|
|
const transfers = [];
|
||
|
|
const protocolErrors = [];
|
||
|
|
const router = new PluginRpcRouter({
|
||
|
|
pluginId: "com.example.test",
|
||
|
|
send(message, transfer = []) {
|
||
|
|
options.send?.(message, transfer);
|
||
|
|
sent.push(message);
|
||
|
|
transfers.push(transfer);
|
||
|
|
},
|
||
|
|
handlers: options.handlers,
|
||
|
|
requestHandlers: options.requestHandlers,
|
||
|
|
notificationHandlers: options.notificationHandlers,
|
||
|
|
maxPending: options.maxPending,
|
||
|
|
defaultTimeoutMs: options.defaultTimeoutMs ?? 100,
|
||
|
|
onBeforeMessage: options.onBeforeMessage,
|
||
|
|
onIncomingStream: options.onIncomingStream,
|
||
|
|
onProtocolError(error) { protocolErrors.push(error); },
|
||
|
|
});
|
||
|
|
return { router, sent, transfers, protocolErrors };
|
||
|
|
}
|
||
|
|
|
||
|
|
test("raw message guards run before schema work and can contain every transport message class", async () => {
|
||
|
|
const messages = [
|
||
|
|
{ malformed: true },
|
||
|
|
{ jsonrpc: "2.0", id: 1, method: "storage.get", params: { key: "x" } },
|
||
|
|
{ jsonrpc: "2.0", method: "log.write", params: {} },
|
||
|
|
{ jsonrpc: "2.0", id: 2, result: null },
|
||
|
|
{ jsonrpc: "2.0", method: "$/progress", params: { token: "x", value: null } },
|
||
|
|
{ jsonrpc: "2.0", method: "$/cancelRequest", params: { cancellationId: "x" } },
|
||
|
|
{ frame: { streamId: "x", sequence: 0, kind: "open", windowBytes: 1 } },
|
||
|
|
];
|
||
|
|
for (const guarded of messages) {
|
||
|
|
let seen;
|
||
|
|
const fixture = createRouter({
|
||
|
|
onBeforeMessage(message) {
|
||
|
|
seen = message;
|
||
|
|
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "runtime message quota exceeded");
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await fixture.router.accept(guarded);
|
||
|
|
assert.equal(seen, guarded);
|
||
|
|
assert.equal(fixture.router.closed, true);
|
||
|
|
assert.equal(fixture.protocolErrors[0].code, RPC_ERRORS.resourceExhausted);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
test("raw message guards must remain synchronous", async () => {
|
||
|
|
const fixture = createRouter({ onBeforeMessage: async () => true });
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", method: "log.write", params: {} });
|
||
|
|
assert.equal(fixture.router.closed, true);
|
||
|
|
assert.match(fixture.protocolErrors[0].message, /must be synchronous/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("RPC correlation validates initialize results against the reserved contract", async () => {
|
||
|
|
const fixture = createRouter();
|
||
|
|
const resultPromise = fixture.router.request("plugin.initialize", {
|
||
|
|
netcattyVersion: "1.0.0",
|
||
|
|
apiVersion: "0.1.0-internal",
|
||
|
|
supportedFeatures: [],
|
||
|
|
});
|
||
|
|
const request = fixture.sent[0];
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: request.id,
|
||
|
|
result: {
|
||
|
|
pluginId: "com.example.test",
|
||
|
|
pluginVersion: "1.0.0",
|
||
|
|
apiVersion: "0.1.0-internal",
|
||
|
|
enabledFeatures: [],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assert.equal((await resultPromise).pluginId, "com.example.test");
|
||
|
|
|
||
|
|
const invalidPromise = fixture.router.request("plugin.initialize", {
|
||
|
|
netcattyVersion: "1.0.0",
|
||
|
|
apiVersion: "0.1.0-internal",
|
||
|
|
supportedFeatures: [],
|
||
|
|
});
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: fixture.sent.at(-1).id, result: {} });
|
||
|
|
await assert.rejects(invalidPromise, /Initialize result violates/);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("RPC deadline sends cancellation and rejects without leaking pending state", async () => {
|
||
|
|
const fixture = createRouter({ defaultTimeoutMs: 10 });
|
||
|
|
const request = fixture.router.request("plugin.activate", {});
|
||
|
|
await assert.rejects(request, (error) => error instanceof PluginRpcError && error.code === RPC_ERRORS.deadlineExceeded);
|
||
|
|
assert.equal(fixture.sent.at(-1).method, "$/cancelRequest");
|
||
|
|
assert.equal(fixture.router.pending.size, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("one late response after cancellation is ignored without hiding duplicates", async () => {
|
||
|
|
const fixture = createRouter({ defaultTimeoutMs: 10 });
|
||
|
|
const request = fixture.router.request("provider.invoke", {});
|
||
|
|
const requestId = fixture.sent[0].id;
|
||
|
|
await assert.rejects(request, (error) => error?.code === RPC_ERRORS.deadlineExceeded);
|
||
|
|
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: requestId, result: { late: true } });
|
||
|
|
assert.equal(fixture.router.closed, false);
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: requestId, result: { duplicate: true } });
|
||
|
|
assert.equal(fixture.router.closed, true);
|
||
|
|
assert.equal(fixture.protocolErrors.length, 1);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("retired response IDs are not reused for new requests", async () => {
|
||
|
|
const fixture = createRouter({ defaultTimeoutMs: 10 });
|
||
|
|
fixture.router.nextId = Number.MAX_SAFE_INTEGER;
|
||
|
|
const expired = fixture.router.request("provider.expired", {});
|
||
|
|
assert.equal(fixture.sent[0].id, Number.MAX_SAFE_INTEGER);
|
||
|
|
await assert.rejects(expired);
|
||
|
|
fixture.router.nextId = Number.MAX_SAFE_INTEGER;
|
||
|
|
const next = fixture.router.request("provider.next", {});
|
||
|
|
assert.equal(fixture.sent.at(-1).id, 0);
|
||
|
|
fixture.router.close();
|
||
|
|
await assert.rejects(next);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("incoming request deadlines do not block later requests", async () => {
|
||
|
|
const fixture = createRouter({
|
||
|
|
defaultTimeoutMs: 20,
|
||
|
|
handlers: {
|
||
|
|
"storage.get": async () => new Promise(() => {}),
|
||
|
|
"storage.keys": async () => ({ keys: ["ready"] }),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "slow",
|
||
|
|
method: "storage.get",
|
||
|
|
params: { key: "blocked" },
|
||
|
|
deadlineMs: 10,
|
||
|
|
});
|
||
|
|
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.deadlineExceeded);
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "next",
|
||
|
|
method: "storage.keys",
|
||
|
|
params: {},
|
||
|
|
});
|
||
|
|
assert.deepEqual(fixture.sent[1].result, { keys: ["ready"] });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("a stalled stream consumer does not block frames for another stream", async () => {
|
||
|
|
let releaseSlowChunk;
|
||
|
|
const slowChunk = new Promise((resolve) => { releaseSlowChunk = resolve; });
|
||
|
|
const fixture = createRouter({
|
||
|
|
onIncomingStream({ streamId, bind }) {
|
||
|
|
bind({
|
||
|
|
async onChunk(_chunk, release) {
|
||
|
|
if (streamId === "slow") await slowChunk;
|
||
|
|
release();
|
||
|
|
},
|
||
|
|
});
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const contract = await import("@netcatty/plugin-contract");
|
||
|
|
await fixture.router.accept({
|
||
|
|
frame: { streamId: "slow", sequence: 0, kind: "open", windowBytes: 1024 },
|
||
|
|
});
|
||
|
|
const pendingSlowChunk = fixture.router.accept({
|
||
|
|
frame: {
|
||
|
|
streamId: "slow",
|
||
|
|
sequence: 1,
|
||
|
|
kind: "chunk",
|
||
|
|
data: contract.createJsonStreamChunk({ value: "slow" }),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
|
||
|
|
await fixture.router.accept({
|
||
|
|
frame: { streamId: "independent", sequence: 0, kind: "open", windowBytes: 1024 },
|
||
|
|
});
|
||
|
|
assert.equal(fixture.router.streams.incoming.has("independent"), true);
|
||
|
|
|
||
|
|
releaseSlowChunk();
|
||
|
|
await pendingSlowChunk;
|
||
|
|
});
|
||
|
|
|
||
|
|
test("incoming handlers can await a nested outgoing RPC response", async () => {
|
||
|
|
let fixture;
|
||
|
|
fixture = createRouter({
|
||
|
|
handlers: {
|
||
|
|
"storage.get": async () => fixture.router.request("plugin.nested", {}),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const incoming = fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "incoming",
|
||
|
|
method: "storage.get",
|
||
|
|
params: { key: "value" },
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
const nested = fixture.sent[0];
|
||
|
|
assert.equal(nested.method, "plugin.nested");
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: nested.id, result: { value: 42 } });
|
||
|
|
await incoming;
|
||
|
|
assert.deepEqual(fixture.sent[1], {
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "incoming",
|
||
|
|
result: { value: 42 },
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("an already-aborted outgoing request is never sent", async () => {
|
||
|
|
const fixture = createRouter();
|
||
|
|
const controller = new AbortController();
|
||
|
|
controller.abort();
|
||
|
|
await assert.rejects(
|
||
|
|
fixture.router.request("plugin.activate", {}, { signal: controller.signal }),
|
||
|
|
(error) => error?.code === RPC_ERRORS.cancelled,
|
||
|
|
);
|
||
|
|
assert.deepEqual(fixture.sent, []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("synchronous transport failure releases RPC correlation and cancellation ownership", async () => {
|
||
|
|
const fixture = createRouter({ send() { throw new Error("port is closed"); } });
|
||
|
|
await assert.rejects(fixture.router.request("plugin.activate", {}), /port is closed/);
|
||
|
|
assert.equal(fixture.router.pending.size, 0);
|
||
|
|
assert.equal(fixture.router.pendingCancellationIds.size, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("outgoing cancellation IDs cannot alias concurrent requests", async () => {
|
||
|
|
const fixture = createRouter();
|
||
|
|
const first = fixture.router.request("plugin.activate", {}, { cancellationId: "same" });
|
||
|
|
await assert.rejects(
|
||
|
|
fixture.router.request("plugin.deactivate", {}, { cancellationId: "same" }),
|
||
|
|
(error) => error?.code === RPC_ERRORS.invalidParams,
|
||
|
|
);
|
||
|
|
fixture.router.close();
|
||
|
|
await assert.rejects(first, (error) => error?.code === RPC_ERRORS.unavailable);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("duplicate in-flight request and cancellation IDs fail without replacing ownership", async () => {
|
||
|
|
let release;
|
||
|
|
const blocked = new Promise((resolve) => { release = resolve; });
|
||
|
|
const fixture = createRouter({
|
||
|
|
handlers: { "storage.keys": async () => blocked },
|
||
|
|
});
|
||
|
|
const first = fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "same-id",
|
||
|
|
method: "storage.keys",
|
||
|
|
params: {},
|
||
|
|
cancellationId: "same-cancel",
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "same-id",
|
||
|
|
method: "storage.keys",
|
||
|
|
params: {},
|
||
|
|
cancellationId: "different-cancel",
|
||
|
|
});
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "different-id",
|
||
|
|
method: "storage.keys",
|
||
|
|
params: {},
|
||
|
|
cancellationId: "same-cancel",
|
||
|
|
});
|
||
|
|
assert.deepEqual(fixture.sent.map((message) => message.error?.code), [
|
||
|
|
RPC_ERRORS.invalidParams,
|
||
|
|
RPC_ERRORS.invalidParams,
|
||
|
|
]);
|
||
|
|
release({ keys: [] });
|
||
|
|
await first;
|
||
|
|
assert.deepEqual(fixture.sent.at(-1), {
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "same-id",
|
||
|
|
result: { keys: [] },
|
||
|
|
});
|
||
|
|
assert.equal(fixture.router.closed, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("invalid host handler results become bounded internal errors", async () => {
|
||
|
|
const fixture = createRouter({
|
||
|
|
handlers: { "storage.keys": async () => ({ unsafe: 1n }) },
|
||
|
|
});
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "invalid-result",
|
||
|
|
method: "storage.keys",
|
||
|
|
params: {},
|
||
|
|
});
|
||
|
|
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.internal);
|
||
|
|
assert.equal(fixture.router.closed, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("incoming requests retain host-assigned plugin identity and unsupported methods fail immediately", async () => {
|
||
|
|
const identities = [];
|
||
|
|
const fixture = createRouter({
|
||
|
|
handlers: {
|
||
|
|
"storage.keys": async (_params, context) => {
|
||
|
|
identities.push(context.pluginId);
|
||
|
|
return { keys: [] };
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: "one", method: "storage.keys", params: {} });
|
||
|
|
assert.deepEqual(identities, ["com.example.test"]);
|
||
|
|
assert.deepEqual(fixture.sent[0], { jsonrpc: "2.0", id: "one", result: { keys: [] } });
|
||
|
|
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: "two", method: "host.unsupported", params: {} });
|
||
|
|
assert.equal(fixture.sent[1].error.code, RPC_ERRORS.methodNotFound);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("request and notification handler ownership cannot be confused", async () => {
|
||
|
|
const calls = [];
|
||
|
|
const fixture = createRouter({
|
||
|
|
requestHandlers: { "storage.set": async () => { calls.push("request"); return null; } },
|
||
|
|
notificationHandlers: { "log.write": async () => { calls.push("notification"); } },
|
||
|
|
});
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", method: "storage.set", params: { key: "x", value: 1 } });
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: "log", method: "log.write", params: {} });
|
||
|
|
assert.deepEqual(calls, []);
|
||
|
|
assert.equal(fixture.sent[0].error.code, RPC_ERRORS.methodNotFound);
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: "write", method: "storage.set", params: {} });
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", method: "log.write", params: {} });
|
||
|
|
assert.deepEqual(calls, ["request", "notification"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing the router aborts in-flight notification handlers", async () => {
|
||
|
|
let observedSignal;
|
||
|
|
const fixture = createRouter({
|
||
|
|
notificationHandlers: {
|
||
|
|
"events.consume": async (_params, context) => {
|
||
|
|
observedSignal = context.signal;
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
context.signal.addEventListener("abort", () => reject(context.signal.reason), { once: true });
|
||
|
|
});
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const accepting = fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
method: "events.consume",
|
||
|
|
params: {},
|
||
|
|
});
|
||
|
|
await new Promise((resolve) => setImmediate(resolve));
|
||
|
|
fixture.router.close();
|
||
|
|
await accepting;
|
||
|
|
assert.equal(observedSignal.aborted, true);
|
||
|
|
assert.equal(fixture.router.inflightNotifications.size, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("outgoing requests validate method-specific plugin results before resolving", async () => {
|
||
|
|
const fixture = createRouter();
|
||
|
|
const response = fixture.router.request("provider.invoke", {}, {
|
||
|
|
validateResult(result) {
|
||
|
|
if (result?.status !== "ok") throw new TypeError("Invalid provider result");
|
||
|
|
return Object.freeze({ ...result });
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: fixture.sent[0].id,
|
||
|
|
result: { status: "failed" },
|
||
|
|
});
|
||
|
|
await assert.rejects(response, /Invalid provider result/);
|
||
|
|
await assert.rejects(
|
||
|
|
fixture.router.request("provider.invoke", {}, { validateResult: true }),
|
||
|
|
/validator must be a function/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("outgoing requests keep transferable setup under router correlation and validation", async () => {
|
||
|
|
const fixture = createRouter();
|
||
|
|
const port = { postMessage() {} };
|
||
|
|
const response = fixture.router.request("plugin.terminal.interceptor.attach", {
|
||
|
|
descriptor: {
|
||
|
|
providerId: "com.example.input",
|
||
|
|
direction: "input",
|
||
|
|
session: { sessionId: "session-1", protocol: "ssh", status: "connected" },
|
||
|
|
},
|
||
|
|
}, {
|
||
|
|
transfer: [port],
|
||
|
|
validateResult(result) {
|
||
|
|
if (result?.accepted !== true) throw new TypeError("Invalid attachment result");
|
||
|
|
return undefined;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
assert.deepEqual(fixture.transfers[0], [port]);
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: fixture.sent[0].id,
|
||
|
|
result: { accepted: true },
|
||
|
|
});
|
||
|
|
assert.equal(await response, undefined);
|
||
|
|
await assert.rejects(
|
||
|
|
fixture.router.request("plugin.terminal.interceptor.attach", {}, { transfer: port }),
|
||
|
|
/transfer list must be an array/,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("malformed RPC closes the peer instead of accepting a near-match", async () => {
|
||
|
|
const fixture = createRouter();
|
||
|
|
await fixture.router.accept({ jsonrpc: "2.0", id: -1, method: "plugin.activate" });
|
||
|
|
assert.equal(fixture.protocolErrors.length, 1);
|
||
|
|
assert.equal(fixture.router.closed, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("oversized control messages close the peer before dispatch", async () => {
|
||
|
|
let calls = 0;
|
||
|
|
const fixture = createRouter({
|
||
|
|
handlers: { "storage.set": () => { calls += 1; } },
|
||
|
|
});
|
||
|
|
await fixture.router.accept({
|
||
|
|
jsonrpc: "2.0",
|
||
|
|
id: "oversized",
|
||
|
|
method: "storage.set",
|
||
|
|
params: { key: "value", value: "x".repeat(PLUGIN_RPC_MAX_JSON_BYTES) },
|
||
|
|
});
|
||
|
|
assert.equal(calls, 0);
|
||
|
|
assert.equal(fixture.router.closed, true);
|
||
|
|
assert.match(fixture.protocolErrors[0].message, /exceeds/);
|
||
|
|
});
|