Files
Netcatty/electron/preloadDataBacklog.test.cjs
T
陈大猫 99e1a8d6d8 fix(zmodem): keep transfer listeners across same-session reconnects (#1962)
* fix(zmodem): keep transfer listeners across same-session reconnects

The preload netcatty:exit handler dropped the per-session zmodem
listener sets, but auth-failure retries and auto-reconnects reuse the
same sessionId without remounting the terminal, so useZmodemTransfer
never re-subscribed and rz/sz progress + overwrite dialogs silently
stopped appearing (#1955). Move listener cleanup to explicit
closeSession and prune empty sets on subscriber dispose instead.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(zmodem): gate event delivery instead of dropping listeners on close

Codex review caught that reconnects also call closeSession with the
terminal still mounted (startReconnect -> cleanupSession), so clearing
listener maps there reintroduced the bug for manual/auto reconnects.
Keep listener sets owned by renderer subscribers and gate zmodem event
delivery on closedTerminalDataSessions, which is cleared when a session
restarts with the same id.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:48:05 +08:00

868 lines
24 KiB
JavaScript

const assert = require("node:assert/strict");
const test = require("node:test");
const { createPreloadApi } = require("./preload/api.cjs");
const {
clearTerminalDataBacklog,
clearTerminalDataSession,
createTerminalDataBacklog,
createTerminalDataDispatcher,
} = require("./preload/terminalDataBacklog.cjs");
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function loadPreloadWithFakeElectron() {
const handlers = new Map();
let exposedApi = null;
const fakeElectron = {
ipcRenderer: {
on(channel, handler) {
handlers.set(channel, handler);
},
send() {},
async invoke(channel, payload) {
if (channel === "netcatty:local:start") {
return { sessionId: payload?.sessionId };
}
return null;
},
},
contextBridge: {
exposeInMainWorld(_name, value) {
exposedApi = value;
},
},
webUtils: {
getPathForFile(file) {
return file?.path ?? "";
},
},
};
const electronPath = require.resolve("electron");
const preloadPath = require.resolve("./preload.cjs");
const previousElectron = require.cache[electronPath];
const previousWindow = global.window;
require.cache[electronPath] = {
id: electronPath,
filename: electronPath,
loaded: true,
exports: fakeElectron,
};
delete require.cache[preloadPath];
global.window = {
location: { origin: "app://netcatty" },
netcatty: undefined,
};
require(preloadPath);
return {
api: exposedApi,
handlers,
cleanup() {
delete require.cache[preloadPath];
if (previousElectron) {
require.cache[electronPath] = previousElectron;
} else {
delete require.cache[electronPath];
}
if (previousWindow === undefined) {
delete global.window;
} else {
global.window = previousWindow;
}
},
};
}
test("stores early terminal data until the listener is registered", () => {
const backlog = createTerminalDataBacklog();
backlog.append("session-1", "Linux banner\r\n");
backlog.append("session-1", "root@host:~# ");
assert.equal(backlog.take("session-1"), "Linux banner\r\nroot@host:~# ");
assert.equal(backlog.take("session-1"), "");
});
test("keeps each session backlog isolated", () => {
const backlog = createTerminalDataBacklog();
backlog.append("session-1", "one");
backlog.append("session-2", "two");
assert.equal(backlog.take("session-2"), "two");
assert.equal(backlog.take("session-1"), "one");
});
test("trims old data when the per-session limit is exceeded", () => {
const backlog = createTerminalDataBacklog({ maxBytesPerSession: 5 });
backlog.append("session-1", "hello");
backlog.append("session-1", " world");
assert.equal(backlog.take("session-1"), "world");
});
test("keeps terminal output metadata while trimming backlog data", () => {
const backlog = createTerminalDataBacklog({ maxBytesPerSession: 5 });
backlog.append("session-1", "hello", { droppedOutputMayAffectTerminalState: true });
backlog.append("session-1", " world");
assert.deepEqual(backlog.takeEntry("session-1"), {
data: "world",
meta: { droppedOutputMayAffectTerminalState: true },
});
});
test("keeps latest alternate-screen metadata while trimming backlog data", () => {
const backlog = createTerminalDataBacklog({ maxBytesPerSession: 5 });
backlog.append("session-1", "hello", {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "enter",
});
backlog.append("session-1", " world", {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "leave",
});
assert.deepEqual(backlog.takeEntry("session-1"), {
data: "world",
meta: {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "leave",
},
});
});
test("new unknown terminal-state metadata clears stale alternate-screen action in backlog", () => {
const backlog = createTerminalDataBacklog({ maxBytesPerSession: 64 });
backlog.append("session-1", "first", {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "leave",
});
backlog.append("session-1", "second", {
droppedOutputMayAffectTerminalState: true,
});
assert.deepEqual(backlog.takeEntry("session-1"), {
data: "firstsecond",
meta: { droppedOutputMayAffectTerminalState: true },
});
});
test("dispatcher writes terminal output metadata into backlog", () => {
const dataListeners = new Map();
const displayDataListeners = new Map();
const terminalDataBacklog = createTerminalDataBacklog();
const deliver = createTerminalDataDispatcher({
dataListeners,
displayDataListeners,
terminalDataBacklog,
});
deliver("session-1", "early output", { droppedOutputMayAffectTerminalState: true });
assert.deepEqual(terminalDataBacklog.takeEntry("session-1"), {
data: "early output",
meta: { droppedOutputMayAffectTerminalState: true },
});
});
test("clear drops pending data for a closed session", () => {
const backlog = createTerminalDataBacklog();
backlog.append("session-1", "pending");
backlog.clear("session-1");
assert.equal(backlog.size("session-1"), 0);
assert.equal(backlog.take("session-1"), "");
});
test("onSessionData flushes pending terminal data on subscribe", () => {
const dataListeners = new Map();
const displayDataListeners = new Map();
const terminalDataBacklog = createTerminalDataBacklog();
terminalDataBacklog.append("session-1", "early MOTD\r\n");
const api = createPreloadApi({
ipcRenderer: {
invoke() {},
send() {},
on() {},
removeListener() {},
},
os: {
release: () => "10.0.19045",
},
dataListeners,
displayDataListeners,
terminalDataBacklog,
});
const received = [];
const unsubscribe = api.onSessionData("session-1", (chunk) => {
received.push(chunk);
}, { replayBacklog: true });
assert.deepEqual(received, ["early MOTD\r\n"]);
assert.equal(terminalDataBacklog.size("session-1"), 0);
assert.equal(displayDataListeners.get("session-1").size, 1);
unsubscribe();
assert.equal(dataListeners.has("session-1"), false);
assert.equal(displayDataListeners.has("session-1"), false);
});
test("onSessionData replays pending terminal data metadata on subscribe", () => {
const dataListeners = new Map();
const displayDataListeners = new Map();
const terminalDataBacklog = createTerminalDataBacklog();
terminalDataBacklog.append("session-1", "early output", {
droppedOutputMayAffectTerminalState: true,
});
const api = createPreloadApi({
ipcRenderer: {
invoke() {},
send() {},
on() {},
removeListener() {},
},
os: {
release: () => "10.0.19045",
},
dataListeners,
displayDataListeners,
terminalDataBacklog,
});
const received = [];
api.onSessionData("session-1", (chunk, meta) => {
received.push({ chunk, meta });
}, { replayBacklog: true });
assert.deepEqual(received, [{
chunk: "early output",
meta: { droppedOutputMayAffectTerminalState: true },
}]);
});
test("delayed MCP terminal data flush preserves metadata", async () => {
const preload = loadPreloadWithFakeElectron();
try {
const received = [];
preload.api.onSessionData("session-1", (chunk, meta) => {
received.push({ chunk, meta });
});
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "prompt __NCM",
meta: { droppedOutputMayAffectTerminalState: true },
});
assert.deepEqual(received, []);
await sleep(100);
assert.deepEqual(received, [{
chunk: "prompt __NCM",
meta: { droppedOutputMayAffectTerminalState: true },
}]);
} finally {
preload.cleanup();
}
});
test("MCP-filtered empty terminal data carries metadata to next visible output", () => {
const preload = loadPreloadWithFakeElectron();
try {
const received = [];
preload.api.onSessionData("session-1", (chunk, meta) => {
received.push({ chunk, meta });
});
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "__NCMCP_TEST\n",
meta: { droppedOutputMayAffectTerminalState: true },
});
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "READY\n",
});
assert.deepEqual(received, [{
chunk: "READY\n",
meta: { droppedOutputMayAffectTerminalState: true },
}]);
} finally {
preload.cleanup();
}
});
test("MCP metadata merge clears stale alternate-screen action on later unknown risk", () => {
const preload = loadPreloadWithFakeElectron();
try {
const received = [];
preload.api.onSessionData("session-1", (chunk, meta) => {
received.push({ chunk, meta });
});
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "__NCMCP_TEST\n",
meta: {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "leave",
},
});
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "READY\n",
meta: { droppedOutputMayAffectTerminalState: true },
});
assert.deepEqual(received, [{
chunk: "READY\n",
meta: { droppedOutputMayAffectTerminalState: true },
}]);
} finally {
preload.cleanup();
}
});
test("MCP-filtered empty terminal metadata is cleared on session exit", async () => {
const preload = loadPreloadWithFakeElectron();
try {
const received = [];
preload.api.onSessionData("session-1", (chunk, meta) => {
received.push({ chunk, meta });
}, { replayBacklog: true });
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "__NCMCP_TEST\n",
meta: { droppedOutputMayAffectTerminalState: true },
});
preload.handlers.get("netcatty:exit")?.({}, {
sessionId: "session-1",
reason: "closed",
});
await preload.api.startLocalSession({ sessionId: "session-1" });
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "READY\n",
});
assert.deepEqual(received, [{
chunk: "READY\n",
meta: undefined,
}]);
} finally {
preload.cleanup();
}
});
test("non-display listeners do not drain pending terminal data", () => {
const dataListeners = new Map();
const displayDataListeners = new Map();
const terminalDataBacklog = createTerminalDataBacklog();
terminalDataBacklog.append("session-1", "early prompt");
const api = createPreloadApi({
ipcRenderer: {
invoke() {},
send() {},
on() {},
removeListener() {},
},
os: {
release: () => "10.0.19045",
},
dataListeners,
displayDataListeners,
terminalDataBacklog,
});
const observerReceived = [];
const displayReceived = [];
api.onSessionData("session-1", (chunk) => {
observerReceived.push(chunk);
});
assert.deepEqual(observerReceived, []);
assert.equal(terminalDataBacklog.size("session-1"), "early prompt".length);
api.onSessionData("session-1", (chunk) => {
displayReceived.push(chunk);
}, { replayBacklog: true });
assert.deepEqual(observerReceived, []);
assert.deepEqual(displayReceived, ["early prompt"]);
assert.equal(terminalDataBacklog.size("session-1"), 0);
});
test("keeps early data for display replay while only observer listeners exist", () => {
const observed = [];
const dataListeners = new Map([
["session-1", new Set([(chunk) => observed.push(chunk)])],
]);
const displayDataListeners = new Map();
const terminalDataBacklog = createTerminalDataBacklog();
const deliverToListeners = createTerminalDataDispatcher({
dataListeners,
displayDataListeners,
terminalDataBacklog,
});
deliverToListeners("session-1", "Linux banner\r\n");
assert.deepEqual(observed, ["Linux banner\r\n"]);
assert.equal(terminalDataBacklog.take("session-1"), "Linux banner\r\n");
});
test("does not backlog data once the display listener is registered", () => {
const observed = [];
const displayed = [];
const displayListener = (chunk) => displayed.push(chunk);
const dataListeners = new Map([
["session-1", new Set([(chunk) => observed.push(chunk), displayListener])],
]);
const displayDataListeners = new Map([
["session-1", new Set([displayListener])],
]);
const terminalDataBacklog = createTerminalDataBacklog();
const deliverToListeners = createTerminalDataDispatcher({
dataListeners,
displayDataListeners,
terminalDataBacklog,
});
deliverToListeners("session-1", "live output");
assert.deepEqual(observed, ["live output"]);
assert.deepEqual(displayed, ["live output"]);
assert.equal(terminalDataBacklog.take("session-1"), "");
});
test("drops terminal data for sessions marked closed", () => {
const observed = [];
const dataListeners = new Map([
["session-1", new Set([(chunk) => observed.push(chunk)])],
]);
const displayDataListeners = new Map();
const terminalDataBacklog = createTerminalDataBacklog();
const closedSessions = new Set(["session-1"]);
const deliverToListeners = createTerminalDataDispatcher({
dataListeners,
displayDataListeners,
terminalDataBacklog,
shouldDropSession: (sessionId) => closedSessions.has(sessionId),
});
deliverToListeners("session-1", "late output");
assert.deepEqual(observed, []);
assert.equal(terminalDataBacklog.take("session-1"), "");
});
test("clearTerminalDataSession drops listener and backlog state together", () => {
const listener = () => {};
const dataListeners = new Map([
["session-1", new Set([listener])],
]);
const displayDataListeners = new Map([
["session-1", new Set([listener])],
]);
const terminalDataBacklog = createTerminalDataBacklog();
terminalDataBacklog.append("session-1", "pending");
clearTerminalDataSession({
dataListeners,
displayDataListeners,
terminalDataBacklog,
}, "session-1");
assert.equal(dataListeners.has("session-1"), false);
assert.equal(displayDataListeners.has("session-1"), false);
assert.equal(terminalDataBacklog.take("session-1"), "");
});
test("clearTerminalDataBacklog preserves live display listeners for reconnect", () => {
const listener = () => {};
const dataListeners = new Map([
["session-1", new Set([listener])],
]);
const displayDataListeners = new Map([
["session-1", new Set([listener])],
]);
const terminalDataBacklog = createTerminalDataBacklog();
terminalDataBacklog.append("session-1", "pending");
clearTerminalDataBacklog({ terminalDataBacklog }, "session-1");
assert.equal(dataListeners.get("session-1")?.has(listener), true);
assert.equal(displayDataListeners.get("session-1")?.has(listener), true);
assert.equal(terminalDataBacklog.take("session-1"), "");
});
test("backend exit preserves live listeners for same-id reconnect", async () => {
const preload = loadPreloadWithFakeElectron();
try {
const received = [];
const exits = [];
preload.api.onSessionData("session-1", (chunk) => {
received.push(chunk);
}, { replayBacklog: true });
preload.api.onSessionExit("session-1", (evt) => {
exits.push(evt.reason);
});
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "before exit",
});
preload.handlers.get("netcatty:exit")?.({}, {
sessionId: "session-1",
reason: "closed",
});
preload.handlers.get("netcatty:exit")?.({}, {
sessionId: "session-1",
reason: "duplicate-closed",
});
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "dropped while closed",
});
await preload.api.startLocalSession({ sessionId: "session-1" });
preload.handlers.get("netcatty:data")?.({}, {
sessionId: "session-1",
data: "after reconnect",
});
preload.handlers.get("netcatty:exit")?.({}, {
sessionId: "session-1",
reason: "closed-again",
});
assert.deepEqual(received, ["before exit", "after reconnect"]);
assert.deepEqual(exits, ["closed", "closed-again"]);
} finally {
preload.cleanup();
}
});
test("zmodem events after explicit close and backend exit are gated", () => {
const preload = loadPreloadWithFakeElectron();
try {
const zmodemEvents = [];
const overwriteRequests = [];
preload.api.onZmodemEvent("session-1", (evt) => {
zmodemEvents.push(evt.type);
});
preload.api.onZmodemOverwriteRequest("session-1", (payload) => {
overwriteRequests.push(payload.sessionId);
});
preload.api.closeSession("session-1");
preload.handlers.get("netcatty:exit")?.({}, {
sessionId: "session-1",
reason: "closed",
});
preload.handlers.get("netcatty:zmodem:detect")?.({}, {
sessionId: "session-1",
});
preload.handlers.get("netcatty:zmodem:overwrite-request")?.({}, {
sessionId: "session-1",
});
assert.deepEqual(zmodemEvents, []);
assert.deepEqual(overwriteRequests, []);
} finally {
preload.cleanup();
}
});
test("zmodem listeners survive backend exit and fire after same-session reconnect", async () => {
const preload = loadPreloadWithFakeElectron();
try {
const zmodemEvents = [];
const overwriteRequests = [];
preload.api.onZmodemEvent("session-1", (evt) => {
zmodemEvents.push(evt.type);
});
preload.api.onZmodemOverwriteRequest("session-1", (payload) => {
overwriteRequests.push(payload);
});
preload.handlers.get("netcatty:exit")?.({}, {
sessionId: "session-1",
reason: "error",
});
await preload.api.startLocalSession({ sessionId: "session-1" });
preload.handlers.get("netcatty:zmodem:detect")?.({}, {
sessionId: "session-1",
});
preload.handlers.get("netcatty:zmodem:overwrite-request")?.({}, {
sessionId: "session-1",
requestId: "r1",
filename: "f",
});
assert.deepEqual(zmodemEvents, ["detect"]);
assert.deepEqual(overwriteRequests, [{
sessionId: "session-1",
requestId: "r1",
filename: "f",
}]);
} finally {
preload.cleanup();
}
});
test("zmodem listeners survive reconnect-style closeSession and resume after restart", async () => {
const preload = loadPreloadWithFakeElectron();
try {
const zmodemEvents = [];
const overwriteRequests = [];
preload.api.onZmodemEvent("session-1", (evt) => {
zmodemEvents.push(evt.type);
});
preload.api.onZmodemOverwriteRequest("session-1", (payload) => {
overwriteRequests.push(payload.requestId);
});
// Reconnect path: closeSession is called while the terminal component
// stays mounted, then the session restarts with the same id.
preload.api.closeSession("session-1");
preload.handlers.get("netcatty:zmodem:detect")?.({}, {
sessionId: "session-1",
});
assert.deepEqual(zmodemEvents, []);
await preload.api.startLocalSession({ sessionId: "session-1" });
preload.handlers.get("netcatty:zmodem:detect")?.({}, {
sessionId: "session-1",
});
preload.handlers.get("netcatty:zmodem:overwrite-request")?.({}, {
sessionId: "session-1",
requestId: "r1",
filename: "f",
});
assert.deepEqual(zmodemEvents, ["detect"]);
assert.deepEqual(overwriteRequests, ["r1"]);
} finally {
preload.cleanup();
}
});
test("onZmodemEvent unsubscribe removes empty listener set", () => {
const zmodemListeners = new Map();
const api = createPreloadApi({
ipcRenderer: {
invoke() {},
send() {},
on() {},
removeListener() {},
},
os: {
release: () => "10.0.19045",
},
dataListeners: new Map(),
displayDataListeners: new Map(),
terminalDataBacklog: createTerminalDataBacklog(),
zmodemListeners,
});
const off = api.onZmodemEvent("session-1", () => {});
assert.equal(zmodemListeners.has("session-1"), true);
off();
assert.equal(zmodemListeners.has("session-1"), false);
});
test("onSessionExit unsubscribe removes empty listener set", () => {
const exitListeners = new Map();
const api = createPreloadApi({
ipcRenderer: {
invoke() {},
send() {},
on() {},
removeListener() {},
},
os: {
release: () => "10.0.19045",
},
dataListeners: new Map(),
displayDataListeners: new Map(),
terminalDataBacklog: createTerminalDataBacklog(),
exitListeners,
});
const off = api.onSessionExit("session-1", () => {});
assert.equal(exitListeners.has("session-1"), true);
off();
assert.equal(exitListeners.has("session-1"), false);
});
test("closeSession clears terminal data state and marks the session closed", () => {
const listener = () => {};
const dataListeners = new Map([
["session-1", new Set([listener])],
]);
const displayDataListeners = new Map([
["session-1", new Set([listener])],
]);
const terminalDataBacklog = createTerminalDataBacklog();
const closedTerminalDataSessions = new Set();
const telnetEchoModeListeners = new Map([
["session-1", new Set([listener])],
]);
const zmodemListeners = new Map([
["session-1", new Set([listener])],
]);
const zmodemOverwriteListeners = new Map([
["session-1", new Set([listener])],
]);
const sent = [];
const closedPorts = [];
terminalDataBacklog.append("session-1", "pending");
const api = createPreloadApi({
ipcRenderer: {
invoke() {},
send(channel, payload) {
sent.push({ channel, payload });
},
on() {},
removeListener() {},
},
os: {
release: () => "10.0.19045",
},
dataListeners,
displayDataListeners,
terminalDataBacklog,
closedTerminalDataSessions,
telnetEchoModeListeners,
zmodemListeners,
zmodemOverwriteListeners,
terminalOutputPorts: {
closeSession(sessionId) {
closedPorts.push(sessionId);
},
},
});
api.closeSession("session-1");
assert.equal(dataListeners.has("session-1"), false);
assert.equal(displayDataListeners.has("session-1"), false);
assert.equal(terminalDataBacklog.take("session-1"), "");
assert.equal(closedTerminalDataSessions.has("session-1"), true);
assert.equal(telnetEchoModeListeners.has("session-1"), false);
// Zmodem listeners are preserved: reconnect closes the session without
// unmounting the subscriber, so cleanup is left to subscriber dispose.
assert.equal(zmodemListeners.has("session-1"), true);
assert.equal(zmodemOverwriteListeners.has("session-1"), true);
assert.deepEqual(closedPorts, ["session-1"]);
assert.deepEqual(sent, [
{ channel: "netcatty:close", payload: { sessionId: "session-1" } },
]);
});
test("interruptSession uses the urgent input port before falling back to IPC", () => {
const sent = [];
const urgent = [];
const api = createPreloadApi({
ipcRenderer: {
invoke() {},
send(channel, payload) {
sent.push({ channel, payload });
},
on() {},
removeListener() {},
},
os: {
release: () => "10.0.19045",
},
dataListeners: new Map(),
displayDataListeners: new Map(),
terminalDataBacklog: createTerminalDataBacklog(),
terminalUrgentInputPorts: {
postInterrupt(sessionId, trace) {
urgent.push({ sessionId, trace });
return true;
},
},
});
api.interruptSession("session-1", {
traceId: "trace-1",
rendererKeyAt: 123,
});
assert.deepEqual(urgent, [
{
sessionId: "session-1",
trace: {
traceId: "trace-1",
rendererKeyAt: 123,
rendererHasSelection: false,
debug: false,
rendererPriority: undefined,
rendererSendAt: undefined,
rendererStatus: undefined,
sessionId: undefined,
source: undefined,
},
},
]);
assert.deepEqual(sent, []);
});
test("startLocalSession reopens a previously closed terminal data session", async () => {
const closedTerminalDataSessions = new Set(["session-1"]);
const invoked = [];
const api = createPreloadApi({
ipcRenderer: {
async invoke(channel, payload) {
invoked.push({ channel, payload, wasClosed: closedTerminalDataSessions.has("session-1") });
return { sessionId: "session-1" };
},
send() {},
on() {},
removeListener() {},
},
os: {
release: () => "10.0.19045",
},
dataListeners: new Map(),
displayDataListeners: new Map(),
terminalDataBacklog: createTerminalDataBacklog(),
closedTerminalDataSessions,
telnetEchoModeListeners: new Map(),
});
const sessionId = await api.startLocalSession({ sessionId: "session-1" });
assert.equal(sessionId, "session-1");
assert.deepEqual(invoked, [
{
channel: "netcatty:local:start",
payload: { sessionId: "session-1" },
wasClosed: false,
},
]);
assert.equal(closedTerminalDataSessions.has("session-1"), false);
});