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
4947 lines
144 KiB
JavaScript
4947 lines
144 KiB
JavaScript
const assert = require("node:assert/strict");
|
|
const EventEmitter = require("node:events");
|
|
const test = require("node:test");
|
|
|
|
const {
|
|
createTerminalWorkerManager,
|
|
isTerminalWorkerEnabled,
|
|
registerTransportIdleTtlSettingsSync,
|
|
shouldForwardWorkerRendererEvent,
|
|
} = require("./terminalWorkerManager.cjs");
|
|
const { createTerminalWorkerRuntime } = require("../terminalWorker/runtime.cjs");
|
|
|
|
class FakeChild extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.messages = [];
|
|
this.transferLists = [];
|
|
this.killed = false;
|
|
}
|
|
|
|
postMessage(message, transferList) {
|
|
this.messages.push(message);
|
|
this.transferLists.push(transferList || []);
|
|
}
|
|
|
|
kill() {
|
|
this.killed = true;
|
|
}
|
|
}
|
|
|
|
test("compressed transfer lifecycle uses only the global renderer channel", () => {
|
|
for (const channel of [
|
|
"netcatty:compress:progress",
|
|
"netcatty:compress:complete",
|
|
"netcatty:compress:cancelled",
|
|
"netcatty:compress:error",
|
|
]) {
|
|
assert.equal(shouldForwardWorkerRendererEvent(channel), false, channel);
|
|
}
|
|
});
|
|
|
|
class LinkedRuntimeChild extends FakeChild {
|
|
connectRuntime(registerBridges) {
|
|
let workerMessageListener = null;
|
|
const parentPort = {
|
|
on(event, listener) {
|
|
if (event === "message") workerMessageListener = listener;
|
|
},
|
|
postMessage: (message) => {
|
|
queueMicrotask(() => this.emit("message", message));
|
|
},
|
|
};
|
|
const runtime = createTerminalWorkerRuntime({ parentPort, registerBridges });
|
|
runtime.start();
|
|
this.workerMessageListener = (message) => workerMessageListener?.(message);
|
|
return runtime;
|
|
}
|
|
|
|
postMessage(message, transferList) {
|
|
super.postMessage(message, transferList);
|
|
queueMicrotask(() => this.workerMessageListener?.(message));
|
|
}
|
|
}
|
|
|
|
function emitOutputPortReady(child, sessionId) {
|
|
const openMessage = [...child.messages].reverse().find((message) => (
|
|
message.kind === "output-port" && message.sessionId === sessionId
|
|
));
|
|
assert.ok(openMessage);
|
|
child.emit("message", {
|
|
kind: "output-port-ready",
|
|
sessionId,
|
|
sessionGeneration: openMessage.sessionGeneration,
|
|
outputPortRequestId: openMessage.outputPortRequestId,
|
|
});
|
|
}
|
|
|
|
class FakePort extends EventEmitter {
|
|
constructor(label) {
|
|
super();
|
|
this.label = label;
|
|
this.messages = [];
|
|
this.closed = false;
|
|
this.started = false;
|
|
}
|
|
|
|
postMessage(message) {
|
|
this.messages.push(message);
|
|
}
|
|
|
|
start() {
|
|
this.started = true;
|
|
}
|
|
|
|
close() {
|
|
this.closed = true;
|
|
}
|
|
}
|
|
|
|
class FakeMessageChannelMain {
|
|
constructor() {
|
|
this.port1 = new FakePort("port1");
|
|
this.port2 = new FakePort("port2");
|
|
}
|
|
}
|
|
|
|
test("isTerminalWorkerEnabled defaults on and honors NETCATTY_TERMINAL_WORKER=0", () => {
|
|
assert.equal(isTerminalWorkerEnabled({ env: {} }), true);
|
|
assert.equal(isTerminalWorkerEnabled({ env: { NETCATTY_TERMINAL_WORKER: "0" } }), false);
|
|
});
|
|
|
|
test("settings sync forwards all SSH idle TTL presets and supports disabled worker mode", () => {
|
|
const ipcMain = new EventEmitter();
|
|
const values = [];
|
|
const dispose = registerTransportIdleTtlSettingsSync(ipcMain, {
|
|
setDefaultTransportIdleTtlMs(value) {
|
|
values.push(value);
|
|
},
|
|
});
|
|
const key = "netcatty_ssh_transport_idle_ttl_ms_v1";
|
|
for (const value of [60_000, 15 * 60_000, 30 * 60_000, 0]) {
|
|
ipcMain.emit("netcatty:settings:changed", {}, { key, value });
|
|
}
|
|
ipcMain.emit("netcatty:settings:changed", {}, { key: "unrelated", value: 1 });
|
|
assert.deepEqual(values, [60_000, 15 * 60_000, 30 * 60_000, 0]);
|
|
|
|
dispose();
|
|
ipcMain.emit("netcatty:settings:changed", {}, { key, value: 60_000 });
|
|
assert.equal(values.length, 4);
|
|
|
|
const disabledIpcMain = new EventEmitter();
|
|
const disposeDisabled = registerTransportIdleTtlSettingsSync(disabledIpcMain, null);
|
|
assert.equal(disabledIpcMain.listenerCount("netcatty:settings:changed"), 0);
|
|
disposeDisabled();
|
|
});
|
|
|
|
test("worker started after a settings change inherits the latest SSH idle TTL", async () => {
|
|
const child = new FakeChild();
|
|
const forks = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork(...args) {
|
|
forks.push(args);
|
|
return child;
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
initialTransportIdleTtlMs: 5 * 60_000,
|
|
});
|
|
|
|
manager.setDefaultTransportIdleTtlMs(60_000);
|
|
manager.setDefaultTransportIdleTtlMs(15 * 60_000);
|
|
manager.setDefaultTransportIdleTtlMs(30 * 60_000);
|
|
manager.setDefaultTransportIdleTtlMs(0);
|
|
assert.equal(child.messages.length, 0, "changing an idle manager must not start its worker");
|
|
|
|
const request = manager.request("netcatty:test", {});
|
|
assert.equal(forks.length, 1);
|
|
assert.equal(forks[0][2].env.NETCATTY_SSH_TRANSPORT_IDLE_TTL_MS, "0");
|
|
assert.equal(child.messages[0].kind, "request");
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { ok: true },
|
|
});
|
|
await request;
|
|
});
|
|
|
|
test("active worker receives every SSH idle TTL change without restarting", async () => {
|
|
const child = new FakeChild();
|
|
let forks = 0;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
forks += 1;
|
|
return child;
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
initialTransportIdleTtlMs: 5 * 60_000,
|
|
});
|
|
const request = manager.request("netcatty:test", {});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { ok: true },
|
|
});
|
|
await request;
|
|
|
|
for (const value of [60_000, 15 * 60_000, 30 * 60_000, 0]) {
|
|
assert.equal(manager.setDefaultTransportIdleTtlMs(value), value);
|
|
}
|
|
assert.equal(forks, 1);
|
|
assert.deepEqual(
|
|
child.messages.filter((message) => message.kind === "set-ssh-transport-idle-ttl"),
|
|
[60_000, 15 * 60_000, 30 * 60_000, 0].map((value) => ({
|
|
kind: "set-ssh-transport-idle-ttl",
|
|
value,
|
|
})),
|
|
);
|
|
assert.equal(manager.getDefaultTransportIdleTtlMs(), 0);
|
|
assert.equal(manager.setDefaultTransportIdleTtlMs(-1), 0);
|
|
assert.equal(manager.setDefaultTransportIdleTtlMs(Number.NaN), 0);
|
|
assert.equal(forks, 1);
|
|
});
|
|
|
|
test("replacement worker inherits the last SSH idle TTL after a crash", async () => {
|
|
const children = [new FakeChild(), new FakeChild()];
|
|
const forkOptions = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork(_script, _args, options) {
|
|
forkOptions.push(options);
|
|
return children[forkOptions.length - 1];
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
initialTransportIdleTtlMs: 60_000,
|
|
});
|
|
|
|
const first = manager.request("netcatty:test", {});
|
|
children[0].emit("message", {
|
|
kind: "response",
|
|
requestId: children[0].messages[0].requestId,
|
|
result: { ok: true },
|
|
});
|
|
await first;
|
|
manager.setDefaultTransportIdleTtlMs(30 * 60_000);
|
|
children[0].emit("exit", 1);
|
|
|
|
const second = manager.request("netcatty:test", {});
|
|
assert.equal(forkOptions[1].env.NETCATTY_SSH_TRANSPORT_IDLE_TTL_MS, String(30 * 60_000));
|
|
assert.equal(children[1].messages[0].kind, "request");
|
|
children[1].emit("message", {
|
|
kind: "response",
|
|
requestId: children[1].messages[0].requestId,
|
|
result: { ok: true },
|
|
});
|
|
await second;
|
|
});
|
|
|
|
test("terminal worker runtime applies live SSH idle TTL controls", () => {
|
|
const parentPort = new EventEmitter();
|
|
parentPort.postMessage = () => {};
|
|
const values = [];
|
|
const runtime = createTerminalWorkerRuntime({
|
|
parentPort,
|
|
registerBridges() {},
|
|
setDefaultTransportIdleTtlMs(value) {
|
|
values.push(value);
|
|
},
|
|
});
|
|
runtime.start();
|
|
for (const value of [60_000, 15 * 60_000, 30 * 60_000, 0]) {
|
|
parentPort.emit("message", { kind: "set-ssh-transport-idle-ttl", value });
|
|
}
|
|
assert.deepEqual(values, [60_000, 15 * 60_000, 30 * 60_000, 0]);
|
|
});
|
|
|
|
test("request sends a worker command and resolves matching response", async () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
electronModule: { webContents: { fromId(id) { return { id }; } } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", { shell: "/bin/zsh" }, { webContentsId: 7 });
|
|
assert.equal(child.messages.length, 1);
|
|
assert.equal(child.messages[0].kind, "request");
|
|
assert.equal(child.messages[0].channel, "netcatty:local:start");
|
|
assert.deepEqual(child.messages[0].payload, { shell: "/bin/zsh" });
|
|
assert.equal(child.messages[0].webContentsId, 7);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
|
|
assert.deepEqual(await promise, { sessionId: "local-1" });
|
|
});
|
|
|
|
test("worker lifecycle observers receive renderer events and survive a worker replacement", async () => {
|
|
const children = [new FakeChild(), new FakeChild()];
|
|
let forkIndex = 0;
|
|
const rendererEvents = [];
|
|
const exits = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => children[forkIndex++] },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onWorkerRendererEvent((message) => rendererEvents.push(message.payload));
|
|
manager.onWorkerExit((error) => exits.push(error.message));
|
|
|
|
const firstRequest = manager.request("netcatty:test", {});
|
|
children[0].emit("message", {
|
|
kind: "response",
|
|
requestId: children[0].messages[0].requestId,
|
|
result: { ok: true },
|
|
});
|
|
await firstRequest;
|
|
children[0].emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:portforward:status",
|
|
payload: { tunnelId: "pf-first", status: "active" },
|
|
});
|
|
children[0].emit("exit", 9);
|
|
|
|
const secondRequest = manager.request("netcatty:test", {});
|
|
children[1].emit("message", {
|
|
kind: "response",
|
|
requestId: children[1].messages[0].requestId,
|
|
result: { ok: true },
|
|
});
|
|
await secondRequest;
|
|
children[1].emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:portforward:status",
|
|
payload: { tunnelId: "pf-second", status: "active" },
|
|
});
|
|
|
|
assert.deepEqual(rendererEvents, [
|
|
{ tunnelId: "pf-first", status: "active" },
|
|
{ tunnelId: "pf-second", status: "active" },
|
|
]);
|
|
assert.deepEqual(exits, ["Terminal worker exited with code 9"]);
|
|
manager.stop();
|
|
});
|
|
|
|
test("worker manager retains the stable host id for session-backed transfers", async () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:start", { sessionId: "session-1", hostId: "host-1" }).catch(() => {});
|
|
assert.equal(manager.getSessionHostId("session-1"), "host-1");
|
|
manager.stop();
|
|
await promise;
|
|
assert.equal(manager.getSessionHostId("session-1"), null);
|
|
});
|
|
|
|
test("external sessions reuse worker output routing and propagate input, resize, flow, and close", async () => {
|
|
const child = new FakeChild();
|
|
const observed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, isDestroyed: () => false, once() {}, removeListener() {}, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const started = manager.startExternalSession({
|
|
sessionId: "plugin-1",
|
|
webContentsId: 7,
|
|
columns: 80,
|
|
rows: 24,
|
|
protocol: "plugin:example.transport",
|
|
hostLabel: "Example transport",
|
|
hostname: "example.test",
|
|
sessionLog: {
|
|
enabled: true,
|
|
directory: "/logs",
|
|
format: "txt",
|
|
timestampsEnabled: true,
|
|
},
|
|
onInput: (data) => observed.push(["input", data]),
|
|
onResize: (size) => observed.push(["resize", size]),
|
|
onClose: (reason) => observed.push(["close", reason]),
|
|
});
|
|
const startRequest = child.messages.at(-1);
|
|
assert.equal(startRequest.channel, "netcatty:external:start");
|
|
assert.deepEqual(startRequest.payload, {
|
|
sessionId: "plugin-1",
|
|
columns: 80,
|
|
rows: 24,
|
|
protocol: "plugin:example.transport",
|
|
hostLabel: "Example transport",
|
|
hostname: "example.test",
|
|
sessionLog: {
|
|
enabled: true,
|
|
directory: "/logs",
|
|
format: "txt",
|
|
timestampsEnabled: true,
|
|
},
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: startRequest.requestId,
|
|
result: { sessionId: "plugin-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
assert.deepEqual(await started, { sessionId: "plugin-1" });
|
|
|
|
child.emit("message", { kind: "external-session-event", sessionId: "plugin-1", event: "input", data: "hello" });
|
|
child.emit("message", { kind: "external-session-event", sessionId: "plugin-1", event: "resize", columns: 100, rows: 30 });
|
|
child.emit("message", { kind: "external-session-event", sessionId: "plugin-1", event: "flow", paused: true });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(observed, [
|
|
["input", "hello"],
|
|
["resize", { columns: 100, rows: 30 }],
|
|
]);
|
|
|
|
let outputResolved = false;
|
|
const output = manager.pushExternalOutput("plugin-1", "world", {
|
|
pluginPipelineIngressBytes: 0,
|
|
}).then(() => { outputResolved = true; });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(outputResolved, false);
|
|
child.emit("message", { kind: "external-session-event", sessionId: "plugin-1", event: "flow", paused: false });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const outputRequest = child.messages.at(-1);
|
|
assert.equal(outputRequest.channel, "netcatty:external:output");
|
|
assert.deepEqual(outputRequest.payload, {
|
|
sessionId: "plugin-1",
|
|
data: "world",
|
|
meta: { pluginPipelineIngressBytes: 0 },
|
|
});
|
|
child.emit("message", { kind: "response", requestId: outputRequest.requestId, result: null });
|
|
await output;
|
|
|
|
child.emit("message", { kind: "external-session-event", sessionId: "plugin-1", event: "close", reason: "closed" });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(observed.at(-1), ["close", "closed"]);
|
|
});
|
|
|
|
test("external session finish forwards plugin diagnostics to the worker", async () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, isDestroyed: () => false, once() {}, removeListener() {}, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const started = manager.startExternalSession({
|
|
sessionId: "plugin-diagnostics",
|
|
webContentsId: 7,
|
|
columns: 80,
|
|
rows: 24,
|
|
protocol: "plugin:example.transport",
|
|
});
|
|
const startRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: startRequest.requestId,
|
|
result: { sessionId: "plugin-diagnostics" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await started;
|
|
|
|
const finished = manager.finishExternalSession("plugin-diagnostics", {
|
|
reason: "error",
|
|
error: "Connection failed",
|
|
diagnostics: [{ severity: "error", message: "Host key mismatch", path: "configuration.hostKey" }],
|
|
});
|
|
const finishRequest = child.messages.at(-1);
|
|
assert.equal(finishRequest.channel, "netcatty:external:finish");
|
|
assert.deepEqual(finishRequest.payload, {
|
|
sessionId: "plugin-diagnostics",
|
|
reason: "error",
|
|
error: "Connection failed",
|
|
diagnostics: [{ severity: "error", message: "Host key mismatch", path: "configuration.hostKey" }],
|
|
});
|
|
child.emit("message", { kind: "response", requestId: finishRequest.requestId, result: null });
|
|
assert.equal(await finished, true);
|
|
});
|
|
|
|
test("late external-session cleanup cannot finish a same-ID replacement", async () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, isDestroyed: () => false, once() {}, removeListener() {}, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
const oldOwner = Symbol("old-owner");
|
|
const newOwner = Symbol("new-owner");
|
|
const oldStart = manager.startExternalSession({
|
|
sessionId: "plugin-replaced",
|
|
ownerToken: oldOwner,
|
|
webContentsId: 7,
|
|
columns: 80,
|
|
rows: 24,
|
|
protocol: "plugin:example.transport",
|
|
});
|
|
let request = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: request.requestId,
|
|
result: { sessionId: "plugin-replaced" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await oldStart;
|
|
child.emit("message", { kind: "external-session-event", sessionId: "plugin-replaced", event: "close" });
|
|
|
|
const newStart = manager.startExternalSession({
|
|
sessionId: "plugin-replaced",
|
|
ownerToken: newOwner,
|
|
webContentsId: 8,
|
|
columns: 100,
|
|
rows: 30,
|
|
protocol: "plugin:example.transport",
|
|
});
|
|
request = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: request.requestId,
|
|
result: { sessionId: "plugin-replaced" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await newStart;
|
|
|
|
const messageCount = child.messages.length;
|
|
assert.equal(await manager.pushExternalOutput("plugin-replaced", "late", undefined, oldOwner), false);
|
|
assert.equal(await manager.finishExternalSession("plugin-replaced", { reason: "closed" }, oldOwner), false);
|
|
assert.equal(child.messages.length, messageCount);
|
|
|
|
const finish = manager.finishExternalSession("plugin-replaced", { reason: "closed" }, newOwner);
|
|
const finishRequest = child.messages.at(-1);
|
|
assert.equal(finishRequest.channel, "netcatty:external:finish");
|
|
child.emit("message", { kind: "response", requestId: finishRequest.requestId, result: null });
|
|
assert.equal(await finish, true);
|
|
});
|
|
|
|
test("external input failures close both the worker route and its provider lifecycle", async () => {
|
|
const child = new FakeChild();
|
|
const observed = [];
|
|
const inputs = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, isDestroyed: () => false, once() {}, removeListener() {}, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
const started = manager.startExternalSession({
|
|
sessionId: "plugin-input-error",
|
|
webContentsId: 7,
|
|
columns: 80,
|
|
rows: 24,
|
|
protocol: "plugin:example.transport",
|
|
async onInput(data) {
|
|
inputs.push(data);
|
|
throw new Error("provider input failed");
|
|
},
|
|
onClose: (reason) => observed.push(reason),
|
|
});
|
|
const startRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: startRequest.requestId,
|
|
result: { sessionId: "plugin-input-error" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await started;
|
|
child.emit("message", {
|
|
kind: "external-session-event",
|
|
sessionId: "plugin-input-error",
|
|
event: "input",
|
|
data: "hello",
|
|
});
|
|
child.emit("message", {
|
|
kind: "external-session-event",
|
|
sessionId: "plugin-input-error",
|
|
event: "input",
|
|
data: "late",
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(inputs, ["hello"]);
|
|
const finishRequest = child.messages.at(-1);
|
|
assert.equal(finishRequest.channel, "netcatty:external:finish");
|
|
assert.equal(finishRequest.payload.reason, "error");
|
|
child.emit("message", { kind: "response", requestId: finishRequest.requestId, result: null });
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(inputs, ["hello"]);
|
|
assert.deepEqual(observed, ["input-error"]);
|
|
await assert.rejects(manager.pushExternalOutput("plugin-input-error", "late"), /not registered/i);
|
|
});
|
|
|
|
test("external session input waits for prior plugin writes before accepting more input", async () => {
|
|
const child = new FakeChild();
|
|
const observed = [];
|
|
let releaseFirst;
|
|
const firstWrite = new Promise((resolve) => { releaseFirst = resolve; });
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, isDestroyed: () => false, once() {}, removeListener() {}, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
const started = manager.startExternalSession({
|
|
sessionId: "plugin-input-chain",
|
|
webContentsId: 7,
|
|
columns: 80,
|
|
rows: 24,
|
|
protocol: "plugin:example.transport",
|
|
async onInput(data) {
|
|
observed.push(data);
|
|
if (data === "first") await firstWrite;
|
|
},
|
|
});
|
|
const startRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: startRequest.requestId,
|
|
result: { sessionId: "plugin-input-chain" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await started;
|
|
|
|
child.emit("message", {
|
|
kind: "external-session-event",
|
|
sessionId: "plugin-input-chain",
|
|
event: "input",
|
|
data: "first",
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
child.emit("message", {
|
|
kind: "external-session-event",
|
|
sessionId: "plugin-input-chain",
|
|
event: "input",
|
|
data: "second",
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(observed, ["first"]);
|
|
|
|
releaseFirst();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(observed, ["first", "second"]);
|
|
assert.equal(child.messages.some((message) => message.channel === "netcatty:external:finish"), false);
|
|
});
|
|
|
|
test("session ownership listeners finish before buffered output is released", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
let releaseOwnership;
|
|
let outputOpen = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() { outputOpen = true; },
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: { webContents: { fromId(id) { return { id }; } } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned(() => new Promise((resolve) => { releaseOwnership = resolve; }));
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
child.emit("message", { kind: "output", sessionId: "local-1", data: "banner" });
|
|
assert.deepEqual(routed, []);
|
|
|
|
releaseOwnership();
|
|
assert.deepEqual(await promise, { sessionId: "local-1" });
|
|
assert.deepEqual(routed, [{ sessionId: "local-1", data: "banner" }]);
|
|
});
|
|
|
|
test("destroying a renderer immediately cancels its pending session ownership", async () => {
|
|
const child = new FakeChild();
|
|
const contents = new EventEmitter();
|
|
contents.id = 7;
|
|
contents.destroyed = false;
|
|
contents.isDestroyed = () => contents.destroyed;
|
|
let releaseOwnership;
|
|
let outputOpened = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() { outputOpened = true; },
|
|
},
|
|
electronModule: { webContents: { fromId: () => contents } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned(() => new Promise((resolve) => { releaseOwnership = resolve; }));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
while (!releaseOwnership) await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(manager.ownsSession("local-1", 7), true);
|
|
|
|
contents.destroyed = true;
|
|
contents.emit("destroyed");
|
|
assert.equal(manager.ownsSession("local-1", 7), false);
|
|
assert.equal(manager.getSessionOwnerWebContentsId("local-1"), null);
|
|
releaseOwnership();
|
|
|
|
await assert.rejects(start, /output route opened/u);
|
|
assert.equal(outputOpened, false);
|
|
});
|
|
|
|
test("worker exit after renderer loss still closes a session with pending ownership", async () => {
|
|
const child = new FakeChild();
|
|
const contents = new EventEmitter();
|
|
contents.id = 7;
|
|
contents.destroyed = false;
|
|
contents.isDestroyed = () => contents.destroyed;
|
|
const closed = [];
|
|
let releaseOwnership;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession() {} },
|
|
electronModule: { webContents: { fromId: () => contents } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned(() => new Promise((resolve) => { releaseOwnership = resolve; }));
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
while (!releaseOwnership) await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
contents.destroyed = true;
|
|
contents.emit("destroyed");
|
|
releaseOwnership();
|
|
await assert.rejects(start, /output route opened/u);
|
|
assert.deepEqual(closed, []);
|
|
|
|
child.emit("exit", 1);
|
|
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "worker-exit" }]);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "worker-exit" }]);
|
|
});
|
|
|
|
test("a start closed while ownership is pending rejects instead of creating a ghost session", async () => {
|
|
const child = new FakeChild();
|
|
let releaseOwnership;
|
|
let outputOpened = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
outputOpened = true;
|
|
},
|
|
},
|
|
electronModule: { webContents: { fromId(id) { return { id }; } } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned(() => new Promise((resolve) => { releaseOwnership = resolve; }));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
manager.send("netcatty:close", { sessionId: "local-1" }, { webContentsId: 7 });
|
|
releaseOwnership();
|
|
|
|
await assert.rejects(start, /closed before its output route opened/u);
|
|
assert.equal(outputOpened, false);
|
|
assert.equal(manager.hasOpenSession("local-1"), false);
|
|
});
|
|
|
|
test("a worker exit while ownership is pending rejects instead of creating a ghost session", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
let releaseOwnership;
|
|
let outputOpened = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
outputOpened = true;
|
|
},
|
|
},
|
|
electronModule: { webContents: { fromId(id) { return { id }; } } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned(() => new Promise((resolve) => { releaseOwnership = resolve; }));
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
child.emit("exit", 1);
|
|
await assert.rejects(start, /Terminal worker exited with code 1/u);
|
|
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "worker-exit" }]);
|
|
releaseOwnership();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
assert.equal(outputOpened, false);
|
|
assert.equal(manager.hasOpenSession("local-1"), false);
|
|
});
|
|
|
|
test("an older ownership waiter cannot release a newer output route", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
const releaseOwnership = [];
|
|
let outputOpen = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() { outputOpen = true; },
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: { webContents: { fromId(id) { return { id }; } } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned(() => new Promise((resolve) => { releaseOwnership.push(resolve); }));
|
|
|
|
const first = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
const second = manager.request("netcatty:local:reconnect", { sessionId: "local-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[1].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(releaseOwnership.length, 2);
|
|
|
|
child.emit("message", { kind: "output", sessionId: "local-1", data: "new-banner" });
|
|
releaseOwnership[0]();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(outputOpen, false);
|
|
assert.deepEqual(routed, []);
|
|
|
|
releaseOwnership[1]();
|
|
assert.deepEqual(await first, { sessionId: "local-1" });
|
|
assert.deepEqual(await second, { sessionId: "local-1" });
|
|
assert.deepEqual(routed, [{ sessionId: "local-1", data: "new-banner" }]);
|
|
});
|
|
|
|
test("terminal interceptor ports transfer directly to the worker and warnings stay host-owned", () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
const port = new FakePort("interceptor");
|
|
const warnings = [];
|
|
manager.onTerminalInterceptorWarning((warning) => warnings.push(warning));
|
|
manager.attachTerminalInterceptor({
|
|
sessionId: "session-1",
|
|
direction: "input",
|
|
providerId: "com.example.input",
|
|
}, port);
|
|
assert.equal(child.messages[0].kind, "terminal-interceptor-port");
|
|
assert.deepEqual(child.transferLists[0], [port]);
|
|
child.emit("message", {
|
|
kind: "terminal-interceptor-warning",
|
|
warning: { sessionId: "session-1", direction: "input", code: "timeout" },
|
|
});
|
|
assert.equal(warnings[0].code, "timeout");
|
|
manager.detachTerminalInterceptor("session-1", "input");
|
|
assert.equal(child.messages[1].kind, "terminal-interceptor-detach");
|
|
child.emit("exit", 9);
|
|
assert.deepEqual(warnings[1], {
|
|
code: "worker-exit",
|
|
message: "Terminal worker exited with code 9",
|
|
});
|
|
});
|
|
|
|
test("request opens a terminal output port when a session starts", async () => {
|
|
const child = new FakeChild();
|
|
const opened = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession(sessionId, webContents) {
|
|
opened.push({ sessionId, webContentsId: webContents.id });
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
|
|
assert.deepEqual(opened, [{ sessionId: "local-1", webContentsId: 7 }]);
|
|
});
|
|
|
|
test("worker ZMODEM upload dialog request opens picker from the owning webContents", async () => {
|
|
const child = new FakeChild();
|
|
const shown = [];
|
|
const contents = { id: 7 };
|
|
const window = { id: "main-window" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
assert.equal(id, 7);
|
|
return contents;
|
|
},
|
|
},
|
|
BrowserWindow: {
|
|
fromWebContents(value) {
|
|
assert.equal(value, contents);
|
|
return window;
|
|
},
|
|
},
|
|
dialog: {
|
|
async showOpenDialog(owner, options) {
|
|
shown.push({ owner, options });
|
|
return { canceled: false, filePaths: ["/tmp/upload.txt"] };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.ensureStarted();
|
|
child.emit("message", {
|
|
kind: "zmodem-upload-dialog",
|
|
requestId: "dialog-1",
|
|
webContentsId: 7,
|
|
});
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
assert.deepEqual(shown, [{
|
|
owner: window,
|
|
options: {
|
|
properties: ["openFile", "multiSelections"],
|
|
title: "Select files to upload (ZMODEM)",
|
|
},
|
|
}]);
|
|
assert.deepEqual(child.messages.at(-1), {
|
|
kind: "zmodem-upload-dialog-result",
|
|
requestId: "dialog-1",
|
|
result: { canceled: false, filePaths: ["/tmp/upload.txt"] },
|
|
});
|
|
});
|
|
|
|
test("worker ZMODEM download dialog request opens directory picker from the owning webContents", async () => {
|
|
const child = new FakeChild();
|
|
const shown = [];
|
|
const contents = { id: 7 };
|
|
const window = { id: "main-window" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
assert.equal(id, 7);
|
|
return contents;
|
|
},
|
|
},
|
|
BrowserWindow: {
|
|
fromWebContents(value) {
|
|
assert.equal(value, contents);
|
|
return window;
|
|
},
|
|
},
|
|
dialog: {
|
|
async showOpenDialog(owner, options) {
|
|
shown.push({ owner, options });
|
|
return { canceled: false, filePaths: ["/tmp/downloads"] };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.ensureStarted();
|
|
child.emit("message", {
|
|
kind: "zmodem-download-dialog",
|
|
requestId: "download-dialog-1",
|
|
webContentsId: 7,
|
|
});
|
|
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
assert.deepEqual(shown, [{
|
|
owner: window,
|
|
options: {
|
|
properties: ["openDirectory", "createDirectory"],
|
|
title: "Select download directory (ZMODEM)",
|
|
},
|
|
}]);
|
|
assert.deepEqual(child.messages.at(-1), {
|
|
kind: "zmodem-download-dialog-result",
|
|
requestId: "download-dialog-1",
|
|
result: { canceled: false, filePaths: ["/tmp/downloads"] },
|
|
});
|
|
});
|
|
|
|
test("request transfers the output port to the worker when available", async () => {
|
|
const child = new FakeChild();
|
|
const outputPort = { label: "worker-output-port" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession(sessionId, webContents, options) {
|
|
assert.equal(sessionId, "local-1");
|
|
assert.equal(webContents.id, 7);
|
|
assert.deepEqual(options, { transferToWorker: true });
|
|
return outputPort;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "early",
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
|
|
assert.equal(child.messages[1].kind, "output-port");
|
|
assert.equal(child.messages[1].sessionId, "local-1");
|
|
assert.equal(child.messages[1].sessionGeneration, 0);
|
|
assert.equal(typeof child.messages[1].outputPortRequestId, "string");
|
|
assert.deepEqual(child.messages[1].bufferedOutput, ["early"]);
|
|
assert.deepEqual(child.transferLists[1], [outputPort]);
|
|
});
|
|
|
|
test("request transfers a dedicated urgent input port to the worker and renderer", async () => {
|
|
const child = new FakeChild();
|
|
const rendererMessages = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
MessageChannelMain: FakeMessageChannelMain,
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
id,
|
|
postMessage(channel, payload, transferList) {
|
|
rendererMessages.push({ id, channel, payload, transferList });
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
|
|
assert.equal(child.messages[1].kind, "urgent-input-port");
|
|
assert.equal(child.messages[1].webContentsId, 7);
|
|
assert.deepEqual(child.transferLists[1].map((port) => port.label), ["port1"]);
|
|
assert.equal(rendererMessages[0].channel, "netcatty:terminal-urgent-input-port");
|
|
assert.deepEqual(rendererMessages[0].transferList.map((port) => port.label), ["port2"]);
|
|
});
|
|
|
|
test("destroying a renderer closes its dedicated urgent input port", async () => {
|
|
const child = new FakeChild();
|
|
const contents = new EventEmitter();
|
|
contents.id = 7;
|
|
contents.postMessage = () => {};
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
MessageChannelMain: FakeMessageChannelMain,
|
|
electronModule: { webContents: { fromId: () => contents } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
contents.emit("destroyed");
|
|
|
|
assert.ok(child.messages.some((message) => (
|
|
message.kind === "close-urgent-input-port" && message.webContentsId === 7
|
|
)));
|
|
});
|
|
|
|
test("failed renderer urgent-input transfer closes the worker port", async () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
MessageChannelMain: FakeMessageChannelMain,
|
|
electronModule: {
|
|
webContents: {
|
|
fromId: () => ({ id: 7, postMessage() { throw new Error("destroyed"); } }),
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
|
|
assert.ok(child.messages.some((message) => (
|
|
message.kind === "close-urgent-input-port" && message.webContentsId === 7
|
|
)));
|
|
});
|
|
|
|
test("output-port-ready flushes output that arrived during port transfer", async () => {
|
|
const child = new FakeChild();
|
|
const outputPort = { label: "worker-output-port" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
return outputPort;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "during-transfer",
|
|
});
|
|
emitOutputPortReady(child, "local-1");
|
|
|
|
assert.deepEqual(child.messages[2], {
|
|
kind: "output-flush",
|
|
sessionId: "local-1",
|
|
chunks: ["during-transfer"],
|
|
});
|
|
});
|
|
|
|
test("a stale output-port-ready cannot release a reused session route", async () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
MessageChannelMain: FakeMessageChannelMain,
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
return new FakePort("worker-output");
|
|
},
|
|
closeSession() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await first;
|
|
const oldOpen = child.messages.find((message) => message.kind === "output-port");
|
|
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await second;
|
|
const newOpen = [...child.messages].reverse().find((message) => message.kind === "output-port");
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "NEW-PENDING",
|
|
sessionGeneration: 1,
|
|
originRequestId: secondRequest.requestId,
|
|
});
|
|
|
|
child.emit("message", {
|
|
kind: "output-port-ready",
|
|
sessionId: "session-1",
|
|
sessionGeneration: oldOpen.sessionGeneration,
|
|
outputPortRequestId: oldOpen.outputPortRequestId,
|
|
});
|
|
assert.equal(
|
|
child.messages.some((message) => message.kind === "output-flush"),
|
|
false,
|
|
);
|
|
|
|
child.emit("message", {
|
|
kind: "output-port-ready",
|
|
sessionId: "session-1",
|
|
sessionGeneration: newOpen.sessionGeneration,
|
|
outputPortRequestId: newOpen.outputPortRequestId,
|
|
});
|
|
assert.deepEqual(child.messages.at(-1), {
|
|
kind: "output-flush",
|
|
sessionId: "session-1",
|
|
chunks: ["NEW-PENDING"],
|
|
});
|
|
|
|
assert.equal((await manager.rebindOutputSession("session-1", 9)).success, true);
|
|
assert.equal(manager.drainOutputSession("session-1", "too-early"), false);
|
|
const reboundOpen = [...child.messages].reverse().find((message) => (
|
|
message.kind === "output-port"
|
|
));
|
|
child.emit("message", {
|
|
kind: "output-port-ready",
|
|
sessionId: "session-1",
|
|
sessionGeneration: reboundOpen.sessionGeneration,
|
|
outputPortRequestId: reboundOpen.outputPortRequestId,
|
|
});
|
|
assert.equal(manager.drainOutputSession("session-1", "after-ready"), true);
|
|
assert.deepEqual(child.messages.at(-1), {
|
|
kind: "output-drain",
|
|
sessionId: "session-1",
|
|
requestId: "after-ready",
|
|
});
|
|
});
|
|
|
|
test("worker buffered output byte cap preserves split alternate-screen metadata", async () => {
|
|
const child = new FakeChild();
|
|
const outputPort = { label: "worker-output-port" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
return outputPort;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
maxPendingOutputBytes: "hREADY".length,
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "\x1b[?1049hREADY",
|
|
});
|
|
emitOutputPortReady(child, "local-1");
|
|
|
|
assert.deepEqual(child.messages[2], {
|
|
kind: "output-flush",
|
|
sessionId: "local-1",
|
|
chunks: [{
|
|
data: "hREADY",
|
|
meta: {
|
|
droppedOutputMayAffectTerminalState: true,
|
|
droppedOutputAlternateScreenAction: "enter",
|
|
},
|
|
}],
|
|
});
|
|
});
|
|
|
|
test("worker buffered output chunk cap carries dropped metadata to retained output", async () => {
|
|
const child = new FakeChild();
|
|
const outputPort = { label: "worker-output-port" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
return outputPort;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
maxPendingOutputChunks: 1,
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "old",
|
|
meta: { droppedOutputMayAffectTerminalState: true },
|
|
});
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "READY",
|
|
});
|
|
emitOutputPortReady(child, "local-1");
|
|
|
|
assert.deepEqual(child.messages[2], {
|
|
kind: "output-flush",
|
|
sessionId: "local-1",
|
|
chunks: [{
|
|
data: "READY",
|
|
meta: { droppedOutputMayAffectTerminalState: true },
|
|
}],
|
|
});
|
|
});
|
|
|
|
test("metadata-only interceptor output obeys the pending chunk cap", async () => {
|
|
const child = new FakeChild();
|
|
const outputPort = { label: "worker-output-port" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork() { return child; } },
|
|
terminalOutputChannel: { openSession() { return outputPort; } },
|
|
electronModule: { webContents: { fromId(id) { return { id }; } } },
|
|
maxPendingOutputChunks: 1,
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
for (let index = 0; index < 3; index += 1) {
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "",
|
|
meta: { pluginPipelineIngressBytes: 1 },
|
|
});
|
|
}
|
|
emitOutputPortReady(child, "local-1");
|
|
|
|
assert.deepEqual(child.messages[2], {
|
|
kind: "output-flush",
|
|
sessionId: "local-1",
|
|
chunks: [{ data: "", meta: { pluginPipelineIngressBytes: 3 } }],
|
|
});
|
|
});
|
|
|
|
test("pending output merge keeps state and provenance from only the latest raw chunk", async () => {
|
|
const child = new FakeChild();
|
|
const outputPort = { label: "worker-output-port" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork() { return child; } },
|
|
terminalOutputChannel: { openSession() { return outputPort; } },
|
|
electronModule: { webContents: { fromId(id) { return { id }; } } },
|
|
maxPendingOutputChunks: 1,
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "Password: ",
|
|
meta: {
|
|
pluginPipelineIngressBytes: 10,
|
|
pluginPipelineProcessed: true,
|
|
pluginPipelineSensitiveInput: true,
|
|
},
|
|
});
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "READY",
|
|
});
|
|
emitOutputPortReady(child, "local-1");
|
|
|
|
assert.deepEqual(child.messages[2], {
|
|
kind: "output-flush",
|
|
sessionId: "local-1",
|
|
chunks: [{ data: "READY", meta: { pluginPipelineIngressBytes: 10 } }],
|
|
});
|
|
});
|
|
|
|
test("partial trimming carries sliced raw ingress into inherited flow accounting", async () => {
|
|
const child = new FakeChild();
|
|
const outputPort = { label: "worker-output-port" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork() { return child; } },
|
|
terminalOutputChannel: { openSession() { return outputPort; } },
|
|
electronModule: { webContents: { fromId(id) { return { id }; } } },
|
|
maxPendingOutputBytes: 5,
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "",
|
|
meta: { pluginPipelineIngressBytes: 10, pluginPipelineProcessed: true },
|
|
});
|
|
child.emit("message", { kind: "output", sessionId: "local-1", data: "abcde" });
|
|
child.emit("message", { kind: "output", sessionId: "local-1", data: "Z" });
|
|
emitOutputPortReady(child, "local-1");
|
|
|
|
assert.deepEqual(child.messages[2], {
|
|
kind: "output-flush",
|
|
sessionId: "local-1",
|
|
chunks: [
|
|
{ data: "bcde", meta: { pluginPipelineIngressBytes: 11 } },
|
|
"Z",
|
|
],
|
|
});
|
|
});
|
|
|
|
test("worker fallback output after a ready output port is delivered over legacy IPC", async () => {
|
|
const child = new FakeChild();
|
|
const sent = [];
|
|
const closed = [];
|
|
const outputPort = { label: "worker-output-port" };
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
return outputPort;
|
|
},
|
|
closeSession(sessionId) {
|
|
closed.push(sessionId);
|
|
},
|
|
send() {
|
|
throw new Error("ready worker fallback output should not be sent back through main output channel");
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
id,
|
|
send(channel, payload) {
|
|
sent.push({ id, channel, payload });
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
emitOutputPortReady(child, "local-1");
|
|
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "fallback",
|
|
});
|
|
|
|
assert.deepEqual(sent, [
|
|
{
|
|
id: 7,
|
|
channel: "netcatty:data",
|
|
payload: { sessionId: "local-1", data: "fallback" },
|
|
},
|
|
]);
|
|
assert.deepEqual(closed, ["local-1"]);
|
|
assert.equal(child.messages.some((message) => message.kind === "output-flush" && message.chunks?.includes("fallback")), false);
|
|
});
|
|
|
|
test("falls back to netcatty:data when no output port is available", async () => {
|
|
const child = new FakeChild();
|
|
const sent = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
return false;
|
|
},
|
|
send() {
|
|
return false;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
id,
|
|
send(channel, payload) {
|
|
sent.push({ id, channel, payload });
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "local-1",
|
|
data: "early",
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
|
|
assert.deepEqual(sent, [
|
|
{
|
|
id: 7,
|
|
channel: "netcatty:data",
|
|
payload: { sessionId: "local-1", data: "early" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("send posts fire-and-forget control commands to the worker", () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.send("netcatty:interrupt", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
|
|
assert.deepEqual(child.messages, [
|
|
{
|
|
kind: "send",
|
|
channel: "netcatty:interrupt",
|
|
payload: { sessionId: "session-1" },
|
|
webContentsId: 7,
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("worker output is routed through the dedicated terminal output channel", () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
send(sessionId, data) {
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.ensureStarted();
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "hello",
|
|
});
|
|
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "hello" }]);
|
|
});
|
|
|
|
test("worker output notifies output taps before renderer routing", () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
const tapped = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
send(sessionId, data) {
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.addOutputTap((sessionId, data) => tapped.push({ sessionId, data }));
|
|
manager.ensureStarted();
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "hello",
|
|
});
|
|
|
|
assert.deepEqual(tapped, [{ sessionId: "session-1", data: "hello" }]);
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "hello" }]);
|
|
});
|
|
|
|
test("worker fallback output already announced via output-tap does not notify taps twice", () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
const tapped = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
send(sessionId, data) {
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.addOutputTap((sessionId, data) => tapped.push({ sessionId, data }));
|
|
manager.ensureStarted();
|
|
// The runtime sender emits an output-tap message, then falls back to a
|
|
// plain output message for the same chunk when the output port is not
|
|
// usable. Taps must fire exactly once for that chunk.
|
|
child.emit("message", {
|
|
kind: "output-tap",
|
|
sessionId: "session-1",
|
|
data: "hello",
|
|
});
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "hello",
|
|
tapped: true,
|
|
});
|
|
|
|
assert.deepEqual(tapped, [{ sessionId: "session-1", data: "hello" }]);
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "hello" }]);
|
|
});
|
|
|
|
test("worker output-tap messages notify taps without duplicate renderer routing", () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
const tapped = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
send(sessionId, data) {
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.addOutputTap((sessionId, data) => tapped.push({ sessionId, data }));
|
|
manager.ensureStarted();
|
|
child.emit("message", {
|
|
kind: "output-tap",
|
|
sessionId: "session-1",
|
|
data: "direct-port-output",
|
|
});
|
|
|
|
assert.deepEqual(tapped, [{ sessionId: "session-1", data: "direct-port-output" }]);
|
|
assert.deepEqual(routed, []);
|
|
});
|
|
|
|
test("worker buffers early output until the output port is opened", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
let opened = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
opened = true;
|
|
},
|
|
send(sessionId, data) {
|
|
if (!opened) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "hello",
|
|
});
|
|
assert.deepEqual(routed, []);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await promise;
|
|
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "hello" }]);
|
|
});
|
|
|
|
test("close immediately clears the output route and drops pending output", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const routed = [];
|
|
let opened = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
opened = true;
|
|
},
|
|
send(sessionId, data) {
|
|
if (!opened) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
closeSession(sessionId) {
|
|
closed.push(sessionId);
|
|
opened = false;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "old",
|
|
});
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await assert.rejects(promise, /closed before its output route opened/u);
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "late",
|
|
});
|
|
|
|
assert.deepEqual(closed, ["session-1"]);
|
|
assert.deepEqual(routed, []);
|
|
});
|
|
|
|
test("await close immediately clears the output route and drops pending output", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const routed = [];
|
|
let opened = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
opened = true;
|
|
},
|
|
send(sessionId, data) {
|
|
if (!opened) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
closeSession(sessionId) {
|
|
closed.push(sessionId);
|
|
opened = false;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const startPromise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await startPromise;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
|
|
const closePromise = manager.request("netcatty:close:await", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
assert.equal(manager.hasOpenSession("session-1"), false);
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "late",
|
|
});
|
|
const closeRequest = child.messages.find((message) => message.channel === "netcatty:close:await");
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: closeRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await closePromise;
|
|
|
|
assert.deepEqual(closed, ["session-1"]);
|
|
assert.deepEqual(routed, []);
|
|
assert.equal(manager.hasOpenSession("session-1"), false);
|
|
});
|
|
|
|
test("worker renderer events are forwarded to their original webContents", () => {
|
|
const child = new FakeChild();
|
|
const forwarded = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
send(channel, payload) {
|
|
forwarded.push({ id, channel, payload });
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.ensureStarted();
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1" },
|
|
});
|
|
|
|
assert.deepEqual(forwarded, [
|
|
{ id: 7, channel: "netcatty:exit", payload: { sessionId: "session-1" } },
|
|
]);
|
|
});
|
|
|
|
test("worker renderer events wrapped in MessageEvent data are forwarded", () => {
|
|
const child = new FakeChild();
|
|
const forwarded = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
send(channel, payload) {
|
|
forwarded.push({ id, channel, payload });
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.ensureStarted();
|
|
child.emit("message", {
|
|
data: {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:zmodem:progress",
|
|
payload: {
|
|
sessionId: "session-1",
|
|
filename: "large.bin",
|
|
transferred: 1024,
|
|
total: 2048,
|
|
transferType: "download",
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(forwarded, [
|
|
{
|
|
id: 7,
|
|
channel: "netcatty:zmodem:progress",
|
|
payload: {
|
|
sessionId: "session-1",
|
|
filename: "large.bin",
|
|
transferred: 1024,
|
|
total: 2048,
|
|
transferType: "download",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("worker terminal exit notifies host session lifecycle listeners", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await start;
|
|
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "exited" },
|
|
});
|
|
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
|
|
});
|
|
|
|
test("explicit close notifies host session lifecycle exactly once before the worker exit", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await start;
|
|
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "exited" },
|
|
});
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
|
});
|
|
|
|
test("retainOwnership close does not mark host_open cleanup as explicit", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await start;
|
|
|
|
manager.send("netcatty:close", {
|
|
sessionId: "session-1",
|
|
retainOwnership: true,
|
|
}, { webContentsId: 7 });
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
|
|
});
|
|
|
|
async function startWorkerSessionForLifecycle(manager, child) {
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await start;
|
|
}
|
|
|
|
function createLifecycleManager(child, closed) {
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
return manager;
|
|
}
|
|
|
|
test("fire-and-forget close after a worker exit still emits an explicit lifecycle event", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createLifecycleManager(child, closed);
|
|
await startWorkerSessionForLifecycle(manager, child);
|
|
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "exited", exitCode: 0 },
|
|
});
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
|
|
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "exited" },
|
|
{ sessionId: "session-1", reason: "closed", explicit: true },
|
|
]);
|
|
});
|
|
|
|
test("awaited close after a worker exit still emits an explicit lifecycle event", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createLifecycleManager(child, closed);
|
|
await startWorkerSessionForLifecycle(manager, child);
|
|
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "exited", exitCode: 0 },
|
|
});
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
|
|
|
|
await manager.request("netcatty:close:await", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "exited" },
|
|
{ sessionId: "session-1", reason: "closed", explicit: true },
|
|
]);
|
|
});
|
|
|
|
test("a duplicate exit from an old session generation cannot close a reconnected session", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const first = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await first;
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "error" },
|
|
sessionGeneration: 0,
|
|
});
|
|
|
|
const reconnect = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages.at(-1).requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await reconnect;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "close" },
|
|
sessionGeneration: 0,
|
|
});
|
|
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "error" }]);
|
|
});
|
|
|
|
test("a duplicate old-generation exit cannot cancel a pending same-id reconnect", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const first = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await first;
|
|
const oldExit = {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "error" },
|
|
sessionGeneration: 0,
|
|
};
|
|
child.emit("message", oldExit);
|
|
|
|
const reconnect = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
child.emit("message", oldExit);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages.at(-1).requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
await reconnect;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "error" }]);
|
|
});
|
|
|
|
test("an old-generation exit after explicit close cannot cancel a pending reconnect", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const routed = [];
|
|
let outputOpen = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
outputOpen = true;
|
|
return true;
|
|
},
|
|
closeSession() {
|
|
outputOpen = false;
|
|
},
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const first = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const firstRequestId = child.messages[0].requestId;
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await first;
|
|
const staleReplacement = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const staleRequest = child.messages.at(-1);
|
|
const staleRejected = assert.rejects(staleReplacement, /close failed/u);
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
|
|
const reconnect = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 9,
|
|
});
|
|
const reconnectRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "session-superseding",
|
|
sessionId: "session-1",
|
|
sessionGeneration: 0,
|
|
replacementRequestId: staleRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: staleRequest.requestId,
|
|
error: "close failed",
|
|
});
|
|
await staleRejected;
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "STALE-OLD",
|
|
sessionGeneration: 0,
|
|
originRequestId: firstRequestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "NEW-EARLY",
|
|
sessionGeneration: 1,
|
|
originRequestId: reconnectRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "closed" },
|
|
sessionGeneration: 0,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: reconnectRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
await reconnect;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "NEW-EARLY" }]);
|
|
});
|
|
|
|
test("a closed pending start response records its generation before a later reconnect", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const first = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
const firstRequest = child.messages.at(-1);
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await assert.rejects(first, /closed before its output route opened/u);
|
|
|
|
const second = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "closed" },
|
|
sessionGeneration: 0,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages.at(-1).requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
await second;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
|
});
|
|
|
|
test("a much older exit cannot cancel a reconnect after multiple generations", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await start;
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "gen-0" },
|
|
sessionGeneration: 0,
|
|
});
|
|
|
|
const second = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages.at(-1).requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await second;
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "gen-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
const third = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "late-gen-0" },
|
|
sessionGeneration: 0,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages.at(-1).requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 2,
|
|
});
|
|
|
|
await third;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "gen-0" },
|
|
{ sessionId: "session-1", reason: "gen-1" },
|
|
]);
|
|
});
|
|
|
|
test("rebound interactive events target only the popup while exit also reaches home", async () => {
|
|
const child = new FakeChild();
|
|
const forwarded = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork() { return child; } },
|
|
terminalOutputChannel: {
|
|
openSession() { return true; },
|
|
closeSession() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
id,
|
|
isDestroyed() { return false; },
|
|
send(channel, payload) { forwarded.push({ id, channel, payload }); },
|
|
};
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const started = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await started;
|
|
assert.equal((await manager.rebindOutputSession("session-1", 9)).success, true);
|
|
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:zmodem:overwrite-request",
|
|
payload: { sessionId: "session-1", requestId: "request-1" },
|
|
});
|
|
assert.deepEqual(forwarded, [{
|
|
id: 9,
|
|
channel: "netcatty:zmodem:overwrite-request",
|
|
payload: { sessionId: "session-1", requestId: "request-1" },
|
|
}]);
|
|
|
|
forwarded.length = 0;
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "exited" },
|
|
});
|
|
assert.deepEqual(forwarded, [
|
|
{ id: 9, channel: "netcatty:exit", payload: { sessionId: "session-1", reason: "exited" } },
|
|
{ id: 7, channel: "netcatty:exit", payload: { sessionId: "session-1", reason: "exited" } },
|
|
]);
|
|
});
|
|
|
|
test("rebind waits for ownership and routes interactive events to the pending renderer", async () => {
|
|
const child = new FakeChild();
|
|
const forwarded = [];
|
|
const dialogOwners = [];
|
|
let releaseRebind;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork() { return child; } },
|
|
terminalOutputChannel: { openSession() { return true; }, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
id,
|
|
isDestroyed() { return false; },
|
|
send(channel, payload) { forwarded.push({ id, channel, payload }); },
|
|
};
|
|
},
|
|
},
|
|
BrowserWindow: {
|
|
fromWebContents(contents) { return { webContentsId: contents.id }; },
|
|
},
|
|
dialog: {
|
|
async showOpenDialog(owner) {
|
|
dialogOwners.push(owner?.webContentsId ?? null);
|
|
return { canceled: true, filePaths: [] };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned(({ webContentsId }) => (
|
|
webContentsId === 9
|
|
? new Promise((resolve) => { releaseRebind = resolve; })
|
|
: undefined
|
|
));
|
|
|
|
const started = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await started;
|
|
|
|
let settled = false;
|
|
const rebound = manager.rebindOutputSession("session-1", 9).then((result) => {
|
|
settled = true;
|
|
return result;
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(settled, false);
|
|
assert.equal(manager.getSessionWebContentsId("session-1"), 7);
|
|
assert.equal(manager.getSessionOwnerWebContentsId("session-1"), 9);
|
|
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:zmodem:overwrite-request",
|
|
payload: { sessionId: "session-1", requestId: "request-1" },
|
|
});
|
|
assert.deepEqual(forwarded, [{
|
|
id: 9,
|
|
channel: "netcatty:zmodem:overwrite-request",
|
|
payload: { sessionId: "session-1", requestId: "request-1" },
|
|
}]);
|
|
|
|
child.emit("message", {
|
|
kind: "zmodem-upload-dialog",
|
|
requestId: "upload-dialog-1",
|
|
sessionId: "session-1",
|
|
webContentsId: 7,
|
|
});
|
|
child.emit("message", {
|
|
kind: "zmodem-download-dialog",
|
|
requestId: "download-dialog-1",
|
|
sessionId: "session-1",
|
|
webContentsId: 7,
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.deepEqual(dialogOwners, [9, 9]);
|
|
|
|
releaseRebind();
|
|
assert.equal((await rebound).success, true);
|
|
assert.equal(manager.getSessionWebContentsId("session-1"), 9);
|
|
assert.equal(manager.getSessionOwnerWebContentsId("session-1"), 9);
|
|
});
|
|
|
|
test("failed output-port transfer retires the dead worker before the next start", async () => {
|
|
const children = [];
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
const postMessage = child.postMessage.bind(child);
|
|
child.postMessage = (message, transferList) => {
|
|
if (message?.kind === "output-port") {
|
|
throw new Error("worker transfer failed");
|
|
}
|
|
postMessage(message, transferList);
|
|
};
|
|
const closed = [];
|
|
let openCount = 0;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
if (children.length === 1) return child;
|
|
return children.at(-1);
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
openCount += 1;
|
|
return openCount === 1 ? true : { label: "worker-output-port" };
|
|
},
|
|
send() { return false; },
|
|
closeSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
id,
|
|
isDestroyed() { return false; },
|
|
send() {},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const started = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await started;
|
|
|
|
const rebound = manager.rebindOutputSession("session-1", 9);
|
|
child.emit("message", { kind: "output", sessionId: "session-1", data: "buffered" });
|
|
assert.deepEqual(await rebound, {
|
|
success: false,
|
|
error: "Failed to rebind session output",
|
|
});
|
|
assert.equal(manager.hasOpenSession("session-1"), false);
|
|
assert.equal(child.killed, true);
|
|
assert.equal(manager.getSessionWebContentsId("session-1"), null);
|
|
assert.equal(manager.getAttachHomeWebContentsId("session-1"), null);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
|
|
|
|
const replacement = new FakeChild();
|
|
children.push(replacement);
|
|
const restarted = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 9,
|
|
});
|
|
replacement.emit("message", {
|
|
kind: "response",
|
|
requestId: replacement.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await restarted;
|
|
child.emit("exit", 1);
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
});
|
|
|
|
test("explicit close notifies both a rebound popup and its home renderer", async () => {
|
|
const child = new FakeChild();
|
|
const forwarded = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId: (id) => ({
|
|
id,
|
|
isDestroyed: () => false,
|
|
send: (channel, payload) => forwarded.push({ id, channel, payload }),
|
|
}),
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const started = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await started;
|
|
assert.equal((await manager.rebindOutputSession("session-1", 9)).success, true);
|
|
|
|
const closing = manager.request(
|
|
"netcatty:close:await",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const closeRequest = child.messages.at(-1);
|
|
child.emit("message", { kind: "response", requestId: closeRequest.requestId, result: undefined });
|
|
await closing;
|
|
|
|
assert.deepEqual(forwarded, [
|
|
{ id: 9, channel: "netcatty:exit", payload: { sessionId: "session-1", exitCode: 0, reason: "closed" } },
|
|
{ id: 7, channel: "netcatty:exit", payload: { sessionId: "session-1", exitCode: 0, reason: "closed" } },
|
|
]);
|
|
assert.equal(manager.getAttachHomeWebContentsId("session-1"), null);
|
|
assert.equal(manager.hasOpenSession("session-1"), false);
|
|
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "closed" },
|
|
});
|
|
assert.equal(forwarded.length, 2, "worker transport close is not forwarded twice");
|
|
|
|
const lateFlow = manager.request(
|
|
"netcatty:terminal:setFlowPausedAndWait",
|
|
{ sessionId: "session-1", paused: true },
|
|
{ webContentsId: 9 },
|
|
);
|
|
const flowRequest = child.messages.at(-1);
|
|
child.emit("message", { kind: "response", requestId: flowRequest.requestId, result: { success: false } });
|
|
await lateFlow;
|
|
assert.equal(manager.hasOpenSession("session-1"), false, "late control requests cannot reopen a closed session");
|
|
});
|
|
|
|
test("worker exit events close the session output route", () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
closeSession(sessionId) {
|
|
closed.push(sessionId);
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId() {
|
|
return { send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
manager.ensureStarted();
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1" },
|
|
});
|
|
|
|
assert.deepEqual(closed, ["session-1"]);
|
|
});
|
|
|
|
test("worker exit rejects pending requests and closes output routes", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
closeAll() {
|
|
closed.push("all");
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
|
|
child.emit("exit", 1);
|
|
|
|
await assert.rejects(promise, /Terminal worker exited/);
|
|
assert.deepEqual(closed, ["all"]);
|
|
});
|
|
|
|
test("worker exit closes a session identified by output before its start response", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { closeAll() {} },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", { kind: "output", sessionId: "session-1", data: "banner" });
|
|
child.emit("exit", 1);
|
|
|
|
await assert.rejects(start, /Terminal worker exited/u);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
|
|
});
|
|
|
|
test("worker exit closes a session identified only by an output tap before its start response", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { closeAll() {} },
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", { kind: "output-tap", sessionId: "session-1", data: "banner" });
|
|
child.emit("exit", 1);
|
|
|
|
await assert.rejects(start, /Terminal worker exited/u);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
|
|
});
|
|
|
|
test("worker stop clears buffered output byte accounting before session id reuse", async () => {
|
|
const children = [];
|
|
const routed = [];
|
|
let opened = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
opened = true;
|
|
},
|
|
send(sessionId, data) {
|
|
if (!opened) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
closeAll() {
|
|
opened = false;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id };
|
|
},
|
|
},
|
|
},
|
|
maxPendingOutputBytes: "READY".length,
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const firstChild = manager.ensureStarted();
|
|
firstChild.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "oldold",
|
|
});
|
|
manager.stop();
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const secondChild = children[1];
|
|
secondChild.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "READY",
|
|
});
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: secondChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await promise;
|
|
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "READY" }]);
|
|
});
|
|
|
|
test("a replacement worker can deliver generation-zero early output after a crash", async () => {
|
|
const children = [];
|
|
const routed = [];
|
|
let opened = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {
|
|
opened = true;
|
|
},
|
|
send(sessionId, data) {
|
|
if (!opened) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
closeAll() {
|
|
opened = false;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstChild = children[0];
|
|
const firstRequest = firstChild.messages.at(-1);
|
|
firstChild.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await first;
|
|
firstChild.emit("exit", 1);
|
|
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondChild = children[1];
|
|
const secondRequest = secondChild.messages.at(-1);
|
|
secondChild.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "NEW-WORKER-EARLY",
|
|
sessionGeneration: 0,
|
|
originRequestId: secondRequest.requestId,
|
|
});
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await second;
|
|
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "NEW-WORKER-EARLY" }]);
|
|
});
|
|
|
|
test("a stale worker exit cannot close sessions owned by its replacement", async () => {
|
|
const children = [];
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const firstChild = manager.ensureStarted();
|
|
manager.stop();
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const secondChild = children[1];
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: secondChild.messages[0].requestId,
|
|
result: { sessionId: "session-2" },
|
|
});
|
|
await start;
|
|
|
|
firstChild.emit("exit", 1);
|
|
assert.deepEqual(closed, []);
|
|
assert.equal(manager.hasOpenSession("session-2"), true);
|
|
|
|
secondChild.emit("exit", 1);
|
|
assert.deepEqual(closed, [{ sessionId: "session-2", reason: "worker-exit" }]);
|
|
assert.equal(manager.hasOpenSession("session-2"), false);
|
|
});
|
|
|
|
test("closing a crashed session is idempotent until a new worker reuses its id", async () => {
|
|
const children = [];
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const firstStart = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const firstChild = children[0];
|
|
firstChild.emit("message", {
|
|
kind: "response",
|
|
requestId: firstChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await firstStart;
|
|
firstChild.emit("exit", 1);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
|
|
|
|
const close = manager.request("netcatty:close:await", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
if (children[1]) {
|
|
const closeRequest = children[1].messages.at(-1);
|
|
children[1].emit("message", {
|
|
kind: "response",
|
|
requestId: closeRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
}
|
|
await close;
|
|
assert.equal(children.length, 1);
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
{ sessionId: "session-1", reason: "closed", explicit: true },
|
|
]);
|
|
|
|
const secondStart = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const secondChild = children[1];
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: secondChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await secondStart;
|
|
secondChild.emit("exit", 1);
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
{ sessionId: "session-1", reason: "closed", explicit: true },
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
]);
|
|
});
|
|
|
|
test("an old start response cannot reopen a session claimed by a later same-id start", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
let outputOpen = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() { outputOpen = true; },
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const first = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
const firstRequest = child.messages.at(-1);
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
const second = manager.request("netcatty:local:reconnect", { sessionId: "session-1" }, {
|
|
webContentsId: 7,
|
|
});
|
|
const secondRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "new-banner",
|
|
sessionGeneration: 1,
|
|
originRequestId: secondRequest.requestId,
|
|
});
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await assert.rejects(first, /superseded by a newer start request/u);
|
|
assert.equal(manager.hasOpenSession("session-1"), false);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await second;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "new-banner" }]);
|
|
});
|
|
|
|
|
|
test("start-close-start StrictMode order keeps the remount start alive", async () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
// Mount1 start
|
|
const first = manager.request(
|
|
"netcatty:local:start",
|
|
{ sessionId: "session-1", bootEpoch: 1 },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
|
|
// Mount1 cleanup close (before remount start)
|
|
manager.send("netcatty:close", { sessionId: "session-1", bootEpoch: 1 }, { webContentsId: 7 });
|
|
|
|
// Mount2 remount start
|
|
const second = manager.request(
|
|
"netcatty:local:start",
|
|
{ sessionId: "session-1", bootEpoch: 2 },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await assert.rejects(first, /superseded by a newer start request|closed before its output route opened/u);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await second;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
});
|
|
|
|
test("stale bootEpoch close must not reject a newer same-id start (StrictMode remount)", async () => {
|
|
const child = new FakeChild();
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:start",
|
|
{ sessionId: "session-1", bootEpoch: 1 },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
const second = manager.request(
|
|
"netcatty:local:start",
|
|
{ sessionId: "session-1", bootEpoch: 2 },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
|
|
// Orphan close from the aborted StrictMode boot after the remount start is pending.
|
|
// This is the exact IPC order that produces:
|
|
// superseded → "closed before its output route opened"
|
|
const closeResult = await manager.request(
|
|
"netcatty:close:await",
|
|
{ sessionId: "session-1", bootEpoch: 1 },
|
|
{ webContentsId: 7 },
|
|
);
|
|
assert.deepEqual(closeResult, { skipped: true, reason: "boot-epoch-mismatch" });
|
|
manager.send("netcatty:close", { sessionId: "session-1", bootEpoch: 1 }, { webContentsId: 7 });
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await assert.rejects(first, /superseded by a newer start request|closed before its output route opened/u);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await second;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
});
|
|
|
|
|
|
test("closed-session tombstones are bounded without admitting recent stale output", async () => {
|
|
const child = new FakeChild();
|
|
const tapped = [];
|
|
let now = 0;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession: () => true, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, isDestroyed: () => false, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
now: () => now,
|
|
closedSessionTombstoneTtlMs: 100,
|
|
maxClosedSessionTombstones: 8,
|
|
});
|
|
manager.addOutputTap((sessionId, data) => tapped.push({ sessionId, data }));
|
|
|
|
for (let index = 0; index < 24; index += 1) {
|
|
const sessionId = `closed-${index}`;
|
|
const started = manager.request("netcatty:local:reconnect", { sessionId }, { webContentsId: 7 });
|
|
const startRequest = [...child.messages].reverse().find((message) => (
|
|
message.kind === "request" && message.payload?.sessionId === sessionId
|
|
));
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: startRequest.requestId,
|
|
result: { sessionId },
|
|
sessionGeneration: 0,
|
|
});
|
|
await started;
|
|
manager.send("netcatty:close", { sessionId }, { webContentsId: 7 });
|
|
}
|
|
|
|
assert.deepEqual(manager._getClosedSessionTombstoneCountsForTests(), {
|
|
sessions: 8,
|
|
sequences: 8,
|
|
generations: 8,
|
|
});
|
|
|
|
const sessionId = "closed-23";
|
|
now = 99;
|
|
child.emit("message", {
|
|
kind: "output-tap",
|
|
sessionId,
|
|
data: "stale",
|
|
sessionGeneration: 0,
|
|
});
|
|
assert.deepEqual(tapped, [], "a recent close still rejects its old generation");
|
|
|
|
const restarted = manager.request("netcatty:local:reconnect", { sessionId }, { webContentsId: 7 });
|
|
const restartRequest = [...child.messages].reverse().find((message) => (
|
|
message.kind === "request" && message.payload?.sessionId === sessionId
|
|
));
|
|
child.emit("message", {
|
|
kind: "output-tap",
|
|
sessionId,
|
|
data: "fresh",
|
|
sessionGeneration: 1,
|
|
originRequestId: restartRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: restartRequest.requestId,
|
|
result: { sessionId },
|
|
sessionGeneration: 1,
|
|
});
|
|
await restarted;
|
|
|
|
assert.deepEqual(tapped, [{ sessionId, data: "fresh" }]);
|
|
assert.equal(manager.hasOpenSession(sessionId), true, "same-id restart is not killed by its tombstone");
|
|
assert.deepEqual(manager._getClosedSessionTombstoneCountsForTests(), {
|
|
sessions: 7,
|
|
sequences: 7,
|
|
generations: 7,
|
|
});
|
|
});
|
|
|
|
test("a later same-id start response cannot be overwritten by an older response", async () => {
|
|
const child = new FakeChild();
|
|
const owners = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned((event) => owners.push(event));
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 2,
|
|
});
|
|
await second;
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
await assert.rejects(first, /superseded by a newer start request/u);
|
|
assert.deepEqual(owners, [{ sessionId: "session-1", webContentsId: 8 }]);
|
|
});
|
|
|
|
test("a later same-id start does not inherit ambiguous output from an older pending start", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
let outputOpen = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() { outputOpen = true; },
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
void first.catch(() => {});
|
|
const firstRequest = child.messages.at(-1);
|
|
child.emit("message", { kind: "output", sessionId: "session-1", data: "OLD" });
|
|
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
child.emit("message", { kind: "output", sessionId: "session-1", data: "AMBIGUOUS" });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await second;
|
|
child.emit("message", { kind: "output", sessionId: "session-1", data: "NEW" });
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await assert.rejects(first, /superseded by a newer start request/u);
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "NEW" }]);
|
|
});
|
|
|
|
test("a serialized same-id restart keeps its generation-bound early output", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
let outputOpen = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() { outputOpen = true; },
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await assert.rejects(first, /superseded by a newer start request/u);
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "NEW-EARLY",
|
|
sessionGeneration: 1,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
await second;
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "NEW-EARLY" }]);
|
|
});
|
|
|
|
test("an internal same-id replacement exit retires only the old generation", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
const closed = [];
|
|
let outputOpen = false;
|
|
let outputWebContentsId = null;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession(_sessionId, contents) {
|
|
outputOpen = true;
|
|
outputWebContentsId = contents.id;
|
|
},
|
|
closeSession() {
|
|
outputOpen = false;
|
|
outputWebContentsId = null;
|
|
},
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data, webContentsId: outputWebContentsId });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await first;
|
|
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "session-superseding",
|
|
sessionId: "session-1",
|
|
sessionGeneration: 0,
|
|
replacementRequestId: secondRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", exitCode: 0, reason: "closed" },
|
|
sessionGeneration: 0,
|
|
});
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "NEW-EARLY",
|
|
sessionGeneration: 1,
|
|
originRequestId: secondRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
await second;
|
|
assert.deepEqual(routed, [{
|
|
sessionId: "session-1",
|
|
data: "NEW-EARLY",
|
|
webContentsId: 8,
|
|
}]);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
|
|
assert.equal(outputOpen, true);
|
|
assert.equal(
|
|
child.messages.some((message) => (
|
|
message.kind === "close-output-port" && message.sessionId === "session-1"
|
|
)),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a failed replacement is not reported closed again when the worker exits", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession() {}, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await first;
|
|
|
|
const replacement = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const replacementRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "session-superseding",
|
|
sessionId: "session-1",
|
|
sessionGeneration: 0,
|
|
replacementRequestId: replacementRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "closed" },
|
|
sessionGeneration: 0,
|
|
});
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "FAILED-EARLY",
|
|
sessionGeneration: 1,
|
|
originRequestId: replacementRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: replacementRequest.requestId,
|
|
error: "start failed",
|
|
});
|
|
await assert.rejects(replacement, /start failed/u);
|
|
child.emit("exit", 1);
|
|
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
|
|
});
|
|
|
|
test("manager and runtime preserve a completed same-id replacement end to end", async () => {
|
|
const child = new LinkedRuntimeChild();
|
|
const order = [];
|
|
const routed = [];
|
|
const closed = [];
|
|
let active = null;
|
|
let outputWebContentsId = null;
|
|
child.connectRuntime((ipcMain) => {
|
|
ipcMain.handle("netcatty:local:reconnect", (event, payload) => {
|
|
const generation = event.sender.claimSessionGeneration(payload.sessionId);
|
|
order.push(`start:${payload.attempt}:g${generation}`);
|
|
active = { attempt: payload.attempt, generation, sender: event.sender };
|
|
event.sender.send("netcatty:data", {
|
|
sessionId: payload.sessionId,
|
|
data: payload.attempt === "second" ? "NEW-EARLY" : "OLD",
|
|
_terminalSessionGeneration: generation,
|
|
});
|
|
return { sessionId: payload.sessionId };
|
|
});
|
|
ipcMain.handle("netcatty:close:await", (_event, payload) => {
|
|
order.push(`close:${active.attempt}:g${active.generation}`);
|
|
active.sender.send("netcatty:exit", {
|
|
sessionId: payload.sessionId,
|
|
exitCode: 0,
|
|
reason: "closed",
|
|
_terminalSessionGeneration: active.generation,
|
|
});
|
|
active = null;
|
|
});
|
|
});
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession(_sessionId, contents) {
|
|
outputWebContentsId = contents.id;
|
|
return true;
|
|
},
|
|
closeSession() {
|
|
outputWebContentsId = null;
|
|
},
|
|
send(sessionId, data) {
|
|
if (outputWebContentsId == null) return false;
|
|
routed.push({ sessionId, data, webContentsId: outputWebContentsId });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
await manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1", attempt: "first" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
await manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1", attempt: "second" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
|
|
assert.deepEqual(order, [
|
|
"start:first:g0",
|
|
"close:first:g0",
|
|
"start:second:g1",
|
|
]);
|
|
assert.deepEqual(routed, [
|
|
{ sessionId: "session-1", data: "OLD", webContentsId: 7 },
|
|
{ sessionId: "session-1", data: "NEW-EARLY", webContentsId: 8 },
|
|
]);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
|
|
assert.equal(outputWebContentsId, 8);
|
|
assert.equal(
|
|
child.messages.some((message) => (
|
|
message.kind === "close-output-port" && message.sessionId === "session-1"
|
|
)),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a failed replacement close cannot let the old exit cancel the next start", async () => {
|
|
const child = new LinkedRuntimeChild();
|
|
const order = [];
|
|
const routed = [];
|
|
let active = null;
|
|
let closeAttempts = 0;
|
|
let outputWebContentsId = null;
|
|
child.connectRuntime((ipcMain) => {
|
|
ipcMain.handle("netcatty:local:reconnect", (event, payload) => {
|
|
const generation = event.sender.claimSessionGeneration(payload.sessionId);
|
|
order.push(`start:${payload.attempt}:g${generation}`);
|
|
active = { attempt: payload.attempt, generation, sender: event.sender };
|
|
event.sender.send("netcatty:data", {
|
|
sessionId: payload.sessionId,
|
|
data: payload.attempt,
|
|
_terminalSessionGeneration: generation,
|
|
});
|
|
return { sessionId: payload.sessionId };
|
|
});
|
|
ipcMain.handle("netcatty:close:await", (_event, payload) => {
|
|
closeAttempts += 1;
|
|
order.push(`close:${active.attempt}:g${active.generation}`);
|
|
if (closeAttempts === 1) throw new Error("close failed");
|
|
active.sender.send("netcatty:exit", {
|
|
sessionId: payload.sessionId,
|
|
exitCode: 0,
|
|
reason: "closed",
|
|
_terminalSessionGeneration: active.generation,
|
|
});
|
|
active = null;
|
|
});
|
|
});
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession(_sessionId, contents) {
|
|
outputWebContentsId = contents.id;
|
|
return true;
|
|
},
|
|
closeSession() {
|
|
outputWebContentsId = null;
|
|
},
|
|
send(sessionId, data) {
|
|
if (outputWebContentsId == null) return false;
|
|
routed.push({ sessionId, data, webContentsId: outputWebContentsId });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
await manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1", attempt: "R1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
await assert.rejects(
|
|
manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1", attempt: "R2" },
|
|
{ webContentsId: 8 },
|
|
),
|
|
/close failed/u,
|
|
);
|
|
await manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1", attempt: "R3" },
|
|
{ webContentsId: 9 },
|
|
);
|
|
|
|
assert.deepEqual(order, [
|
|
"start:R1:g0",
|
|
"close:R1:g0",
|
|
"close:R1:g0",
|
|
"start:R3:g2",
|
|
]);
|
|
assert.deepEqual(routed, [
|
|
{ sessionId: "session-1", data: "R1", webContentsId: 7 },
|
|
{ sessionId: "session-1", data: "R3", webContentsId: 9 },
|
|
]);
|
|
assert.equal(outputWebContentsId, 9);
|
|
assert.equal(
|
|
child.messages.some((message) => (
|
|
message.kind === "close-output-port" && message.sessionId === "session-1"
|
|
)),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("a failed replacement start cannot leak its early output into the next start", async () => {
|
|
const child = new LinkedRuntimeChild();
|
|
const routed = [];
|
|
let active = null;
|
|
let outputWebContentsId = null;
|
|
child.connectRuntime((ipcMain) => {
|
|
ipcMain.handle("netcatty:local:reconnect", (event, payload) => {
|
|
const generation = event.sender.claimSessionGeneration(payload.sessionId);
|
|
event.sender.send("netcatty:data", {
|
|
sessionId: payload.sessionId,
|
|
data: `${payload.attempt}-EARLY`,
|
|
_terminalSessionGeneration: generation,
|
|
});
|
|
if (payload.attempt === "R2") throw new Error("start failed");
|
|
active = { generation, sender: event.sender };
|
|
return { sessionId: payload.sessionId };
|
|
});
|
|
ipcMain.handle("netcatty:close:await", (_event, payload) => {
|
|
if (!active) return;
|
|
active.sender.send("netcatty:exit", {
|
|
sessionId: payload.sessionId,
|
|
exitCode: 0,
|
|
reason: "closed",
|
|
_terminalSessionGeneration: active.generation,
|
|
});
|
|
active = null;
|
|
});
|
|
});
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession(_sessionId, contents) {
|
|
outputWebContentsId = contents.id;
|
|
return true;
|
|
},
|
|
closeSession() {
|
|
outputWebContentsId = null;
|
|
},
|
|
send(sessionId, data) {
|
|
if (outputWebContentsId == null) return false;
|
|
routed.push({ sessionId, data, webContentsId: outputWebContentsId });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
await manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1", attempt: "R1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
await assert.rejects(
|
|
manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1", attempt: "R2" },
|
|
{ webContentsId: 8 },
|
|
),
|
|
/start failed/u,
|
|
);
|
|
await manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1", attempt: "R3" },
|
|
{ webContentsId: 9 },
|
|
);
|
|
|
|
assert.deepEqual(routed, [
|
|
{ sessionId: "session-1", data: "R1-EARLY", webContentsId: 7 },
|
|
{ sessionId: "session-1", data: "R3-EARLY", webContentsId: 9 },
|
|
]);
|
|
assert.equal(outputWebContentsId, 9);
|
|
});
|
|
|
|
test("overlapping same-id reconnects keep only the latest request's early output", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
let outputOpen = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() { outputOpen = true; },
|
|
closeSession() { outputOpen = false; },
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const initial = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await initial;
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "OLD-FIRST",
|
|
sessionGeneration: 1,
|
|
originRequestId: firstRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await assert.rejects(first, /superseded by a newer start request/u);
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "NEW-SECOND",
|
|
sessionGeneration: 2,
|
|
originRequestId: secondRequest.requestId,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 2,
|
|
});
|
|
|
|
await second;
|
|
assert.deepEqual(routed, [{ sessionId: "session-1", data: "NEW-SECOND" }]);
|
|
});
|
|
|
|
test("a superseded ownership waiter cannot flush the latest reconnect output to its old renderer", async () => {
|
|
const child = new FakeChild();
|
|
const routed = [];
|
|
let currentOwner = null;
|
|
let releaseFirstOwnership;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession(_sessionId, contents) { currentOwner = contents.id; },
|
|
closeSession() { currentOwner = null; },
|
|
send(sessionId, data) {
|
|
if (currentOwner == null) return false;
|
|
routed.push({ sessionId, data, webContentsId: currentOwner });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionOwned(({ webContentsId }) => {
|
|
if (webContentsId !== 7) return undefined;
|
|
return new Promise((resolve) => { releaseFirstOwnership = resolve; });
|
|
});
|
|
|
|
const initial = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 6 },
|
|
);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await initial;
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 6 });
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
while (!releaseFirstOwnership) await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
child.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "LATEST-EARLY",
|
|
sessionGeneration: 2,
|
|
originRequestId: secondRequest.requestId,
|
|
});
|
|
|
|
releaseFirstOwnership();
|
|
await assert.rejects(first, /superseded by a newer start request/u);
|
|
assert.deepEqual(routed, []);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 2,
|
|
});
|
|
await second;
|
|
assert.deepEqual(routed, [{
|
|
sessionId: "session-1",
|
|
data: "LATEST-EARLY",
|
|
webContentsId: 8,
|
|
}]);
|
|
});
|
|
|
|
test("a superseded start failure cannot cancel the queued same-id restart", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const first = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstRequest = child.messages.at(-1);
|
|
const second = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 8 },
|
|
);
|
|
const secondRequest = child.messages.at(-1);
|
|
|
|
child.emit("message", {
|
|
kind: "renderer-event",
|
|
originRequestId: firstRequest.requestId,
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "failed" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await assert.rejects(first, /superseded by a newer start request/u);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: firstRequest.requestId,
|
|
error: "first failed",
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: secondRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
await second;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
|
|
});
|
|
|
|
test("a failed reconnect after worker exit keeps later close cleanup idempotent", async () => {
|
|
const children = [];
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const firstChild = children[0];
|
|
firstChild.emit("message", {
|
|
kind: "response",
|
|
requestId: firstChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await start;
|
|
firstChild.emit("exit", 1);
|
|
|
|
const reconnect = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const secondChild = children[1];
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: secondChild.messages[0].requestId,
|
|
error: "reconnect failed",
|
|
});
|
|
await assert.rejects(reconnect, /reconnect failed/u);
|
|
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
assert.equal(children.length, 2);
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
{ sessionId: "session-1", reason: "closed", explicit: true },
|
|
]);
|
|
});
|
|
|
|
test("a same-id reconnect that outputs before another worker exit closes its new lifecycle", async () => {
|
|
const children = [];
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const firstChild = children[0];
|
|
firstChild.emit("message", {
|
|
kind: "response",
|
|
requestId: firstChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await start;
|
|
firstChild.emit("exit", 1);
|
|
|
|
const reconnect = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const secondChild = children[1];
|
|
secondChild.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "reconnected banner",
|
|
});
|
|
secondChild.emit("exit", 1);
|
|
|
|
await assert.rejects(reconnect, /Terminal worker exited/u);
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
]);
|
|
});
|
|
|
|
test("closing a same-id reconnect before its response closes the new lifecycle once", async () => {
|
|
const children = [];
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const firstChild = children[0];
|
|
firstChild.emit("message", {
|
|
kind: "response",
|
|
requestId: firstChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await start;
|
|
firstChild.emit("exit", 1);
|
|
|
|
const reconnect = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
void reconnect.catch(() => {});
|
|
const secondChild = children[1];
|
|
secondChild.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "reconnected banner",
|
|
});
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
|
|
const closeMessage = secondChild.messages.at(-1);
|
|
assert.equal(closeMessage.channel, "netcatty:close");
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
{ sessionId: "session-1", reason: "closed", explicit: true },
|
|
]);
|
|
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: secondChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await assert.rejects(reconnect, /closed before its output route opened/u);
|
|
secondChild.emit("message", { kind: "session-exit", sessionId: "session-1", exitCode: 0 });
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
{ sessionId: "session-1", reason: "closed", explicit: true },
|
|
]);
|
|
});
|
|
|
|
test("a pending reconnect exit drops late output before another same-id restart", async () => {
|
|
const children = [];
|
|
const closed = [];
|
|
const routed = [];
|
|
let outputOpen = false;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() { outputOpen = true; },
|
|
closeSession() { outputOpen = false; },
|
|
closeAll() { outputOpen = false; },
|
|
send(sessionId, data) {
|
|
if (!outputOpen) return false;
|
|
routed.push({ sessionId, data });
|
|
return true;
|
|
},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const firstChild = children[0];
|
|
firstChild.emit("message", {
|
|
kind: "response",
|
|
requestId: firstChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await start;
|
|
firstChild.emit("exit", 1);
|
|
|
|
const reconnect = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const secondChild = children[1];
|
|
const reconnectRequest = secondChild.messages[0];
|
|
secondChild.emit("message", {
|
|
kind: "renderer-event",
|
|
webContentsId: 7,
|
|
channel: "netcatty:exit",
|
|
payload: { sessionId: "session-1", reason: "exited" },
|
|
sessionGeneration: 0,
|
|
});
|
|
secondChild.emit("message", {
|
|
kind: "output",
|
|
sessionId: "session-1",
|
|
data: "STALE",
|
|
});
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: reconnectRequest.requestId,
|
|
error: "reconnect failed",
|
|
});
|
|
await assert.rejects(reconnect, /reconnect failed/u);
|
|
|
|
const restart = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const restartRequest = secondChild.messages.at(-1);
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: restartRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await restart;
|
|
|
|
assert.deepEqual(routed, []);
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "session-1", reason: "worker-exit" },
|
|
{ sessionId: "session-1", reason: "exited" },
|
|
]);
|
|
});
|
|
|
|
test("a worker-exit close listener can start a replacement without later cleanup removing it", async () => {
|
|
const children = [];
|
|
let replacementStart;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => {
|
|
if (event.reason !== "worker-exit") return;
|
|
replacementStart = manager.request("netcatty:local:start", {}, { webContentsId: 8 });
|
|
void replacementStart.catch(() => {});
|
|
});
|
|
|
|
const firstStart = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
const firstChild = children[0];
|
|
firstChild.emit("message", {
|
|
kind: "response",
|
|
requestId: firstChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
});
|
|
await firstStart;
|
|
|
|
firstChild.emit("exit", 1);
|
|
assert.equal(children.length, 2);
|
|
|
|
const secondChild = children[1];
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: secondChild.messages[0].requestId,
|
|
result: { sessionId: "session-2" },
|
|
});
|
|
await replacementStart;
|
|
assert.equal(manager.hasOpenSession("session-2"), true);
|
|
});
|
|
|
|
test("an explicit-close listener can restart the same id without later cleanup removing it", async () => {
|
|
const child = new FakeChild();
|
|
let replacementStart;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession() {}, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => {
|
|
if (event.reason !== "closed") return;
|
|
replacementStart = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: event.sessionId },
|
|
{ webContentsId: 8 },
|
|
);
|
|
void replacementStart.catch(() => {});
|
|
});
|
|
|
|
const firstStart = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await firstStart;
|
|
|
|
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
|
|
const replacementRequest = child.messages.find((message) => (
|
|
message.kind === "request"
|
|
&& message.channel === "netcatty:local:reconnect"
|
|
&& message.requestId !== child.messages[0].requestId
|
|
));
|
|
const workerCloseIndex = child.messages.findIndex((message) => (
|
|
message.kind === "send" && message.channel === "netcatty:close"
|
|
));
|
|
const replacementIndex = child.messages.indexOf(replacementRequest);
|
|
assert.equal(workerCloseIndex < replacementIndex, true);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: replacementRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
|
|
await replacementStart;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
});
|
|
|
|
test("an awaited close reaches the worker before its listener can restart the same id", async () => {
|
|
const child = new FakeChild();
|
|
let replacementStart;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession() {}, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => {
|
|
if (event.reason !== "closed") return;
|
|
replacementStart = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: event.sessionId },
|
|
{ webContentsId: 8 },
|
|
);
|
|
void replacementStart.catch(() => {});
|
|
});
|
|
|
|
const firstStart = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await firstStart;
|
|
|
|
const close = manager.request(
|
|
"netcatty:close:await",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const closeRequest = child.messages.find((message) => (
|
|
message.kind === "request" && message.channel === "netcatty:close:await"
|
|
));
|
|
const replacementRequest = child.messages.find((message) => (
|
|
message.kind === "request"
|
|
&& message.channel === "netcatty:local:reconnect"
|
|
&& message.requestId !== child.messages[0].requestId
|
|
));
|
|
assert.equal(child.messages.indexOf(closeRequest) < child.messages.indexOf(replacementRequest), true);
|
|
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: closeRequest.requestId,
|
|
result: undefined,
|
|
});
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: replacementRequest.requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 1,
|
|
});
|
|
await close;
|
|
await replacementStart;
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
});
|
|
|
|
test("an awaited close still notifies lifecycle cleanup when worker IPC is already dead", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: { openSession() {}, closeSession() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => closed.push(event));
|
|
|
|
const start = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await start;
|
|
|
|
child.postMessage = () => { throw new Error("IPC closed"); };
|
|
const close = manager.request(
|
|
"netcatty:close:await",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
|
|
await assert.rejects(close, /IPC closed/u);
|
|
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
|
|
assert.equal(manager.hasOpenSession("session-1"), false);
|
|
});
|
|
|
|
test("a dead worker is replaced when close cleanup immediately restarts the same id", async () => {
|
|
const children = [];
|
|
let replacementStart;
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
const child = new FakeChild();
|
|
children.push(child);
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: { openSession() {}, closeSession() {}, closeAll() {} },
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => {
|
|
if (event.reason !== "closed") return;
|
|
replacementStart = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: event.sessionId },
|
|
{ webContentsId: 8 },
|
|
);
|
|
void replacementStart.catch(() => {});
|
|
});
|
|
|
|
const start = manager.request(
|
|
"netcatty:local:reconnect",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
const firstChild = children[0];
|
|
firstChild.emit("message", {
|
|
kind: "response",
|
|
requestId: firstChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await start;
|
|
|
|
firstChild.postMessage = () => { throw new Error("IPC closed"); };
|
|
const close = manager.request(
|
|
"netcatty:close:await",
|
|
{ sessionId: "session-1" },
|
|
{ webContentsId: 7 },
|
|
);
|
|
await assert.rejects(close, /IPC closed/u);
|
|
assert.equal(firstChild.killed, true);
|
|
assert.equal(children.length, 2);
|
|
|
|
const secondChild = children[1];
|
|
secondChild.emit("message", {
|
|
kind: "response",
|
|
requestId: secondChild.messages[0].requestId,
|
|
result: { sessionId: "session-1" },
|
|
sessionGeneration: 0,
|
|
});
|
|
await replacementStart;
|
|
firstChild.emit("exit", 1);
|
|
|
|
assert.equal(manager.hasOpenSession("session-1"), true);
|
|
assert.equal(children.length, 2);
|
|
});
|
|
|
|
test("worker exit notifies renderers for active worker sessions", async () => {
|
|
const child = new FakeChild();
|
|
const sent = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: {
|
|
fork() {
|
|
return child;
|
|
},
|
|
},
|
|
terminalOutputChannel: {
|
|
openSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return {
|
|
id,
|
|
send(channel, payload) {
|
|
sent.push({ id, channel, payload });
|
|
},
|
|
};
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
|
|
const promise = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await promise;
|
|
|
|
child.emit("exit", 1);
|
|
|
|
assert.deepEqual(sent, [
|
|
{
|
|
id: 7,
|
|
channel: "netcatty:exit",
|
|
payload: {
|
|
sessionId: "local-1",
|
|
exitCode: 1,
|
|
error: "Terminal worker exited with code 1",
|
|
reason: "error",
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("worker exit notifies host lifecycle listeners for every active session", async () => {
|
|
const child = new FakeChild();
|
|
const closed = [];
|
|
const manager = createTerminalWorkerManager({
|
|
utilityProcess: { fork: () => child },
|
|
terminalOutputChannel: {
|
|
openSession() {},
|
|
closeAll() {},
|
|
},
|
|
electronModule: {
|
|
webContents: {
|
|
fromId(id) {
|
|
return { id, send() {} };
|
|
},
|
|
},
|
|
},
|
|
workerScriptPath: "/worker.cjs",
|
|
});
|
|
manager.onSessionClosed((event) => {
|
|
manager.detachTerminalInterceptor(event.sessionId, "input");
|
|
closed.push(event);
|
|
});
|
|
|
|
const first = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages[0].requestId,
|
|
result: { sessionId: "local-1" },
|
|
});
|
|
await first;
|
|
|
|
const second = manager.request("netcatty:local:start", {}, { webContentsId: 8 });
|
|
child.emit("message", {
|
|
kind: "response",
|
|
requestId: child.messages.at(-1).requestId,
|
|
result: { sessionId: "local-2" },
|
|
});
|
|
await second;
|
|
|
|
child.postMessage = () => {
|
|
throw new Error("worker IPC channel is closed");
|
|
};
|
|
child.emit("exit", 1);
|
|
|
|
assert.deepEqual(closed, [
|
|
{ sessionId: "local-1", reason: "worker-exit" },
|
|
{ sessionId: "local-2", reason: "worker-exit" },
|
|
]);
|
|
});
|