[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:
933
electron/plugins/terminalDataPipelineService.test.cjs
Normal file
933
electron/plugins/terminalDataPipelineService.test.cjs
Normal file
@@ -0,0 +1,933 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const { MessageChannel } = require("node:worker_threads");
|
||||
const test = require("node:test");
|
||||
|
||||
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
|
||||
const { PluginTerminalDataPipelineService } = require("./terminalDataPipelineService.cjs");
|
||||
|
||||
function provider(pluginId, id, direction) {
|
||||
return Object.freeze({
|
||||
pluginId,
|
||||
pluginVersion: "1.0.0",
|
||||
pluginDisplayName: pluginId,
|
||||
provider: Object.freeze({ id, kind: `terminal.interceptor.${direction}`, label: id }),
|
||||
});
|
||||
}
|
||||
|
||||
function harness(options = {}) {
|
||||
const providers = options.providers ?? [provider("com.example", "com.example.input", "input")];
|
||||
const identity = Object.freeze({
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: options.runtimeKind ?? "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
});
|
||||
const attached = [];
|
||||
const detached = [];
|
||||
const authorized = [];
|
||||
const activationCalls = [];
|
||||
const revokedSessions = [];
|
||||
let permissionRevocationListener = null;
|
||||
const contributionListeners = [];
|
||||
const runtimeListeners = [];
|
||||
const contributionService = {
|
||||
listProviders({ kind }) { return providers.filter((entry) => entry.provider.kind === kind); },
|
||||
async activateProvider(providerId) {
|
||||
activationCalls.push(providerId);
|
||||
await options.onActivateProvider?.();
|
||||
const entry = providers.find((candidate) => candidate.provider.id === providerId);
|
||||
if (!entry) throw new Error("missing provider");
|
||||
return {
|
||||
plugin: {
|
||||
id: entry.pluginId,
|
||||
activeVersion: entry.pluginVersion,
|
||||
manifest: { main: { node: "dist/index.js" }, permissions: { required: ["runtime.advanced"] } },
|
||||
},
|
||||
provider: entry.provider,
|
||||
identity: options.activationIdentity ?? identity,
|
||||
};
|
||||
},
|
||||
onDidChange(listener) { contributionListeners.push(listener); },
|
||||
};
|
||||
const runtimeSupervisor = {
|
||||
getRuntimeIdentity() { return options.getRuntimeIdentity ? options.getRuntimeIdentity() : identity; },
|
||||
onDidChangeRuntime(listener) { runtimeListeners.push(listener); },
|
||||
async attachTerminalInterceptor(pluginId, descriptor, port, attachOptions) {
|
||||
port.unref?.();
|
||||
attached.push({ side: "plugin", pluginId, descriptor, port, attachOptions });
|
||||
await options.onAttachTerminalInterceptor?.();
|
||||
},
|
||||
};
|
||||
const permissionEngine = {
|
||||
async authorize(context, request) {
|
||||
authorized.push({ context, request });
|
||||
await options.onAuthorize?.({ context, request, providers });
|
||||
request.validateBeforeGrant?.();
|
||||
const scope = typeof options.permissionScope === "function"
|
||||
? options.permissionScope({ context, request, call: authorized.length })
|
||||
: options.permissionScope ?? "session";
|
||||
return { scope };
|
||||
},
|
||||
revokeSession(sessionId) { revokedSessions.push(sessionId); },
|
||||
onDidRevoke(listener) {
|
||||
permissionRevocationListener = listener;
|
||||
return { dispose: () => { permissionRevocationListener = null; } };
|
||||
},
|
||||
};
|
||||
const worker = {
|
||||
warningListener: null,
|
||||
ownedListener: null,
|
||||
closedListener: null,
|
||||
ownsSession() { return true; },
|
||||
getSessionOwnerWebContentsId() {
|
||||
return options.sessionOwnerWebContentsId ?? null;
|
||||
},
|
||||
attachTerminalInterceptor(descriptor, port) {
|
||||
port.unref?.();
|
||||
attached.push({ side: "worker", descriptor, port });
|
||||
},
|
||||
detachTerminalInterceptor(sessionId, direction) { detached.push({ sessionId, direction }); },
|
||||
onTerminalInterceptorWarning(listener) {
|
||||
this.warningListener = listener;
|
||||
return { dispose: () => { this.warningListener = null; } };
|
||||
},
|
||||
onSessionOwned(listener) {
|
||||
this.ownedListener = listener;
|
||||
return { dispose: () => { this.ownedListener = null; } };
|
||||
},
|
||||
onSessionClosed(listener) {
|
||||
this.closedListener = listener;
|
||||
return { dispose: () => { this.closedListener = null; } };
|
||||
},
|
||||
};
|
||||
const selections = [];
|
||||
const warnings = [];
|
||||
const service = new PluginTerminalDataPipelineService({
|
||||
contributionService,
|
||||
permissionEngine,
|
||||
runtimeSupervisor,
|
||||
MessageChannelMain: MessageChannel,
|
||||
requestSelection: async (request) => {
|
||||
selections.push(request);
|
||||
return Object.hasOwn(options, "selectedProviderId")
|
||||
? options.selectedProviderId
|
||||
: request.providers[0].provider.id;
|
||||
},
|
||||
showWarning: (warning) => warnings.push(warning),
|
||||
});
|
||||
service.bindTerminalWorkerManager(worker);
|
||||
return {
|
||||
service,
|
||||
worker,
|
||||
identity,
|
||||
attached,
|
||||
detached,
|
||||
authorized,
|
||||
activationCalls,
|
||||
revokedSessions,
|
||||
selections,
|
||||
warnings,
|
||||
runtimeListeners,
|
||||
revokePermission(event) { permissionRevocationListener?.(event); },
|
||||
contributionListeners,
|
||||
contributionService,
|
||||
};
|
||||
}
|
||||
|
||||
const session = Object.freeze({
|
||||
sessionId: "session-1",
|
||||
protocol: "ssh",
|
||||
status: "connected",
|
||||
});
|
||||
|
||||
test("pipeline activation requires exact session permissions and transfers one port to each process", async () => {
|
||||
const h = harness();
|
||||
const result = await h.service.configureDirection(session, "input");
|
||||
assert.deepEqual(result, {
|
||||
status: "active",
|
||||
direction: "input",
|
||||
providerId: "com.example.input",
|
||||
pluginId: "com.example",
|
||||
});
|
||||
assert.deepEqual(h.authorized.map((entry) => entry.request.permission), [
|
||||
"provider.terminal",
|
||||
"terminal.intercept.input",
|
||||
]);
|
||||
assert.ok(h.authorized.every((entry) => entry.request.sessionId === "session-1"));
|
||||
assert.ok(h.authorized.every((entry) => (
|
||||
JSON.stringify(entry.request.allowedScopes) === JSON.stringify(["session", "application", "always"])
|
||||
)));
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker"]);
|
||||
assert.deepEqual(h.attached[0].attachOptions.expectedIdentity, h.identity);
|
||||
assert.deepEqual(h.attached[0].descriptor, {
|
||||
providerId: "com.example.input",
|
||||
direction: "input",
|
||||
session,
|
||||
});
|
||||
assert.deepEqual(h.attached[1].descriptor, {
|
||||
sessionId: "session-1",
|
||||
direction: "input",
|
||||
providerId: "com.example.input",
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId: "runtime-1",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
session,
|
||||
});
|
||||
});
|
||||
|
||||
test("pipeline rejects one-use permission grants before opening a streaming port", async () => {
|
||||
const h = harness({ permissionScope: "once" });
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/require a session, application, or persistent permission grant/,
|
||||
);
|
||||
assert.equal(h.authorized.length, 1);
|
||||
assert.equal(h.attached.length, 0);
|
||||
|
||||
const laterOnce = harness({
|
||||
permissionScope: ({ call }) => (call === 2 ? "once" : "session"),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => laterOnce.service.configureDirection(session, "input"),
|
||||
/require a session, application, or persistent permission grant/,
|
||||
);
|
||||
assert.equal(laterOnce.authorized.length, 2);
|
||||
assert.equal(laterOnce.attached.length, 0);
|
||||
});
|
||||
|
||||
test("permission denial is remembered for the session instead of prompting again", async () => {
|
||||
const h = harness({
|
||||
onAuthorize() {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "permission denied");
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
(error) => error?.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
h.contributionListeners[0]();
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal(h.authorized.length, 1);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("session event permission denial is a quiet declined result", async () => {
|
||||
const h = harness({
|
||||
onAuthorize() {
|
||||
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "permission denied");
|
||||
},
|
||||
});
|
||||
const results = await h.service.handleSessionEvent({ type: "connected", session }, {
|
||||
webContentsId: 99,
|
||||
});
|
||||
assert.deepEqual(results, [
|
||||
{ status: "declined", direction: "input" },
|
||||
{ status: "none", direction: "output" },
|
||||
]);
|
||||
assert.deepEqual(h.warnings, []);
|
||||
});
|
||||
|
||||
test("pipeline accepts an existing long-lived permission grant", async () => {
|
||||
const h = harness({ permissionScope: "existing" });
|
||||
const result = await h.service.configureDirection(session, "input");
|
||||
assert.equal(result.status, "active");
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker"]);
|
||||
});
|
||||
|
||||
test("multiple interceptors require an explicit per-session selection", async () => {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
],
|
||||
selectedProviderId: "com.example.input",
|
||||
});
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 1);
|
||||
assert.equal(h.selections[0].providers.length, 2);
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 1, "an active session binding must not prompt again");
|
||||
});
|
||||
|
||||
test("declining competing interceptors is remembered for the session and reset on disposal", async () => {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
],
|
||||
selectedProviderId: null,
|
||||
});
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal(h.selections.length, 1);
|
||||
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
});
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 2);
|
||||
});
|
||||
|
||||
test("concurrent snapshots serialize to one authorization and one port pair", async () => {
|
||||
const h = harness();
|
||||
const [first, second] = await Promise.all([
|
||||
h.service.configureDirection(session, "input"),
|
||||
h.service.configureDirection(session, "input"),
|
||||
]);
|
||||
assert.equal(first.status, "active");
|
||||
assert.equal(second.status, "active");
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.equal(h.attached.length, 2);
|
||||
});
|
||||
|
||||
test("browser runtimes cannot receive privileged terminal ports", async () => {
|
||||
const h = harness({ runtimeKind: "browser" });
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/advanced utility runtime/,
|
||||
);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("stale activation identity fails before permissions or port transfer", async () => {
|
||||
const h = harness({
|
||||
activationIdentity: {
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "0.9.0",
|
||||
runtimeId: "stale-runtime",
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/identity is unavailable, stale/,
|
||||
);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("worker transfer starts only after the plugin port is ready", async () => {
|
||||
const h = harness();
|
||||
h.worker.attachTerminalInterceptor = () => { throw new Error("worker unavailable"); };
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/worker unavailable/,
|
||||
);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
});
|
||||
|
||||
test("contribution withdrawal during authorization prevents stale port publication", async () => {
|
||||
let withdrawn = false;
|
||||
const h = harness({
|
||||
onAuthorize({ providers }) {
|
||||
if (withdrawn) return;
|
||||
withdrawn = true;
|
||||
providers.splice(0, providers.length);
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/contribution changed/,
|
||||
);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("runtime replacement during port attachment cannot publish a stale active binding", async () => {
|
||||
let runtimeId = "runtime-1";
|
||||
const h = harness({
|
||||
getRuntimeIdentity: () => ({
|
||||
pluginId: "com.example",
|
||||
pluginVersion: "1.0.0",
|
||||
runtimeId,
|
||||
runtimeKind: "utility",
|
||||
securityPrincipal: "principal-1",
|
||||
}),
|
||||
onAttachTerminalInterceptor() { runtimeId = "runtime-2"; },
|
||||
});
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input"),
|
||||
/runtime changed during port attachment/,
|
||||
);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
assert.deepEqual(h.detached, []);
|
||||
assert.equal(h.service.active.size, 0);
|
||||
});
|
||||
|
||||
test("disconnect detaches and reconnect restores the remembered session interceptor", async () => {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
],
|
||||
selectedProviderId: "com.example.input",
|
||||
});
|
||||
await h.service.handleSessionEvent({ type: "connected", session });
|
||||
assert.equal(h.selections.length, 1);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker"]);
|
||||
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disconnected",
|
||||
session: { ...session, status: "disconnected" },
|
||||
}, { webContentsId: 99 });
|
||||
assert.deepEqual(h.detached, [{ sessionId: "session-1", direction: "input" }]);
|
||||
|
||||
await h.worker.ownedListener({ sessionId: session.sessionId, webContentsId: 99 });
|
||||
assert.equal(h.selections.length, 1, "reconnect ownership must reuse the session-local choice");
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), [
|
||||
"plugin", "worker", "plugin", "worker",
|
||||
]);
|
||||
|
||||
await h.service.handleSessionEvent({ type: "reconnected", session });
|
||||
assert.equal(h.selections.length, 1, "reconnect must reuse the session-local choice");
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), [
|
||||
"plugin", "worker", "plugin", "worker", "plugin", "worker",
|
||||
]);
|
||||
assert.equal(h.attached.at(-1).descriptor.session.status, "connected");
|
||||
});
|
||||
|
||||
test("session metadata changes refresh the interceptor invocation snapshot", async () => {
|
||||
const h = harness();
|
||||
await h.service.handleSessionEvent({ type: "connected", session });
|
||||
await h.service.handleSessionEvent({
|
||||
type: "cwdChanged",
|
||||
session: { ...session, cwd: "/srv/current" },
|
||||
});
|
||||
assert.equal(h.attached.at(-1).descriptor.session.cwd, "/srv/current");
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), [
|
||||
"plugin", "worker", "plugin", "worker",
|
||||
]);
|
||||
assert.deepEqual(h.detached, [], "snapshot refresh must replace the worker port atomically");
|
||||
});
|
||||
|
||||
test("revoking a terminal permission immediately detaches the matching active interceptor", async () => {
|
||||
const h = harness();
|
||||
await h.service.configureDirection(session, "input");
|
||||
h.revokePermission({
|
||||
pluginId: "com.example",
|
||||
permission: "terminal.intercept.input",
|
||||
resource: "*",
|
||||
scope: "application",
|
||||
});
|
||||
assert.equal(h.service.active.size, 0);
|
||||
assert.deepEqual(h.detached, [{ sessionId: "session-1", direction: "input" }]);
|
||||
});
|
||||
|
||||
test("a worker-confirmed terminal exit revokes session grants and clears pipeline state", async () => {
|
||||
const h = harness();
|
||||
await h.service.configureDirection(session, "input");
|
||||
|
||||
h.worker.closedListener({ sessionId: session.sessionId, reason: "exited" });
|
||||
|
||||
assert.deepEqual(h.revokedSessions, [session.sessionId]);
|
||||
assert.equal(h.service.active.size, 0);
|
||||
assert.deepEqual(h.detached, [{ sessionId: session.sessionId, direction: "input" }]);
|
||||
});
|
||||
|
||||
test("a renderer cannot attach an interceptor to another window's terminal session", async () => {
|
||||
const h = harness();
|
||||
h.worker.ownsSession = () => false;
|
||||
await assert.rejects(
|
||||
() => h.service.configureDirection(session, "input", { webContentsId: 99 }),
|
||||
/not owned by the requesting window/,
|
||||
);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("renderer loss during provider selection prevents authorization", async () => {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
],
|
||||
});
|
||||
let owned = true;
|
||||
let releaseSelection;
|
||||
h.worker.ownsSession = () => owned;
|
||||
h.service.requestSelection = async () => {
|
||||
await new Promise((resolve) => { releaseSelection = resolve; });
|
||||
return "com.example.input";
|
||||
};
|
||||
|
||||
const activation = h.service.configureDirection(session, "input", { webContentsId: 99 });
|
||||
while (!releaseSelection) await new Promise((resolve) => setImmediate(resolve));
|
||||
owned = false;
|
||||
releaseSelection();
|
||||
|
||||
await assert.rejects(() => activation, /ownership changed during selection/);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("renderer loss during permission approval prevents a lasting grant", async () => {
|
||||
let owned = true;
|
||||
let releaseAuthorization;
|
||||
const h = harness({
|
||||
onAuthorize: () => new Promise((resolve) => { releaseAuthorization = resolve; }),
|
||||
});
|
||||
h.worker.ownsSession = () => owned;
|
||||
|
||||
const activation = h.service.configureDirection(session, "input", { webContentsId: 99 });
|
||||
while (!releaseAuthorization) await new Promise((resolve) => setImmediate(resolve));
|
||||
owned = false;
|
||||
releaseAuthorization();
|
||||
|
||||
await assert.rejects(() => activation, /ownership changed during activation/);
|
||||
assert.equal(h.authorized.length, 1);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("renderer loss during port attachment prevents worker publication", async () => {
|
||||
let owned = true;
|
||||
let releaseAttach;
|
||||
const h = harness({
|
||||
onAttachTerminalInterceptor: () => new Promise((resolve) => { releaseAttach = resolve; }),
|
||||
});
|
||||
h.worker.ownsSession = () => owned;
|
||||
|
||||
const activation = h.service.configureDirection(session, "input", { webContentsId: 99 });
|
||||
while (!releaseAttach) await new Promise((resolve) => setImmediate(resolve));
|
||||
owned = false;
|
||||
releaseAttach();
|
||||
|
||||
await assert.rejects(() => activation, /ownership changed during activation/);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
});
|
||||
|
||||
test("created events defer silently until the worker has recorded session ownership", async () => {
|
||||
const h = harness();
|
||||
let owned = false;
|
||||
h.worker.ownsSession = () => owned;
|
||||
const results = await h.service.handleSessionEvent({ type: "created", session }, {
|
||||
webContentsId: 99,
|
||||
});
|
||||
assert.deepEqual(results, [
|
||||
{ status: "pending-session", direction: "input" },
|
||||
{ status: "pending-session", direction: "output" },
|
||||
]);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.equal(h.attached.length, 0);
|
||||
|
||||
owned = true;
|
||||
await h.worker.ownedListener({ sessionId: session.sessionId, webContentsId: 99 });
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker"]);
|
||||
assert.equal(h.authorized.length, 2);
|
||||
});
|
||||
|
||||
test("session disposal invalidates an in-flight lazy activation before port transfer", async () => {
|
||||
const h = harness();
|
||||
const originalActivate = h.contributionService.activateProvider;
|
||||
let releaseActivation;
|
||||
h.contributionService.activateProvider = async (...args) => {
|
||||
await new Promise((resolve) => { releaseActivation = resolve; });
|
||||
return originalActivate(...args);
|
||||
};
|
||||
const pending = h.service.configureDirection(session, "input");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
});
|
||||
releaseActivation();
|
||||
await assert.rejects(pending, /session changed/);
|
||||
assert.equal(h.attached.length, 0);
|
||||
});
|
||||
|
||||
test("a stale renderer disposal cannot detach the current owner's interceptor", async () => {
|
||||
const h = harness({ sessionOwnerWebContentsId: 9 });
|
||||
await h.service.configureDirection(session, "input", { webContentsId: 9 });
|
||||
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
}, { webContentsId: 7 });
|
||||
|
||||
assert.equal(h.service.active.size, 1);
|
||||
assert.deepEqual(h.detached, []);
|
||||
assert.deepEqual(h.revokedSessions, []);
|
||||
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
}, { webContentsId: 9 });
|
||||
assert.equal(h.service.active.size, 0);
|
||||
assert.deepEqual(h.detached, [{ sessionId: "session-1", direction: "input" }]);
|
||||
assert.deepEqual(h.revokedSessions, ["session-1"]);
|
||||
});
|
||||
|
||||
test("a stale renderer cannot publish any lifecycle event for the current owner's session", async () => {
|
||||
const h = harness({ sessionOwnerWebContentsId: 9 });
|
||||
await h.service.configureDirection(session, "input", { webContentsId: 9 });
|
||||
for (const type of ["created", "disconnected", "connected", "cwdChanged"]) {
|
||||
const result = await h.service.handleSessionEvent({ type, session }, { webContentsId: 7 });
|
||||
assert.deepEqual(result, []);
|
||||
}
|
||||
assert.equal(h.service.active.size, 1);
|
||||
assert.deepEqual(h.detached, []);
|
||||
assert.deepEqual(h.revokedSessions, []);
|
||||
});
|
||||
|
||||
test("runtime failure statuses quarantine both directions before detaching", async () => {
|
||||
for (const status of ["error", "quarantined"]) {
|
||||
const h = harness({
|
||||
providers: [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.example", "com.example.output", "output"),
|
||||
],
|
||||
});
|
||||
await h.service.handleSessionEvent({ type: "connected", session });
|
||||
h.runtimeListeners[0]({
|
||||
status,
|
||||
pluginId: "com.example",
|
||||
runtimeId: "runtime-1",
|
||||
error: "utility runtime crashed",
|
||||
});
|
||||
assert.deepEqual(h.detached.map((entry) => entry.direction).sort(), ["input", "output"]);
|
||||
assert.deepEqual(h.warnings.map((warning) => warning.code).sort(), [
|
||||
`runtime-${status}`,
|
||||
`runtime-${status}`,
|
||||
]);
|
||||
const reconnect = await h.service.handleSessionEvent({ type: "connected", session });
|
||||
assert.deepEqual(reconnect.map((result) => result.status), ["declined", "declined"]);
|
||||
assert.equal(h.authorized.length, 4, "a crashed runtime must not be authorized again");
|
||||
assert.equal(h.attached.length, 4, "a crashed runtime must not be reattached");
|
||||
await h.service.handleSessionEvent({
|
||||
type: "disposed",
|
||||
session: { ...session, status: "disconnected" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("runtime exit clears the cached provider choice but ordinary reconnect preserves it", async () => {
|
||||
const providers = [
|
||||
provider("com.example", "com.example.input", "input"),
|
||||
provider("com.other", "com.other.input", "input"),
|
||||
];
|
||||
const h = harness({
|
||||
providers,
|
||||
selectedProviderId: "com.example.input",
|
||||
});
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 1);
|
||||
|
||||
const withdrawn = providers.splice(0);
|
||||
h.runtimeListeners[0]({
|
||||
status: "stopped",
|
||||
pluginId: "com.example",
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
providers.push(...withdrawn);
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.selections.length, 2, "a stopped runtime must discard its session-local choice");
|
||||
});
|
||||
|
||||
test("an expected runtime close waits for the stopped event instead of quarantining", async () => {
|
||||
let currentIdentity;
|
||||
const h = harness({ getRuntimeIdentity: () => currentIdentity });
|
||||
currentIdentity = h.identity;
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
currentIdentity = null;
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "closed",
|
||||
message: "Terminal interceptor stopped and was disabled",
|
||||
});
|
||||
assert.equal(h.warnings.length, 0);
|
||||
|
||||
h.runtimeListeners[0]({
|
||||
status: "stopped",
|
||||
pluginId: "com.example",
|
||||
runtimeId: "runtime-1",
|
||||
});
|
||||
currentIdentity = h.identity;
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
assert.equal(h.warnings.length, 0);
|
||||
assert.equal(h.authorized.length, 4);
|
||||
assert.equal(h.attached.length, 4);
|
||||
});
|
||||
|
||||
test("a close during runtime failure is quarantined by the authoritative error event", async () => {
|
||||
let currentIdentity;
|
||||
const h = harness({ getRuntimeIdentity: () => currentIdentity });
|
||||
currentIdentity = h.identity;
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
currentIdentity = null;
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "closed",
|
||||
message: "Terminal interceptor stopped and was disabled",
|
||||
});
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: "runtime-1",
|
||||
error: "utility runtime crashed",
|
||||
});
|
||||
|
||||
const [result] = await h.service.handleSessionEvent({ type: "connected", session });
|
||||
assert.equal(result.status, "declined");
|
||||
assert.deepEqual(h.warnings.map((warning) => warning.code), ["runtime-error"]);
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.equal(h.attached.length, 2);
|
||||
});
|
||||
|
||||
test("terminal worker exit invalidates active bindings before worker restart", async () => {
|
||||
const h = harness();
|
||||
await h.service.configureDirection(session, "input");
|
||||
h.worker.warningListener({ code: "worker-exit", message: "Terminal worker exited" });
|
||||
await h.service.configureDirection(session, "input");
|
||||
assert.equal(h.authorized.length, 4);
|
||||
assert.equal(h.attached.length, 4);
|
||||
});
|
||||
|
||||
test("a failed interceptor stays quarantined for the rest of the session", async () => {
|
||||
const h = harness();
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "timeout",
|
||||
message: "Interceptor timed out",
|
||||
});
|
||||
|
||||
const [result] = await h.service.handleSessionEvent({ type: "connected", session });
|
||||
assert.equal(result.status, "declined");
|
||||
assert.equal(h.authorized.length, 2, "the quarantined provider must not be authorized again");
|
||||
assert.equal(h.attached.length, 2, "the quarantined provider must not be reattached");
|
||||
assert.equal(h.warnings.length, 1);
|
||||
});
|
||||
|
||||
test("permission revocation while a port is attaching prevents the worker attachment", async () => {
|
||||
let releaseAttach;
|
||||
const attachGate = new Promise((resolve) => { releaseAttach = resolve; });
|
||||
const h = harness({ onAttachTerminalInterceptor: () => attachGate });
|
||||
|
||||
const activation = h.service.configureDirection(session, "input");
|
||||
while (h.attached.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
h.revokePermission({ pluginId: "com.example", permission: "terminal.intercept.input" });
|
||||
releaseAttach();
|
||||
|
||||
await assert.rejects(
|
||||
() => activation,
|
||||
(error) => error?.code === RPC_ERRORS.permissionDenied,
|
||||
);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
});
|
||||
|
||||
test("a concurrent metadata refresh cannot revive a quarantined interceptor", async () => {
|
||||
let attachCalls = 0;
|
||||
let releaseRefresh;
|
||||
const refreshGate = new Promise((resolve) => { releaseRefresh = resolve; });
|
||||
const h = harness({
|
||||
onAttachTerminalInterceptor() {
|
||||
attachCalls += 1;
|
||||
return attachCalls === 2 ? refreshGate : undefined;
|
||||
},
|
||||
});
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
const refresh = h.service.configureDirection({ ...session, title: "new title" }, "input");
|
||||
while (h.attached.length < 3) await new Promise((resolve) => setImmediate(resolve));
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "timeout",
|
||||
message: "Interceptor timed out",
|
||||
});
|
||||
releaseRefresh();
|
||||
|
||||
await assert.rejects(
|
||||
() => refresh,
|
||||
(error) => error?.code === RPC_ERRORS.unavailable,
|
||||
);
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin", "worker", "plugin"]);
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
});
|
||||
|
||||
test("a runtime crash reported after its first port attachment fails still quarantines the interceptor", async () => {
|
||||
let rejectAttach;
|
||||
const attachGate = new Promise((_resolve, reject) => { rejectAttach = reject; });
|
||||
const h = harness({ onAttachTerminalInterceptor: () => attachGate });
|
||||
|
||||
const activation = h.service.configureDirection(session, "input");
|
||||
while (h.attached.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
rejectAttach(new Error("plugin runtime transport closed"));
|
||||
await assert.rejects(() => activation, /transport closed/);
|
||||
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
error: "utility runtime crashed during attachment",
|
||||
});
|
||||
assert.deepEqual(h.attached.map((entry) => entry.side), ["plugin"]);
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.deepEqual(h.warnings.map((warning) => warning.code), ["runtime-error"]);
|
||||
});
|
||||
|
||||
test("a runtime crash during first provider activation prevents automatic retry", async () => {
|
||||
let rejectActivation;
|
||||
const activationGate = new Promise((_resolve, reject) => { rejectActivation = reject; });
|
||||
const h = harness({ onActivateProvider: () => activationGate });
|
||||
|
||||
const activation = h.service.configureDirection(session, "input");
|
||||
while (h.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
error: "utility runtime crashed during activation",
|
||||
});
|
||||
rejectActivation(new Error("plugin activation transport closed"));
|
||||
|
||||
await assert.rejects(() => activation, /transport closed/);
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "declined");
|
||||
assert.equal(h.activationCalls.length, 1);
|
||||
assert.equal(h.authorized.length, 0);
|
||||
assert.deepEqual(h.warnings.map((warning) => warning.code), ["runtime-error"]);
|
||||
});
|
||||
|
||||
test("a late crash from an old session generation cannot quarantine a reused session id", async () => {
|
||||
let rejectOldActivation;
|
||||
let activationCalls = 0;
|
||||
const oldActivationGate = new Promise((_resolve, reject) => { rejectOldActivation = reject; });
|
||||
const h = harness({
|
||||
onActivateProvider() {
|
||||
activationCalls += 1;
|
||||
return activationCalls === 1 ? oldActivationGate : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const oldActivation = h.service.handleSessionEvent({ type: "snapshot", session });
|
||||
while (h.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
await h.service.handleSessionEvent({ type: "disposed", session });
|
||||
const newActivation = h.service.handleSessionEvent({ type: "created", session });
|
||||
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
error: "old utility runtime crashed late",
|
||||
});
|
||||
rejectOldActivation(new Error("old plugin activation transport closed"));
|
||||
|
||||
const oldResult = await oldActivation;
|
||||
const newResult = await newActivation;
|
||||
assert.equal(oldResult[0].status, "cancelled");
|
||||
assert.notEqual(newResult[0].status, "declined");
|
||||
assert.equal(h.activationCalls.length, 2);
|
||||
assert.deepEqual(h.warnings.filter((warning) => warning.code === "runtime-error"), []);
|
||||
});
|
||||
|
||||
test("a normal stop before provider activation rejects cannot become a later crash record", async () => {
|
||||
let rejectActivation;
|
||||
let activationCalls = 0;
|
||||
const activationGate = new Promise((_resolve, reject) => { rejectActivation = reject; });
|
||||
const h = harness({
|
||||
onActivateProvider() {
|
||||
activationCalls += 1;
|
||||
return activationCalls === 1 ? activationGate : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
const first = h.service.configureDirection(session, "input");
|
||||
while (h.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
h.runtimeListeners[0]({
|
||||
status: "stopped",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
});
|
||||
rejectActivation(new Error("plugin stopped during activation"));
|
||||
await assert.rejects(
|
||||
() => first,
|
||||
(error) => error?.code === RPC_ERRORS.cancelled,
|
||||
);
|
||||
|
||||
h.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: h.identity.runtimeId,
|
||||
error: "unrelated later crash",
|
||||
});
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
assert.equal(h.activationCalls.length, 2);
|
||||
assert.deepEqual(h.warnings, []);
|
||||
});
|
||||
|
||||
test("normal cancellation and an already-reported crash do not emit duplicate activation warnings", async () => {
|
||||
let rejectActivation;
|
||||
const activationGate = new Promise((_resolve, reject) => { rejectActivation = reject; });
|
||||
const normal = harness({ onActivateProvider: () => activationGate });
|
||||
const normalEvent = normal.service.handleSessionEvent({ type: "snapshot", session });
|
||||
while (normal.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
normal.runtimeListeners[0]({
|
||||
status: "stopped",
|
||||
pluginId: "com.example",
|
||||
runtimeId: normal.identity.runtimeId,
|
||||
});
|
||||
rejectActivation(new Error("runtime stopped"));
|
||||
const normalResults = await normalEvent;
|
||||
assert.equal(normalResults[0].status, "cancelled");
|
||||
assert.deepEqual(normal.warnings, []);
|
||||
|
||||
let rejectCrash;
|
||||
const crashGate = new Promise((_resolve, reject) => { rejectCrash = reject; });
|
||||
const crashed = harness({ onActivateProvider: () => crashGate });
|
||||
const crashEvent = crashed.service.handleSessionEvent({ type: "snapshot", session });
|
||||
while (crashed.activationCalls.length === 0) await new Promise((resolve) => setImmediate(resolve));
|
||||
crashed.runtimeListeners[0]({
|
||||
status: "error",
|
||||
pluginId: "com.example",
|
||||
runtimeId: crashed.identity.runtimeId,
|
||||
error: "runtime crashed",
|
||||
});
|
||||
rejectCrash(new Error("transport closed"));
|
||||
const crashResults = await crashEvent;
|
||||
assert.equal(crashResults[0].status, "cancelled");
|
||||
assert.deepEqual(crashed.warnings.map((warning) => warning.code), ["runtime-error"]);
|
||||
});
|
||||
|
||||
test("contribution changes do not clear a failed interceptor quarantine", async () => {
|
||||
const h = harness();
|
||||
assert.equal((await h.service.configureDirection(session, "input")).status, "active");
|
||||
|
||||
h.worker.warningListener({
|
||||
sessionId: session.sessionId,
|
||||
direction: "input",
|
||||
code: "protocol",
|
||||
message: "Interceptor returned an invalid frame",
|
||||
});
|
||||
h.contributionListeners[0]();
|
||||
|
||||
const [result] = await h.service.handleSessionEvent({ type: "snapshot", session });
|
||||
assert.equal(result.status, "declined");
|
||||
assert.equal(h.authorized.length, 2);
|
||||
assert.equal(h.attached.length, 2);
|
||||
});
|
||||
|
||||
test("disposed terminal sessions do not retain lifecycle generations", async () => {
|
||||
const h = harness({ providers: [] });
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
const nextSession = {
|
||||
...session,
|
||||
sessionId: `disposed-session-${index}`,
|
||||
};
|
||||
await h.service.handleSessionEvent({ type: "created", session: nextSession });
|
||||
await h.service.handleSessionEvent({ type: "disposed", session: nextSession });
|
||||
}
|
||||
assert.equal(h.service._getSessionEpochCountForTests(), 0);
|
||||
});
|
||||
Reference in New Issue
Block a user