[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
576
electron/bridges/terminalBridge.ctrlCInterrupt.test.cjs
Normal file
576
electron/bridges/terminalBridge.ctrlCInterrupt.test.cjs
Normal file
@@ -0,0 +1,576 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const terminalBridge = require("./terminalBridge.cjs");
|
||||
const {
|
||||
FLOW_HIGH_WATER_MARK,
|
||||
} = require("../../infrastructure/config/terminalFlowConstants.cjs");
|
||||
|
||||
function initBridge(sessions, sent = []) {
|
||||
terminalBridge.init({
|
||||
sessions,
|
||||
electronModule: {
|
||||
webContents: {
|
||||
fromId: () => ({
|
||||
send(channel, payload) {
|
||||
sent.push([channel, payload]);
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
test("SSH Ctrl+C writes the original byte without sending a channel signal", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("ssh-1", {
|
||||
stream: {
|
||||
signal(signalName) {
|
||||
calls.push(["signal", signalName]);
|
||||
},
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "\x03" });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["write", "\x03"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("interruptSession sends SSH Ctrl+C without discarding pending output", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("ssh-1", {
|
||||
discardPendingData() {
|
||||
calls.push(["discard"]);
|
||||
},
|
||||
stream: {
|
||||
signal(signalName) {
|
||||
calls.push(["signal", signalName]);
|
||||
},
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "ssh-1" });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["write", "\x03"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("interruptSession sends SSH Ctrl+C before resuming a paused output flood", async () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("ssh-1", {
|
||||
discardPendingData() {
|
||||
calls.push(["discard"]);
|
||||
},
|
||||
stream: {
|
||||
pause() {
|
||||
calls.push(["pause"]);
|
||||
},
|
||||
resume() {
|
||||
calls.push(["resume"]);
|
||||
},
|
||||
signal(signalName) {
|
||||
calls.push(["signal", signalName]);
|
||||
},
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.setSessionFlowPaused({ sender: {} }, { sessionId: "ssh-1", paused: true });
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "ssh-1" });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["pause"],
|
||||
["write", "\x03"],
|
||||
]);
|
||||
|
||||
await delay(0);
|
||||
assert.deepEqual(calls, [
|
||||
["pause"],
|
||||
["write", "\x03"],
|
||||
["resume"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("interruptSession sends SSH Ctrl+C without fastpath under low output pressure", async () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("ssh-1", {
|
||||
takePendingData() {
|
||||
calls.push(["take-pending"]);
|
||||
return "Type :qa and press <Enter> to exit Vim";
|
||||
},
|
||||
discardPendingData() {
|
||||
calls.push(["discard"]);
|
||||
},
|
||||
flowState: {
|
||||
rendererPaused: false,
|
||||
unackedBytes: 119,
|
||||
appliedPause: false,
|
||||
},
|
||||
stream: {
|
||||
pause() {
|
||||
calls.push(["pause"]);
|
||||
},
|
||||
resume() {
|
||||
calls.push(["resume"]);
|
||||
},
|
||||
signal(signalName) {
|
||||
calls.push(["signal", signalName]);
|
||||
},
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "ssh-1" });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["write", "\x03"],
|
||||
]);
|
||||
|
||||
await delay(0);
|
||||
assert.deepEqual(calls, [
|
||||
["write", "\x03"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("interruptSession filters pending SSH output under high output pressure", () => {
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("ssh-1", {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
webContentsId: 1,
|
||||
takePendingData() {
|
||||
calls.push(["take-pending"]);
|
||||
return {
|
||||
data: "stale frame\x1b[?1049l",
|
||||
meta: { droppedOutputMayAffectTerminalState: true },
|
||||
};
|
||||
},
|
||||
discardPendingData() {
|
||||
calls.push(["discard"]);
|
||||
},
|
||||
flowState: {
|
||||
rendererPaused: false,
|
||||
unackedBytes: FLOW_HIGH_WATER_MARK + 1300,
|
||||
appliedPause: false,
|
||||
},
|
||||
stream: {
|
||||
pause() {
|
||||
calls.push(["pause"]);
|
||||
},
|
||||
resume() {},
|
||||
signal(signalName) {
|
||||
calls.push(["signal", signalName]);
|
||||
},
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions, sent);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "ssh-1" });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["pause"],
|
||||
["take-pending"],
|
||||
["pause"],
|
||||
["write", "\x03"],
|
||||
]);
|
||||
assert.deepEqual(sent, [
|
||||
["netcatty:data", {
|
||||
sessionId: "ssh-1",
|
||||
data: "\x1b[?1049l",
|
||||
meta: { droppedOutputMayAffectTerminalState: true },
|
||||
}],
|
||||
]);
|
||||
});
|
||||
|
||||
test("interruptSession preserves pending metadata when drained output is fully filtered", () => {
|
||||
const calls = [];
|
||||
const sent = [];
|
||||
const sessions = new Map();
|
||||
const session = {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
webContentsId: 1,
|
||||
takePendingData() {
|
||||
calls.push(["take-pending"]);
|
||||
return {
|
||||
data: "stale frame without prompt",
|
||||
meta: { droppedOutputMayAffectTerminalState: true },
|
||||
};
|
||||
},
|
||||
flowState: {
|
||||
rendererPaused: false,
|
||||
unackedBytes: FLOW_HIGH_WATER_MARK + 1300,
|
||||
appliedPause: false,
|
||||
},
|
||||
stream: {
|
||||
pause() {
|
||||
calls.push(["pause"]);
|
||||
},
|
||||
resume() {},
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
};
|
||||
sessions.set("ssh-1", session);
|
||||
initBridge(sessions, sent);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "ssh-1" });
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
assert.deepEqual(session._pendingInterruptOutputMeta, {
|
||||
droppedOutputMayAffectTerminalState: true,
|
||||
});
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "x" });
|
||||
|
||||
assert.deepEqual(session._pendingInterruptOutputMeta, {
|
||||
droppedOutputMayAffectTerminalState: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("interruptSession does not arm SSH output drain for tiny in-flight echo", () => {
|
||||
const sessions = new Map();
|
||||
const session = {
|
||||
discardPendingData() {},
|
||||
flowState: {
|
||||
rendererPaused: false,
|
||||
unackedBytes: 119,
|
||||
appliedPause: false,
|
||||
},
|
||||
stream: {
|
||||
pause() {},
|
||||
resume() {},
|
||||
signal() {},
|
||||
write() {},
|
||||
},
|
||||
};
|
||||
sessions.set("ssh-1", session);
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "ssh-1" });
|
||||
|
||||
assert.notEqual(session._interruptOutputGate?.active, true);
|
||||
});
|
||||
|
||||
test("interruptSession arms SSH output drain when interrupting a paused output flood", () => {
|
||||
const sessions = new Map();
|
||||
const session = {
|
||||
discardPendingData() {},
|
||||
flowState: {
|
||||
rendererPaused: false,
|
||||
unackedBytes: 34068,
|
||||
appliedPause: true,
|
||||
},
|
||||
stream: {
|
||||
resume() {},
|
||||
signal() {},
|
||||
write() {},
|
||||
},
|
||||
};
|
||||
sessions.set("ssh-1", session);
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "ssh-1" });
|
||||
|
||||
assert.equal(session._interruptOutputGate?.active, true);
|
||||
});
|
||||
|
||||
test("ordinary SSH input still disarms a pending interrupt output drain", () => {
|
||||
const sessions = new Map();
|
||||
const session = {
|
||||
discardPendingData() {},
|
||||
flowState: {
|
||||
rendererPaused: false,
|
||||
unackedBytes: 119,
|
||||
appliedPause: false,
|
||||
},
|
||||
stream: {
|
||||
resume() {},
|
||||
signal() {},
|
||||
write() {},
|
||||
},
|
||||
};
|
||||
sessions.set("ssh-1", session);
|
||||
initBridge(sessions);
|
||||
|
||||
session._interruptOutputGate = { active: true };
|
||||
assert.equal(session._interruptOutputGate?.active, true);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "l" });
|
||||
|
||||
assert.equal(session._interruptOutputGate?.active, false);
|
||||
});
|
||||
|
||||
test("SSH Ctrl+C does not use channel signals even when the stream exposes them", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("ssh-1", {
|
||||
stream: {
|
||||
signal(signalName) {
|
||||
calls.push(["signal", signalName]);
|
||||
throw new Error("signals unsupported");
|
||||
},
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "\x03" });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
["write", "\x03"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("SSH ordinary input is written without sending INT", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("ssh-1", {
|
||||
stream: {
|
||||
signal(signalName) {
|
||||
calls.push(["signal", signalName]);
|
||||
},
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "ssh-1", data: "cat\r" });
|
||||
|
||||
assert.deepEqual(calls, [["write", "cat\r"]]);
|
||||
});
|
||||
|
||||
test("local Ctrl+C behavior is unchanged", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("local-1", {
|
||||
type: "local",
|
||||
proc: {
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "local-1", data: "\x03" });
|
||||
|
||||
assert.deepEqual(calls, [["write", "\x03"]]);
|
||||
});
|
||||
|
||||
test("local interruptSession writes Ctrl+C without fastpath", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("local-1", {
|
||||
type: "local",
|
||||
takePendingData() {
|
||||
calls.push(["take-pending"]);
|
||||
return "pending";
|
||||
},
|
||||
proc: {
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "local-1" });
|
||||
|
||||
assert.deepEqual(calls, [["write", "\x03"]]);
|
||||
});
|
||||
|
||||
test("telnet Ctrl+C behavior is unchanged", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("telnet-1", {
|
||||
type: "telnet-native",
|
||||
socket: {
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "telnet-1", data: "\x03" });
|
||||
|
||||
assert.deepEqual(calls, [["write", "\x03"]]);
|
||||
});
|
||||
|
||||
test("telnet interruptSession writes Ctrl+C without fastpath", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("telnet-1", {
|
||||
type: "telnet-native",
|
||||
takePendingData() {
|
||||
calls.push(["take-pending"]);
|
||||
return "pending";
|
||||
},
|
||||
socket: {
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "telnet-1" });
|
||||
|
||||
assert.deepEqual(calls, [["write", "\x03"]]);
|
||||
});
|
||||
|
||||
test("serial Ctrl+C behavior is unchanged", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("serial-1", {
|
||||
type: "serial",
|
||||
serialPort: {
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "serial-1", data: "\x03" });
|
||||
|
||||
assert.deepEqual(calls, [["write", "\x03"]]);
|
||||
});
|
||||
|
||||
test("serial interruptSession writes Ctrl+C without fastpath", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("serial-1", {
|
||||
type: "serial",
|
||||
takePendingData() {
|
||||
calls.push(["take-pending"]);
|
||||
return "pending";
|
||||
},
|
||||
serialPort: {
|
||||
write(data) {
|
||||
calls.push(["write", data]);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.interruptSession({ sender: {} }, { sessionId: "serial-1" });
|
||||
|
||||
assert.deepEqual(calls, [["write", "\x03"]]);
|
||||
});
|
||||
|
||||
test("automated multi-line input is written one line at a time", async () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("telnet-1", {
|
||||
type: "telnet-native",
|
||||
socket: {
|
||||
write(data) {
|
||||
calls.push(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.writeToSession(
|
||||
{ sender: {} },
|
||||
{
|
||||
sessionId: "telnet-1",
|
||||
data: "tthdf 0 2323\nadmin\ntest123\n\r",
|
||||
automated: true,
|
||||
lineDelayMs: 5,
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["tthdf 0 2323\r\n"]);
|
||||
await delay(30);
|
||||
assert.deepEqual(calls, ["tthdf 0 2323\r\n", "admin\r\n", "test123\r\n", "\r\n"]);
|
||||
});
|
||||
|
||||
test("manual input cancels pending automated lines", async () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("telnet-1", {
|
||||
type: "telnet-native",
|
||||
socket: {
|
||||
write(data) {
|
||||
calls.push(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.writeToSession(
|
||||
{ sender: {} },
|
||||
{
|
||||
sessionId: "telnet-1",
|
||||
data: "first\nsecond\nthird\r",
|
||||
automated: true,
|
||||
lineDelayMs: 20,
|
||||
},
|
||||
);
|
||||
terminalBridge.writeToSession({ sender: {} }, { sessionId: "telnet-1", data: "\x03" });
|
||||
|
||||
await delay(60);
|
||||
assert.deepEqual(calls, ["first\r\n", "\x03"]);
|
||||
});
|
||||
|
||||
test("closing a paused SSH session does not resume the output flood first", () => {
|
||||
const calls = [];
|
||||
const sessions = new Map();
|
||||
sessions.set("ssh-1", {
|
||||
stream: {
|
||||
pause() {
|
||||
calls.push("pause");
|
||||
},
|
||||
resume() {
|
||||
calls.push("resume");
|
||||
},
|
||||
close() {
|
||||
calls.push("close");
|
||||
},
|
||||
},
|
||||
});
|
||||
initBridge(sessions);
|
||||
|
||||
terminalBridge.setSessionFlowPaused({ sender: {} }, { sessionId: "ssh-1", paused: true });
|
||||
terminalBridge.closeSession({ sender: {} }, { sessionId: "ssh-1" });
|
||||
|
||||
assert.deepEqual(calls, ["pause", "close"]);
|
||||
});
|
||||
Reference in New Issue
Block a user