[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
437
electron/plugins/rpcRouter.cjs
Normal file
437
electron/plugins/rpcRouter.cjs
Normal file
@@ -0,0 +1,437 @@
|
||||
"use strict";
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const {
|
||||
PLUGIN_RPC_DEFAULT_TIMEOUT_MS,
|
||||
PLUGIN_RPC_MAX_PENDING,
|
||||
} = require("./constants.cjs");
|
||||
const {
|
||||
assertInitializeResult,
|
||||
assertRpcMessage,
|
||||
assertStreamFrameSchema,
|
||||
} = require("./contractValidator.cjs");
|
||||
const { PluginStreamRouter, assertStreamEnvelopeShape } = require("./streamRouter.cjs");
|
||||
|
||||
const RPC_ERRORS = Object.freeze({
|
||||
methodNotFound: -32601,
|
||||
invalidParams: -32602,
|
||||
internal: -32603,
|
||||
cancelled: -32001,
|
||||
invalidArgument: -32003,
|
||||
deadlineExceeded: -32004,
|
||||
notFound: -32005,
|
||||
alreadyExists: -32006,
|
||||
permissionDenied: -32007,
|
||||
resourceExhausted: -32008,
|
||||
failedPrecondition: -32009,
|
||||
aborted: -32010,
|
||||
outOfRange: -32011,
|
||||
unsupported: -32012,
|
||||
unavailable: -32014,
|
||||
dataLoss: -32015,
|
||||
unauthenticated: -32016,
|
||||
});
|
||||
|
||||
class PluginRpcError extends Error {
|
||||
constructor(code, message, data) {
|
||||
super(message);
|
||||
this.name = "PluginRpcError";
|
||||
this.code = code;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
function rpcIdKey(id) {
|
||||
return `${typeof id}:${String(id)}`;
|
||||
}
|
||||
|
||||
function toRpcError(error) {
|
||||
if (error instanceof PluginRpcError) {
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message.slice(0, 2_048) || "Plugin RPC failed",
|
||||
...(error.data === undefined ? {} : { data: error.data }),
|
||||
};
|
||||
}
|
||||
return { code: RPC_ERRORS.internal, message: "Plugin host request failed" };
|
||||
}
|
||||
|
||||
function raceWithAbort(operation, signal) {
|
||||
if (signal.aborted) return Promise.reject(signal.reason);
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => reject(signal.reason);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
operation.then(
|
||||
(value) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class PluginRpcRouter {
|
||||
constructor(options) {
|
||||
this.pluginId = options.pluginId;
|
||||
this.send = options.send;
|
||||
const legacyHandlers = options.handlers ?? {};
|
||||
this.requestHandlers = new Map(Object.entries(options.requestHandlers ?? legacyHandlers));
|
||||
this.notificationHandlers = new Map(Object.entries(options.notificationHandlers ?? legacyHandlers));
|
||||
this.pending = new Map();
|
||||
this.pendingCancellationIds = new Set();
|
||||
this.retiredResponseIds = new Set();
|
||||
this.inflight = new Map();
|
||||
this.inflightIds = new Set();
|
||||
this.inflightNotifications = new Set();
|
||||
this.nextId = 0;
|
||||
this.closed = false;
|
||||
this.maxPending = options.maxPending ?? PLUGIN_RPC_MAX_PENDING;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? PLUGIN_RPC_DEFAULT_TIMEOUT_MS;
|
||||
this.onProtocolError = options.onProtocolError ?? (() => {});
|
||||
this.onBeforeMessage = options.onBeforeMessage ?? (() => {});
|
||||
if (typeof this.onBeforeMessage !== "function") {
|
||||
throw new TypeError("Plugin RPC message guard must be a function");
|
||||
}
|
||||
this.progress = options.onProgress ?? (() => {});
|
||||
this.streams = new PluginStreamRouter({
|
||||
send: (message, transfer) => this.send(message, transfer),
|
||||
onIncomingStream: options.onIncomingStream,
|
||||
maxStreams: this.maxPending,
|
||||
openTimeoutMs: this.defaultTimeoutMs,
|
||||
});
|
||||
this.streamChains = new Map();
|
||||
}
|
||||
|
||||
#sendRpc(message, transfer = []) {
|
||||
assertRpcMessage(message);
|
||||
if (!Array.isArray(transfer)) {
|
||||
throw new TypeError("Plugin RPC transfer list must be an array");
|
||||
}
|
||||
this.send(message, transfer);
|
||||
}
|
||||
|
||||
#sendFailure(id, error) {
|
||||
try {
|
||||
this.#sendRpc({ jsonrpc: "2.0", id, error: toRpcError(error) });
|
||||
} catch {
|
||||
this.#sendRpc({
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
error: { code: RPC_ERRORS.internal, message: "Plugin host request failed" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#allocateRequestId() {
|
||||
for (let attempt = 0; attempt <= this.maxPending + this.retiredResponseIds.size; attempt += 1) {
|
||||
const id = this.nextId;
|
||||
this.nextId = this.nextId === Number.MAX_SAFE_INTEGER ? 0 : this.nextId + 1;
|
||||
const key = rpcIdKey(id);
|
||||
if (!this.pending.has(key) && !this.retiredResponseIds.has(key)) return id;
|
||||
}
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "No RPC correlation ID is available");
|
||||
}
|
||||
|
||||
#allocateCancellationId() {
|
||||
let cancellationId;
|
||||
do cancellationId = `host-${randomUUID()}`;
|
||||
while (this.pendingCancellationIds.has(cancellationId));
|
||||
return cancellationId;
|
||||
}
|
||||
|
||||
#forgetPending(id, pending) {
|
||||
this.pending.delete(rpcIdKey(id));
|
||||
this.pendingCancellationIds.delete(pending.cancellationId);
|
||||
clearTimeout(pending.timer);
|
||||
pending.abortCleanup?.();
|
||||
}
|
||||
|
||||
#retireResponseId(id) {
|
||||
const key = rpcIdKey(id);
|
||||
this.retiredResponseIds.delete(key);
|
||||
this.retiredResponseIds.add(key);
|
||||
while (this.retiredResponseIds.size > this.maxPending) {
|
||||
this.retiredResponseIds.delete(this.retiredResponseIds.values().next().value);
|
||||
}
|
||||
}
|
||||
|
||||
accept(rawMessage) {
|
||||
return this.#accept(rawMessage)
|
||||
.catch((error) => {
|
||||
if (this.closed) return;
|
||||
this.onProtocolError(error);
|
||||
this.close(error);
|
||||
});
|
||||
}
|
||||
|
||||
async #accept(rawMessage) {
|
||||
if (this.closed) return;
|
||||
const guardResult = this.onBeforeMessage(rawMessage);
|
||||
if (guardResult && typeof guardResult.then === "function") {
|
||||
void Promise.resolve(guardResult).catch(() => {});
|
||||
throw new TypeError("Plugin RPC message guard must be synchronous");
|
||||
}
|
||||
const message = rawMessage;
|
||||
if (message && typeof message === "object" && Object.hasOwn(message, "frame")) {
|
||||
assertStreamEnvelopeShape(message);
|
||||
assertStreamFrameSchema(message.frame);
|
||||
const streamId = message.frame.streamId;
|
||||
const previous = this.streamChains.get(streamId) ?? Promise.resolve();
|
||||
const current = previous.then(() => this.streams.accept(message));
|
||||
this.streamChains.set(streamId, current);
|
||||
try {
|
||||
await current;
|
||||
} finally {
|
||||
if (this.streamChains.get(streamId) === current) this.streamChains.delete(streamId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
assertRpcMessage(message);
|
||||
if (Object.hasOwn(message, "id") && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error"))) {
|
||||
this.#acceptResponse(message);
|
||||
return;
|
||||
}
|
||||
if (message.method === "$/progress") {
|
||||
this.progress(message.params);
|
||||
return;
|
||||
}
|
||||
if (message.method === "$/cancelRequest") {
|
||||
this.#cancelInflight(message.params.cancellationId);
|
||||
return;
|
||||
}
|
||||
if (Object.hasOwn(message, "id")) {
|
||||
await this.#acceptRequest(message);
|
||||
return;
|
||||
}
|
||||
await this.#acceptNotification(message);
|
||||
}
|
||||
|
||||
#acceptResponse(message) {
|
||||
const key = rpcIdKey(message.id);
|
||||
const pending = this.pending.get(key);
|
||||
if (!pending) {
|
||||
if (this.retiredResponseIds.delete(key)) return;
|
||||
throw new Error(`Plugin returned an unknown RPC response ID: ${String(message.id)}`);
|
||||
}
|
||||
this.#forgetPending(message.id, pending);
|
||||
if (Object.hasOwn(message, "error")) {
|
||||
pending.reject(new PluginRpcError(message.error.code, message.error.message, message.error.data));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let result = pending.method === "plugin.initialize"
|
||||
? assertInitializeResult(message.result)
|
||||
: message.result;
|
||||
if (pending.validateResult) result = pending.validateResult(result);
|
||||
pending.resolve(result);
|
||||
} catch (error) {
|
||||
pending.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
async #acceptRequest(message) {
|
||||
if (this.inflight.size >= this.maxPending) {
|
||||
this.#sendRpc({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: RPC_ERRORS.resourceExhausted, message: "Too many in-flight plugin requests" },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const handler = this.requestHandlers.get(message.method);
|
||||
if (!handler) {
|
||||
this.#sendRpc({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: RPC_ERRORS.methodNotFound, message: `Unsupported plugin method: ${message.method}` },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const requestIdKey = rpcIdKey(message.id);
|
||||
if (this.inflightIds.has(requestIdKey)) {
|
||||
this.#sendFailure(message.id, new PluginRpcError(
|
||||
RPC_ERRORS.invalidParams,
|
||||
"Duplicate in-flight plugin request ID",
|
||||
));
|
||||
return;
|
||||
}
|
||||
let cancellationId = message.cancellationId;
|
||||
if (cancellationId && this.inflight.has(cancellationId)) {
|
||||
this.#sendFailure(message.id, new PluginRpcError(
|
||||
RPC_ERRORS.invalidParams,
|
||||
"Duplicate in-flight plugin cancellation ID",
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (!cancellationId) {
|
||||
do cancellationId = `host-${randomUUID()}`;
|
||||
while (this.inflight.has(cancellationId));
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = message.deadlineMs ?? this.defaultTimeoutMs;
|
||||
const timer = setTimeout(() => controller.abort(new PluginRpcError(
|
||||
RPC_ERRORS.deadlineExceeded,
|
||||
"Plugin request deadline exceeded",
|
||||
)), timeoutMs);
|
||||
this.inflight.set(cancellationId, controller);
|
||||
this.inflightIds.add(requestIdKey);
|
||||
try {
|
||||
const result = await raceWithAbort(Promise.resolve(handler(message.params, {
|
||||
pluginId: this.pluginId,
|
||||
signal: controller.signal,
|
||||
cancellationId,
|
||||
deadlineMs: timeoutMs,
|
||||
requestId: message.id,
|
||||
})), controller.signal);
|
||||
if (!this.closed) this.#sendRpc({ jsonrpc: "2.0", id: message.id, result: result ?? null });
|
||||
} catch (error) {
|
||||
if (!this.closed) this.#sendFailure(message.id, error);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.inflight.delete(cancellationId);
|
||||
this.inflightIds.delete(requestIdKey);
|
||||
}
|
||||
}
|
||||
|
||||
async #acceptNotification(message) {
|
||||
const handler = this.notificationHandlers.get(message.method);
|
||||
if (!handler) return;
|
||||
if (this.inflightNotifications.size >= this.maxPending) {
|
||||
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Too many in-flight plugin notifications");
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(new PluginRpcError(
|
||||
RPC_ERRORS.deadlineExceeded,
|
||||
"Plugin notification deadline exceeded",
|
||||
)), this.defaultTimeoutMs);
|
||||
this.inflightNotifications.add(controller);
|
||||
try {
|
||||
await raceWithAbort(Promise.resolve(handler(message.params, {
|
||||
pluginId: this.pluginId,
|
||||
signal: controller.signal,
|
||||
notification: true,
|
||||
})), controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
this.inflightNotifications.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
#cancelInflight(cancellationId) {
|
||||
const controller = this.inflight.get(cancellationId);
|
||||
controller?.abort(new PluginRpcError(RPC_ERRORS.cancelled, "Plugin request was cancelled"));
|
||||
}
|
||||
|
||||
request(method, params, options = {}) {
|
||||
if (this.closed) return Promise.reject(new PluginRpcError(RPC_ERRORS.unavailable, "Plugin runtime is closed"));
|
||||
if (options.signal?.aborted) {
|
||||
return Promise.reject(new PluginRpcError(RPC_ERRORS.cancelled, "Plugin request was cancelled"));
|
||||
}
|
||||
if (this.pending.size >= this.maxPending) {
|
||||
return Promise.reject(new PluginRpcError(RPC_ERRORS.resourceExhausted, "Too many pending plugin requests"));
|
||||
}
|
||||
const id = this.#allocateRequestId();
|
||||
const cancellationId = options.cancellationId ?? this.#allocateCancellationId();
|
||||
if (this.pendingCancellationIds.has(cancellationId)) {
|
||||
return Promise.reject(new PluginRpcError(
|
||||
RPC_ERRORS.invalidParams,
|
||||
"Plugin request cancellation ID is already in use",
|
||||
));
|
||||
}
|
||||
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
|
||||
if (options.validateResult != null && typeof options.validateResult !== "function") {
|
||||
return Promise.reject(new TypeError("Plugin RPC result validator must be a function"));
|
||||
}
|
||||
if (options.transfer != null && !Array.isArray(options.transfer)) {
|
||||
return Promise.reject(new TypeError("Plugin RPC transfer list must be an array"));
|
||||
}
|
||||
const message = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
...(params === undefined ? {} : { params }),
|
||||
deadlineMs: timeoutMs,
|
||||
cancellationId,
|
||||
};
|
||||
assertRpcMessage(message);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const pending = this.pending.get(rpcIdKey(id));
|
||||
if (pending) {
|
||||
this.#retireResponseId(id);
|
||||
this.#forgetPending(id, pending);
|
||||
}
|
||||
try {
|
||||
this.#sendRpc({ jsonrpc: "2.0", method: "$/cancelRequest", params: { cancellationId } });
|
||||
} catch {}
|
||||
reject(new PluginRpcError(RPC_ERRORS.deadlineExceeded, `Plugin request timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
let abortCleanup;
|
||||
if (options.signal) {
|
||||
const onAbort = () => {
|
||||
const pending = this.pending.get(rpcIdKey(id));
|
||||
if (pending) {
|
||||
this.#retireResponseId(id);
|
||||
this.#forgetPending(id, pending);
|
||||
}
|
||||
try {
|
||||
this.#sendRpc({ jsonrpc: "2.0", method: "$/cancelRequest", params: { cancellationId } });
|
||||
} catch {}
|
||||
reject(new PluginRpcError(RPC_ERRORS.cancelled, "Plugin request was cancelled"));
|
||||
};
|
||||
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
abortCleanup = () => options.signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
const pending = {
|
||||
method,
|
||||
cancellationId,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
abortCleanup,
|
||||
validateResult: options.validateResult,
|
||||
};
|
||||
this.pending.set(rpcIdKey(id), pending);
|
||||
this.pendingCancellationIds.add(cancellationId);
|
||||
try {
|
||||
this.#sendRpc(message, options.transfer ?? []);
|
||||
} catch (error) {
|
||||
this.#forgetPending(id, pending);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notify(method, params) {
|
||||
if (this.closed) throw new PluginRpcError(RPC_ERRORS.unavailable, "Plugin runtime is closed");
|
||||
const message = { jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) };
|
||||
this.#sendRpc(message);
|
||||
}
|
||||
|
||||
close(error = new PluginRpcError(RPC_ERRORS.unavailable, "Plugin runtime closed")) {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.abortCleanup?.();
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.pendingCancellationIds.clear();
|
||||
this.retiredResponseIds.clear();
|
||||
for (const controller of this.inflight.values()) controller.abort(error);
|
||||
this.inflight.clear();
|
||||
this.inflightIds.clear();
|
||||
for (const controller of this.inflightNotifications) controller.abort(error);
|
||||
this.inflightNotifications.clear();
|
||||
this.streams.close(error);
|
||||
this.streamChains.clear();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { PluginRpcError, PluginRpcRouter, RPC_ERRORS, raceWithAbort, rpcIdKey };
|
||||
Reference in New Issue
Block a user