mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 17:10:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b67fca8b5 | ||
|
|
fe7ee21dff | ||
|
|
421d0a5550 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
|
||||
// In-process terminal backends share one registry. Remember the first renderer
|
||||
// for each session object, not its mutable output route: an attached popup is
|
||||
// only a display, and a replacement boot is a different object.
|
||||
const owners = new WeakMap();
|
||||
const trackedRegistries = new WeakMap();
|
||||
|
||||
function trackRendererSessionLifetime(sessions, sessionId, contents, closeSession, transferOwnership = false) {
|
||||
const session = sessions?.get(sessionId);
|
||||
// Worker/agent senders are synthetic and have no renderer destruction event.
|
||||
if (!session || typeof contents?.once !== "function") return;
|
||||
if (owners.has(session) && !transferOwnership) return;
|
||||
owners.set(session, contents);
|
||||
let tracked = trackedRegistries.get(sessions);
|
||||
if (!tracked) trackedRegistries.set(sessions, tracked = new WeakSet());
|
||||
const closeOwnedSessions = () => {
|
||||
for (const [id, current] of [...sessions]) {
|
||||
if (owners.get(current) !== contents || sessions.get(id) !== current) continue;
|
||||
closeSession({ sender: contents }, { sessionId: id, bootEpoch: current.bootEpoch });
|
||||
}
|
||||
};
|
||||
if (!tracked.has(contents)) {
|
||||
tracked.add(contents);
|
||||
contents.once("destroyed", closeOwnedSessions);
|
||||
}
|
||||
// An asynchronous start may finish after its window disappeared. Let its
|
||||
// synchronous setup finish (including acquiring SSH leases), then close only
|
||||
// the still-owned object through the same path as an explicit tab close.
|
||||
if (contents.isDestroyed?.()) queueMicrotask(closeOwnedSessions);
|
||||
}
|
||||
|
||||
module.exports = { trackRendererSessionLifetime };
|
||||
@@ -0,0 +1,78 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const EventEmitter = require("node:events");
|
||||
const test = require("node:test");
|
||||
const { trackRendererSessionLifetime: track } = require("./rendererSessionLifetime.cjs");
|
||||
|
||||
function renderer(id) {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = id;
|
||||
contents.destroyed = false;
|
||||
contents.isDestroyed = () => contents.destroyed;
|
||||
contents.destroy = () => { contents.destroyed = true; contents.emit("destroyed"); };
|
||||
return contents;
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const sessions = new Map();
|
||||
const closed = [];
|
||||
const close = (_event, payload) => { closed.push(payload); sessions.delete(payload.sessionId); };
|
||||
const own = (id, contents, bootEpoch = 1) => {
|
||||
sessions.set(id, { bootEpoch });
|
||||
track(sessions, id, contents, close);
|
||||
};
|
||||
return { sessions, closed, close, own };
|
||||
}
|
||||
|
||||
test("fallback closes only session objects owned by the destroyed renderer", () => {
|
||||
const f = fixture();
|
||||
const first = renderer(7), second = renderer(8);
|
||||
f.own("a", first);
|
||||
f.own("b", first);
|
||||
f.own("c", second);
|
||||
assert.equal(first.listenerCount("destroyed"), 1);
|
||||
first.destroy();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "a", bootEpoch: 1 }, { sessionId: "b", bootEpoch: 1 }]);
|
||||
assert.equal(f.sessions.has("c"), true);
|
||||
});
|
||||
|
||||
test("fallback display rebind is not an ownership handoff; explicit home restore is", () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), popup = renderer(9), nextHome = renderer(8);
|
||||
f.own("a", home);
|
||||
track(f.sessions, "a", popup, f.close);
|
||||
popup.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
track(f.sessions, "a", nextHome, f.close, true);
|
||||
home.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
nextHome.destroy();
|
||||
assert.equal(f.closed.length, 1);
|
||||
});
|
||||
|
||||
test("fallback old owner cannot close a same-ID replacement or synthetic agent session", () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), nextHome = renderer(8);
|
||||
f.own("a", home);
|
||||
f.own("a", nextHome, 2);
|
||||
f.own("agent", { id: 7 });
|
||||
home.destroy();
|
||||
assert.equal(f.closed.length, 0);
|
||||
nextHome.destroy();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "a", bootEpoch: 2 }]);
|
||||
assert.equal(f.sessions.has("agent"), true);
|
||||
});
|
||||
|
||||
test("fallback late start into a destroyed renderer closes after setup, not a replacement", async () => {
|
||||
const f = fixture();
|
||||
const home = renderer(7), nextHome = renderer(8);
|
||||
home.destroy();
|
||||
f.own("late", home);
|
||||
assert.equal(f.closed.length, 0);
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(f.closed, [{ sessionId: "late", bootEpoch: 1 }]);
|
||||
f.own("replacement", home);
|
||||
f.own("replacement", nextHome, 2);
|
||||
await Promise.resolve();
|
||||
assert.equal(f.closed.length, 1);
|
||||
assert.equal(f.sessions.has("replacement"), true);
|
||||
});
|
||||
@@ -508,6 +508,10 @@ function init(deps) {
|
||||
}
|
||||
|
||||
function openTerminalOutputSession(sessionId, webContents) {
|
||||
require("./rendererSessionLifetime.cjs").trackRendererSessionLifetime(
|
||||
sessions, sessionId, webContents,
|
||||
(event, payload) => require("./terminalBridge.cjs").closeSession(event, payload),
|
||||
);
|
||||
terminalOutputChannel?.openSession?.(sessionId, webContents);
|
||||
}
|
||||
|
||||
|
||||
@@ -137,6 +137,9 @@ function init(deps) {
|
||||
}
|
||||
|
||||
function openTerminalOutputSession(sessionId, webContents) {
|
||||
require("./rendererSessionLifetime.cjs").trackRendererSessionLifetime(
|
||||
sessions, sessionId, webContents, closeSession,
|
||||
);
|
||||
const generation = webContents?.claimSessionGeneration?.(sessionId);
|
||||
const session = sessions?.get?.(sessionId);
|
||||
if (session && Number.isSafeInteger(generation)) {
|
||||
@@ -540,6 +543,9 @@ async function restoreAttachedSessionOutputOnce(
|
||||
} else {
|
||||
openTerminalOutputSession(sessionId, home);
|
||||
session.webContentsId = home.id;
|
||||
require("./rendererSessionLifetime.cjs").trackRendererSessionLifetime(
|
||||
sessions, sessionId, home, closeSession, true,
|
||||
);
|
||||
attachHomeWebContentsIds.delete(sessionId);
|
||||
result = { success: true, restored: true, webContentsId: home.id };
|
||||
}
|
||||
@@ -621,7 +627,7 @@ async function restoreTerminalSessionOutput(event, payload, terminalWorkerManage
|
||||
if (!result?.success) {
|
||||
return { success: false, error: result?.error || "Failed to restore session output" };
|
||||
}
|
||||
terminalWorkerManager.clearAttachHome?.(sessionId);
|
||||
terminalWorkerManager.clearAttachHome?.(sessionId, target.id);
|
||||
return { success: true, restored: true, webContentsId: target.id };
|
||||
} catch (err) {
|
||||
return { success: false, error: err?.message || String(err) };
|
||||
@@ -636,6 +642,9 @@ async function restoreTerminalSessionOutput(event, payload, terminalWorkerManage
|
||||
try {
|
||||
openTerminalOutputSession(sessionId, target);
|
||||
session.webContentsId = target.id;
|
||||
require("./rendererSessionLifetime.cjs").trackRendererSessionLifetime(
|
||||
sessions, sessionId, target, closeSession, true,
|
||||
);
|
||||
attachHomeWebContentsIds.delete(sessionId);
|
||||
return { success: true, restored: true, webContentsId: target.id };
|
||||
} catch (err) {
|
||||
|
||||
@@ -125,3 +125,101 @@ test("legacy SSH session without connRef still ends its own connection + chain",
|
||||
assert.equal(chain[0].ended, 1);
|
||||
assert.equal(chain[1].ended, 1);
|
||||
});
|
||||
|
||||
test("destroying a copied child window closes only its shells on the shared SSH transport", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const { trackRendererSessionLifetime } = require("./rendererSessionLifetime.cjs");
|
||||
const sessions = new Map();
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
|
||||
const conn = makeConn();
|
||||
const mainA = makeSession(conn, null);
|
||||
const transport = createConnectionRef(mainA, conn, []);
|
||||
const mainB = makeSession(conn, null);
|
||||
const childA = makeSession(conn, null);
|
||||
const childB = makeSession(conn, null);
|
||||
acquireConnectionRef(mainB, transport);
|
||||
acquireConnectionRef(childA, transport);
|
||||
acquireConnectionRef(childB, transport);
|
||||
for (const [id, session] of [
|
||||
["main-a", mainA],
|
||||
["main-b", mainB],
|
||||
["child-a", childA],
|
||||
["child-b", childB],
|
||||
]) {
|
||||
session.bootEpoch = 1;
|
||||
sessions.set(id, session);
|
||||
}
|
||||
assert.equal(transport.count, 4);
|
||||
assert.equal(transport.state, "live");
|
||||
|
||||
const windowFor = (id) => {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = id;
|
||||
contents.isDestroyed = () => false;
|
||||
return contents;
|
||||
};
|
||||
const mainWindow = windowFor(1);
|
||||
const childWindow = windowFor(2);
|
||||
trackRendererSessionLifetime(sessions, "main-a", mainWindow, terminalBridge.closeSession);
|
||||
trackRendererSessionLifetime(sessions, "main-b", mainWindow, terminalBridge.closeSession);
|
||||
trackRendererSessionLifetime(sessions, "child-a", childWindow, terminalBridge.closeSession);
|
||||
trackRendererSessionLifetime(sessions, "child-b", childWindow, terminalBridge.closeSession);
|
||||
|
||||
childWindow.emit("destroyed");
|
||||
|
||||
assert.equal(childA.stream.closed, 1);
|
||||
assert.equal(childB.stream.closed, 1);
|
||||
assert.equal(mainA.stream.closed, 0);
|
||||
assert.equal(mainB.stream.closed, 0);
|
||||
assert.equal(conn.ended, 0, "shared SSH connection stays up for the main window");
|
||||
assert.equal(transport.count, 2, "only the child window's shell leases are released");
|
||||
assert.equal(transport.state, "live");
|
||||
assert.equal(sessions.has("child-a"), false);
|
||||
assert.equal(sessions.has("child-b"), false);
|
||||
assert.equal(sessions.has("main-a"), true);
|
||||
assert.equal(sessions.has("main-b"), true);
|
||||
|
||||
childWindow.emit("destroyed");
|
||||
assert.equal(transport.count, 2, "a repeated child-window destroy must not drop the main window's leases");
|
||||
assert.equal(conn.ended, 0);
|
||||
|
||||
mainWindow.emit("destroyed");
|
||||
assert.equal(mainA.stream.closed, 1);
|
||||
assert.equal(mainB.stream.closed, 1);
|
||||
assert.equal(transport.count, 0);
|
||||
assert.equal(transport.state, "idle", "the last shells park the transport");
|
||||
assert.equal(conn.ended, 0, "parking does not end the shared connection");
|
||||
});
|
||||
|
||||
test("renderer destruction closes its SSH shell through normal close while other shell and SFTP leases survive", () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const { trackRendererSessionLifetime } = require("./rendererSessionLifetime.cjs");
|
||||
const { borrowTransport, returnTransport } = require("./sshConnectionPool.cjs");
|
||||
const sessions = new Map();
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
const conn = makeConn();
|
||||
const owner = makeSession(conn, null);
|
||||
const transport = createConnectionRef(owner, conn, []);
|
||||
const copy = makeSession(conn, null);
|
||||
acquireConnectionRef(copy, transport);
|
||||
const sftpHolder = {};
|
||||
borrowTransport(transport, { kind: "sftp", holder: sftpHolder });
|
||||
sessions.set("owner", owner);
|
||||
sessions.set("copy", copy);
|
||||
const contents = new EventEmitter();
|
||||
contents.id = 7;
|
||||
trackRendererSessionLifetime(sessions, "copy", contents, terminalBridge.closeSession);
|
||||
contents.emit("destroyed");
|
||||
assert.equal(copy.stream.closed, 1);
|
||||
assert.equal(owner.stream.closed, 0);
|
||||
assert.equal(conn.ended, 0);
|
||||
assert.equal(transport.count, 2);
|
||||
assert.equal(sessions.has("copy"), false);
|
||||
assert.equal(sessions.has("owner"), true);
|
||||
terminalBridge.closeSession({}, { sessionId: "owner" });
|
||||
assert.equal(transport.count, 1, "SFTP lease survives the last shell closing");
|
||||
assert.equal(conn.ended, 0);
|
||||
returnTransport(sftpHolder);
|
||||
assert.equal(transport.state, "idle");
|
||||
});
|
||||
@@ -626,3 +626,21 @@ test("worker replacements abort pending and skip superseded queued local starts"
|
||||
await drainMessages();
|
||||
});
|
||||
});
|
||||
|
||||
test("in-process local session is killed on renderer destruction without closing another renderer's PTY", async () => {
|
||||
const EventEmitter = require("node:events");
|
||||
const spawns = [];
|
||||
const bridge = loadBridgeWithFakes(spawns, []);
|
||||
const sessions = new Map();
|
||||
const renderers = [7, 8].map(id => Object.assign(new EventEmitter(), { id, send() {} }));
|
||||
bridge.init({ sessions, electronModule: { webContents: { fromId: id => renderers.find(r => r.id === id) } } });
|
||||
await bridge.startLocalSession({ sender: renderers[0] }, { sessionId: "lifetime-local", shell: "/bin/sh", bootEpoch: 1 });
|
||||
await bridge.startLocalSession({ sender: renderers[1] }, { sessionId: "lifetime-other", shell: "/bin/sh", bootEpoch: 1 });
|
||||
let killed = 0;
|
||||
spawns[0].kill = () => { killed++; };
|
||||
renderers[0].emit("destroyed");
|
||||
assert.equal(killed, 1);
|
||||
assert.equal(sessions.has("lifetime-local"), false);
|
||||
assert.equal(sessions.has("lifetime-other"), true);
|
||||
bridge.closeSession({}, { sessionId: "lifetime-other" });
|
||||
});
|
||||
@@ -409,6 +409,9 @@ function createTerminalWorkerManager(options = {}) {
|
||||
const workerSessionIds = new Set();
|
||||
const sessionWebContentsIds = new Map();
|
||||
const sessionHostIds = new Map();
|
||||
// Lifetime ownership is independent of the output route (observe popups).
|
||||
const rendererSessionOwners = new Map();
|
||||
const trackedLifetimeRenderers = new WeakSet();
|
||||
const sessionGenerations = new Map();
|
||||
const closedSessionGenerations = new Map();
|
||||
const supersedingSessionGenerations = new Map();
|
||||
@@ -658,6 +661,34 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function trackRendererSessionOwner(sessionId, entry) {
|
||||
// External terminals belong to their agent/provider, not their display.
|
||||
rendererSessionOwners.delete(sessionId);
|
||||
if (!entry.rendererOwned) return;
|
||||
const contents = electronModule?.webContents?.fromId?.(entry.webContentsId);
|
||||
if (!contents) {
|
||||
// A backend-assigned ID may arrive only after the renderer was removed.
|
||||
if (electronModule?.webContents?.fromId) send("netcatty:close", { sessionId });
|
||||
return;
|
||||
}
|
||||
rendererSessionOwners.set(sessionId, contents);
|
||||
const closeOwnedSessions = () => {
|
||||
for (const [ownedId, owner] of [...rendererSessionOwners]) {
|
||||
if (owner !== contents || rendererSessionOwners.get(ownedId) !== contents) continue;
|
||||
// Use the normal close path for pending boots, output/mirror cleanup,
|
||||
// and reference-counted SSH/SFTP transport release in the worker.
|
||||
// Lifetime teardown closes both an active boot and a queued replacement.
|
||||
// Scoping to the pending epoch could leave the older active PTY alive.
|
||||
send("netcatty:close", { sessionId: ownedId }, { webContentsId: contents.id });
|
||||
}
|
||||
};
|
||||
if (!trackedLifetimeRenderers.has(contents)) {
|
||||
trackedLifetimeRenderers.add(contents);
|
||||
contents.once?.("destroyed", closeOwnedSessions);
|
||||
}
|
||||
if (contents.isDestroyed?.()) closeOwnedSessions();
|
||||
}
|
||||
|
||||
async function openOutputSession(sessionId, webContentsId, isStillCurrent = null) {
|
||||
if (!sessionId || !webContentsId) return false;
|
||||
if (closedSessions.has(sessionId)) {
|
||||
@@ -858,7 +889,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
}
|
||||
const ok = await openOutputSession(sessionId, homeId);
|
||||
if (ok) {
|
||||
attachHomeWebContentsIds.delete(sessionId);
|
||||
clearAttachHome(sessionId, homeId);
|
||||
}
|
||||
return {
|
||||
success: ok,
|
||||
@@ -894,8 +925,16 @@ function createTerminalWorkerManager(options = {}) {
|
||||
return attachHomeWebContentsIds.get(sessionId) ?? null;
|
||||
}
|
||||
|
||||
function clearAttachHome(sessionId) {
|
||||
function clearAttachHome(sessionId, restoredHomeId) {
|
||||
if (sessionId) attachHomeWebContentsIds.delete(sessionId);
|
||||
// A completed restore may explicitly adopt another main renderer as home.
|
||||
// Do not overwrite a newer start's lifetime owner while it is still booting.
|
||||
if (typeof restoredHomeId === "number"
|
||||
&& rendererSessionOwners.has(sessionId)
|
||||
&& !pendingSessionStartSequences.has(sessionId)
|
||||
&& sessionWebContentsIds.get(sessionId) === restoredHomeId) {
|
||||
trackRendererSessionOwner(sessionId, { rendererOwned: true, webContentsId: restoredHomeId });
|
||||
}
|
||||
}
|
||||
|
||||
function deleteClosedSessionTombstone(sessionId) {
|
||||
@@ -1089,6 +1128,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
outputPortReady.delete(sessionId);
|
||||
sessionWebContentsIds.delete(sessionId);
|
||||
sessionHostIds.delete(sessionId);
|
||||
rendererSessionOwners.delete(sessionId);
|
||||
const activeGeneration = sessionGenerations.get(sessionId);
|
||||
recordClosedSessionGeneration(sessionId, activeGeneration);
|
||||
sessionGenerations.delete(sessionId);
|
||||
@@ -1331,10 +1371,28 @@ function createTerminalWorkerManager(options = {}) {
|
||||
if (!sessionWebContentsIds.has(entry.requestedSessionId)
|
||||
&& !outputRoutePending.has(entry.requestedSessionId)) {
|
||||
workerSessionIds.delete(entry.requestedSessionId);
|
||||
rendererSessionOwners.delete(entry.requestedSessionId);
|
||||
}
|
||||
}
|
||||
finishPendingSessionStart(entry);
|
||||
pending.delete(message.requestId);
|
||||
// A failed replacement may leave the previous backend alive. Restore
|
||||
// its lifetime owner only if no newer start or generation took over.
|
||||
if (entry.requestedSessionId
|
||||
&& latestSessionStartSequences.get(entry.requestedSessionId) === entry.requestSequence
|
||||
&& sessionWebContentsIds.has(entry.requestedSessionId)
|
||||
&& sessionGenerations.get(entry.requestedSessionId) === entry.previousSessionGeneration) {
|
||||
rendererSessionOwners.delete(entry.requestedSessionId);
|
||||
if (entry.previousRendererOwner) {
|
||||
rendererSessionOwners.set(entry.requestedSessionId, entry.previousRendererOwner);
|
||||
if (entry.previousRendererOwner.isDestroyed?.()) {
|
||||
send("netcatty:close", {
|
||||
sessionId: entry.requestedSessionId,
|
||||
bootEpoch: resolveOwnerBootEpoch(entry.requestedSessionId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.reject(new Error(message.error));
|
||||
} else {
|
||||
const sessionId = message.result?.sessionId;
|
||||
@@ -1379,6 +1437,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
return latestSequence === entry.requestSequence
|
||||
&& !(currentClosedSequence != null && currentClosedSequence >= entry.requestSequence);
|
||||
};
|
||||
if (!entry.requestedSessionId) trackRendererSessionOwner(sessionId, entry);
|
||||
void openOutputSession(sessionId, entry.webContentsId, isStillCurrent).then((opened) => {
|
||||
if (pending.get(message.requestId) !== entry) return;
|
||||
finishPendingSessionStart(entry);
|
||||
@@ -1744,6 +1803,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
workerSessionIds.clear();
|
||||
sessionWebContentsIds.clear();
|
||||
sessionHostIds.clear();
|
||||
rendererSessionOwners.clear();
|
||||
sessionGenerations.clear();
|
||||
closedSessionGenerations.clear();
|
||||
supersedingSessionGenerations.clear();
|
||||
@@ -1825,6 +1885,13 @@ function createTerminalWorkerManager(options = {}) {
|
||||
|
||||
function request(channel, payload, optionsForRequest = {}) {
|
||||
pruneClosedSessionTombstones();
|
||||
const rendererOwned = SESSION_START_CHANNELS.has(channel) && channel !== "netcatty:external:start";
|
||||
if (rendererOwned && electronModule?.webContents?.fromId && optionsForRequest.webContentsId) {
|
||||
const contents = electronModule.webContents.fromId(optionsForRequest.webContentsId);
|
||||
if (!contents || contents.isDestroyed?.()) {
|
||||
return Promise.reject(new Error("Terminal window closed before startup"));
|
||||
}
|
||||
}
|
||||
if (channel === "netcatty:close:await"
|
||||
&& payload?.sessionId
|
||||
&& closedSessions.has(payload.sessionId)
|
||||
@@ -1842,6 +1909,10 @@ function createTerminalWorkerManager(options = {}) {
|
||||
const requestedSessionId = payload?.sessionId && SESSION_START_CHANNELS.has(channel)
|
||||
? payload.sessionId
|
||||
: null;
|
||||
const previousStart = requestedSessionId
|
||||
? [...pending.values()].find(entry => entry.requestedSessionId === requestedSessionId
|
||||
&& entry.requestSequence === latestSessionStartSequences.get(requestedSessionId))
|
||||
: null;
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
pending.set(requestId, {
|
||||
resolve,
|
||||
@@ -1851,6 +1922,11 @@ function createTerminalWorkerManager(options = {}) {
|
||||
bootEpoch: normalizeBootEpoch(payload?.bootEpoch),
|
||||
webContentsId: optionsForRequest.webContentsId,
|
||||
beforeExecute: optionsForRequest.beforeExecute,
|
||||
rendererOwned,
|
||||
previousRendererOwner: previousStart
|
||||
? previousStart.previousRendererOwner
|
||||
: rendererSessionOwners.get(requestedSessionId),
|
||||
previousSessionGeneration: sessionGenerations.get(requestedSessionId),
|
||||
opensOutputSession: channel === "netcatty:start"
|
||||
|| channel === "netcatty:local:reconnect"
|
||||
|| channel === "netcatty:external:start"
|
||||
@@ -1876,6 +1952,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
pendingSessionStartSequences.set(requestedSessionId, requestSequence);
|
||||
latestSessionStartSequences.set(requestedSessionId, requestSequence);
|
||||
rememberPendingStartBootEpoch(requestedSessionId, payload?.bootEpoch);
|
||||
trackRendererSessionOwner(requestedSessionId, pending.get(requestId));
|
||||
// Track host id for SFTP transfer session leases (global transfer center).
|
||||
if (typeof payload?.hostId === "string" && payload.hostId) {
|
||||
sessionHostIds.set(requestedSessionId, payload.hostId);
|
||||
@@ -2114,6 +2191,7 @@ function createTerminalWorkerManager(options = {}) {
|
||||
workerSessionIds.clear();
|
||||
sessionWebContentsIds.clear();
|
||||
sessionHostIds.clear();
|
||||
rendererSessionOwners.clear();
|
||||
sessionGenerations.clear();
|
||||
closedSessionGenerations.clear();
|
||||
supersedingSessionGenerations.clear();
|
||||
|
||||
@@ -771,7 +771,7 @@ test("destroying a renderer immediately cancels its pending session ownership",
|
||||
assert.equal(outputOpened, false);
|
||||
});
|
||||
|
||||
test("worker exit after renderer loss still closes a session with pending ownership", async () => {
|
||||
test("renderer loss closes pending ownership before worker exit without duplicate notification", async () => {
|
||||
const child = new FakeChild();
|
||||
const contents = new EventEmitter();
|
||||
contents.id = 7;
|
||||
@@ -800,12 +800,12 @@ test("worker exit after renderer loss still closes a session with pending owners
|
||||
contents.emit("destroyed");
|
||||
releaseOwnership();
|
||||
await assert.rejects(start, /output route opened/u);
|
||||
assert.deepEqual(closed, []);
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "closed", explicit: true }]);
|
||||
|
||||
child.emit("exit", 1);
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "worker-exit" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "closed", explicit: true }]);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "worker-exit" }]);
|
||||
assert.deepEqual(closed, [{ sessionId: "local-1", reason: "closed", explicit: true }]);
|
||||
});
|
||||
|
||||
test("a start closed while ownership is pending rejects instead of creating a ghost session", async () => {
|
||||
@@ -5003,3 +5003,283 @@ test("worker job diagnostics reach the main console with the diagnostic flag", (
|
||||
t.mock.method(console, "info", () => { throw new Error("unavailable log"); });
|
||||
assert.doesNotThrow(() => child.emit("message", { kind: "job-diagnostic", line }));
|
||||
});
|
||||
|
||||
function createRendererLifetimeFixture(t) {
|
||||
const child = new FakeChild();
|
||||
const renderers = new Map([7, 8, 9].map((id) => {
|
||||
const contents = new EventEmitter();
|
||||
Object.assign(contents, { id, destroyed: false, send() {} });
|
||||
contents.isDestroyed = () => contents.destroyed;
|
||||
contents.destroy = () => { contents.destroyed = true; contents.emit("destroyed"); };
|
||||
return [id, contents];
|
||||
}));
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
electronModule: { webContents: { fromId: id => renderers.get(id) } },
|
||||
terminalOutputChannel: { openSession() {}, closeSession() {} },
|
||||
});
|
||||
t.after(() => manager.stop());
|
||||
const begin = (id, owner = 7, bootEpoch = 1, channel = "netcatty:local:start") => {
|
||||
const promise = manager.request(channel, { sessionId: id, bootEpoch }, { webContentsId: owner });
|
||||
const message = child.messages.findLast(m => m.kind === "request");
|
||||
return { promise, respond() {
|
||||
child.emit("message", { kind: "response", requestId: message.requestId,
|
||||
result: { sessionId: id }, sessionGeneration: bootEpoch });
|
||||
} };
|
||||
};
|
||||
const start = async (...args) => { const pending = begin(...args); pending.respond(); await pending.promise; };
|
||||
const closes = () => child.messages.filter(m => m.channel === "netcatty:close");
|
||||
return { child, renderers, manager, begin, start, closes };
|
||||
}
|
||||
|
||||
test("destroyed lifetime owner closes established sessions through normal worker close once", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
const closed = [];
|
||||
f.manager.onSessionClosed(e => closed.push(e));
|
||||
await f.start("local");
|
||||
await f.start("ssh", 7, 1, "netcatty:start");
|
||||
await f.start("other", 8);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.deepEqual(f.closes().map(m => m.payload.sessionId), ["local", "ssh"]);
|
||||
assert.equal(f.manager.hasOpenSession("local"), false);
|
||||
assert.equal(f.manager.hasOpenSession("ssh"), false);
|
||||
assert.equal(f.manager.hasOpenSession("other"), true);
|
||||
assert.deepEqual(closed.map(e => e.sessionId), ["local", "ssh"]);
|
||||
f.renderers.get(7).emit("destroyed");
|
||||
assert.equal(f.closes().length, 2);
|
||||
});
|
||||
|
||||
test("attached display destruction keeps the source session but source destruction closes it", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
assert.equal((await f.manager.rebindOutputSession("local", 9)).success, true);
|
||||
f.renderers.get(9).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
assert.equal(f.manager.hasOpenSession("local"), true);
|
||||
assert.equal((await f.manager.restoreAttachHome("local")).success, true);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.equal(f.closes().length, 1);
|
||||
});
|
||||
|
||||
test("new pending and established owners survive the old renderer's destruction", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
const replacement = f.begin("local", 8, 2);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
replacement.respond();
|
||||
await replacement.promise;
|
||||
assert.equal(f.manager.getSessionWebContentsId("local"), 8);
|
||||
f.renderers.get(7).emit("destroyed");
|
||||
assert.equal(f.closes().length, 0);
|
||||
f.renderers.get(8).destroy();
|
||||
assert.deepEqual(f.closes()[0].payload, { sessionId: "local" });
|
||||
});
|
||||
|
||||
test("owner destruction cancels a pending backend start before its response", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
const pending = f.begin("local");
|
||||
f.renderers.get(7).destroy();
|
||||
pending.respond();
|
||||
await assert.rejects(pending.promise, /closed before its output route opened/);
|
||||
assert.equal(f.closes().length, 1);
|
||||
assert.equal(f.manager.hasOpenSession("local"), false);
|
||||
});
|
||||
|
||||
test("external agent sessions are not owned by their renderer display", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("external", 7, 1, "netcatty:external:start");
|
||||
f.renderers.get(7).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
assert.equal(f.manager.hasOpenSession("external"), true);
|
||||
});
|
||||
|
||||
test("explicit close and worker shutdown remove renderer lifetime cleanup targets", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
f.manager.send("netcatty:close", { sessionId: "local" });
|
||||
await f.start("second");
|
||||
f.manager.stop();
|
||||
f.renderers.get(7).destroy();
|
||||
assert.deepEqual(f.closes().map(m => m.payload.sessionId), ["local"]);
|
||||
});
|
||||
|
||||
test("restoring an attached terminal into a new home transfers lifetime ownership", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
await f.manager.rebindOutputSession("local", 9);
|
||||
assert.equal((await f.manager.restoreAttachHome("local", 8)).success, true);
|
||||
f.renderers.get(7).destroy();
|
||||
f.renderers.get(9).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
f.renderers.get(8).destroy();
|
||||
assert.equal(f.closes().length, 1);
|
||||
});
|
||||
|
||||
test("failed replacement does not transfer a surviving backend to the failed renderer", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
const replacement = f.begin("local", 8, 2);
|
||||
const request = f.child.messages.findLast(m => m.kind === "request");
|
||||
f.child.emit("message", { kind: "response", requestId: request.requestId, error: "start failed" });
|
||||
await assert.rejects(replacement.promise, /start failed/);
|
||||
f.renderers.get(8).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.deepEqual(f.closes()[0].payload, { sessionId: "local" });
|
||||
});
|
||||
|
||||
test("failed handoff closes a surviving backend if its original renderer already disappeared", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
await f.start("local");
|
||||
const replacement = f.begin("local", 8, 2);
|
||||
f.renderers.get(7).destroy();
|
||||
assert.equal(f.closes().length, 0);
|
||||
const request = f.child.messages.findLast(m => m.kind === "request");
|
||||
f.child.emit("message", { kind: "response", requestId: request.requestId, error: "start failed" });
|
||||
await assert.rejects(replacement.promise, /start failed/);
|
||||
assert.deepEqual(f.closes()[0].payload, { sessionId: "local", bootEpoch: 1 });
|
||||
});
|
||||
|
||||
test("renderer lifetime close cancels queued replacement and closes the older active backend", async t => {
|
||||
const terminalBridge = require("./terminalBridge.cjs");
|
||||
const child = new LinkedRuntimeChild();
|
||||
const contents = Object.assign(new EventEmitter(), { id: 7, send() {} });
|
||||
const sessions = new Map();
|
||||
let starts = 0;
|
||||
let kills = 0;
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
child.connectRuntime(ipcMain => {
|
||||
ipcMain.handle("netcatty:local:start", (_event, payload) => {
|
||||
starts++;
|
||||
sessions.set(payload.sessionId, { bootEpoch: payload.bootEpoch, proc: { kill() { kills++; } } });
|
||||
return { sessionId: payload.sessionId };
|
||||
});
|
||||
ipcMain.on("netcatty:close", terminalBridge.closeSession);
|
||||
ipcMain.handle("netcatty:close:await", terminalBridge.closeSession);
|
||||
});
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
electronModule: { webContents: { fromId: () => contents } },
|
||||
});
|
||||
t.after(() => manager.stop());
|
||||
await manager.request("netcatty:local:start", { sessionId: "queued-close", bootEpoch: 1 }, { webContentsId: 7 });
|
||||
const replacement = manager.request("netcatty:local:start", { sessionId: "queued-close", bootEpoch: 2 }, { webContentsId: 7 });
|
||||
contents.emit("destroyed");
|
||||
await assert.rejects(replacement, /cancelled by close|closed before/);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(starts, 1, "replacement was cancelled before its handler ran");
|
||||
assert.equal(kills, 1, "the old active boot must still be closed");
|
||||
assert.equal(sessions.has("queued-close"), false);
|
||||
assert.equal(manager.hasOpenSession("queued-close"), false);
|
||||
});
|
||||
|
||||
test("destroying a copied child window releases only its shells on a shared SSH transport", async t => {
|
||||
const terminalBridge = require("./terminalBridge.cjs");
|
||||
const {
|
||||
createConnectionRef,
|
||||
acquireConnectionRef,
|
||||
resetSshTransportRegistryForTests,
|
||||
} = require("./sshConnectionPool.cjs");
|
||||
resetSshTransportRegistryForTests();
|
||||
t.after(() => resetSshTransportRegistryForTests());
|
||||
|
||||
const sessions = new Map();
|
||||
terminalBridge.init({ sessions, electronModule: {} });
|
||||
const conn = { ended: 0, end() { this.ended += 1; } };
|
||||
const shell = () => ({
|
||||
stream: { closed: 0, close() { this.closed += 1; } },
|
||||
conn,
|
||||
chainConnections: [],
|
||||
connRef: null,
|
||||
zmodemSentry: { cancel() {} },
|
||||
bootEpoch: 1,
|
||||
});
|
||||
const mainA = shell();
|
||||
const transport = createConnectionRef(mainA, conn, []);
|
||||
const mainB = shell();
|
||||
const childA = shell();
|
||||
const childB = shell();
|
||||
acquireConnectionRef(mainB, transport);
|
||||
acquireConnectionRef(childA, transport);
|
||||
acquireConnectionRef(childB, transport);
|
||||
sessions.set("main-a", mainA);
|
||||
sessions.set("main-b", mainB);
|
||||
sessions.set("child-a", childA);
|
||||
sessions.set("child-b", childB);
|
||||
|
||||
const renderers = new Map([1, 2].map((id) => {
|
||||
const contents = new EventEmitter();
|
||||
Object.assign(contents, { id, destroyed: false, send() {} });
|
||||
contents.isDestroyed = () => contents.destroyed;
|
||||
contents.destroy = () => { contents.destroyed = true; contents.emit("destroyed"); };
|
||||
return [id, contents];
|
||||
}));
|
||||
const child = new LinkedRuntimeChild();
|
||||
child.connectRuntime((ipcMain) => {
|
||||
ipcMain.handle("netcatty:start", (_event, payload) => ({ sessionId: payload.sessionId }));
|
||||
ipcMain.on("netcatty:close", terminalBridge.closeSession);
|
||||
ipcMain.handle("netcatty:close:await", terminalBridge.closeSession);
|
||||
});
|
||||
const manager = createTerminalWorkerManager({
|
||||
utilityProcess: { fork: () => child },
|
||||
electronModule: { webContents: { fromId: (id) => renderers.get(id) } },
|
||||
terminalOutputChannel: { openSession() {}, closeSession() {} },
|
||||
});
|
||||
t.after(() => manager.stop());
|
||||
|
||||
const own = (sessionId, webContentsId) => manager.request(
|
||||
"netcatty:start",
|
||||
{ sessionId, bootEpoch: 1, hostId: "shared-host" },
|
||||
{ webContentsId },
|
||||
);
|
||||
await own("main-a", 1);
|
||||
await own("main-b", 1);
|
||||
await own("child-a", 2);
|
||||
await own("child-b", 2);
|
||||
assert.equal(transport.count, 4);
|
||||
assert.equal(transport.state, "live");
|
||||
|
||||
renderers.get(2).destroy();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(childA.stream.closed, 1);
|
||||
assert.equal(childB.stream.closed, 1);
|
||||
assert.equal(mainA.stream.closed, 0);
|
||||
assert.equal(mainB.stream.closed, 0);
|
||||
assert.equal(conn.ended, 0, "shared SSH connection stays up for the main window");
|
||||
assert.equal(transport.count, 2);
|
||||
assert.equal(transport.state, "live");
|
||||
assert.equal(sessions.has("main-a"), true);
|
||||
assert.equal(sessions.has("main-b"), true);
|
||||
assert.equal(sessions.has("child-a"), false);
|
||||
assert.equal(sessions.has("child-b"), false);
|
||||
assert.equal(manager.hasOpenSession("main-a"), true);
|
||||
assert.equal(manager.hasOpenSession("main-b"), true);
|
||||
assert.equal(manager.hasOpenSession("child-a"), false);
|
||||
assert.equal(manager.hasOpenSession("child-b"), false);
|
||||
|
||||
renderers.get(2).emit("destroyed");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(transport.count, 2, "a repeated child-window destroy must not drop surviving leases");
|
||||
assert.equal(conn.ended, 0);
|
||||
|
||||
renderers.get(1).destroy();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(mainA.stream.closed, 1);
|
||||
assert.equal(mainB.stream.closed, 1);
|
||||
assert.equal(transport.count, 0);
|
||||
assert.equal(transport.state, "idle");
|
||||
assert.equal(conn.ended, 0, "the parked transport is not ended while it can still be reused");
|
||||
});
|
||||
|
||||
test("backend-assigned session ID is closed if its renderer disappeared before the response", async t => {
|
||||
const f = createRendererLifetimeFixture(t);
|
||||
const start = f.manager.request("netcatty:local:start", {}, { webContentsId: 7 });
|
||||
const request = f.child.messages.findLast(m => m.kind === "request");
|
||||
f.renderers.get(7).destroy();
|
||||
f.renderers.delete(7);
|
||||
f.child.emit("message", { kind: "response", requestId: request.requestId, result: { sessionId: "assigned" } });
|
||||
await assert.rejects(start, /closed before its output route opened/);
|
||||
assert.deepEqual(f.closes().map(m => m.payload), [{ sessionId: "assigned" }]);
|
||||
});
|
||||
Reference in new issue
Block a user