Files
Netcatty/components/terminal/runtime/terminalSessionAttachment.test.ts
T
陈大猫 476e441057 fix(terminal): raise SSH flow high-water mark to stop WAN pause ping-pong (#1961) (#1968)
* fix(terminal): raise SSH flow high-water mark to stop WAN pause ping-pong (#1961)

Bulk terminal output over SSH (e.g. `tail -2000f big.log`) was far slower
than other clients on high-latency links. The renderer's output flow control
pauses the source stream once its unacked backlog crosses FLOW_HIGH_WATER_MARK
(previously 100KB). For SSH the source is the ssh2 channel, so pause() stops
the remote until resume() plus a full round-trip.

ssh2's own channel window is 2MB (WINDOW_THRESHOLD 1MB), so a 100KB watermark
made Netcatty pause/resume dozens of times during a multi-MB dump — a 4MB dump
triggered ~61 pause cycles, each costing ~1 RTT. At 300ms RTT that turned a
~2s dump into ~21s.

Raise FLOW_HIGH_WATER_MARK to 1MB so it sits near the ssh2 window; the same
4MB dump now settles in ~4 pause cycles (~2-3s at 300ms RTT) while still
bounding renderer memory. FLOW_LOW_WATER_MARK stays at 8KB to keep interactive
acks responsive.

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

* test(terminal): align deferred-ack test with 1MB flow high-water mark

The deferred IPC ack batch threshold (XTERM_WRITE_CALLBACK_BATCH_BYTES)
now sits far below FLOW_HIGH_WATER_MARK, so deferral alone can no longer
accumulate up to the watermark. Assert the batch-flush invariant instead.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:18:27 +08:00

864 lines
28 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
FLOW_HIGH_WATER_MARK,
FLOW_CHAR_COUNT_ACK_SIZE,
FLOW_LOW_WATER_MARK,
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
XTERM_WRITE_CALLBACK_BATCH_BYTES,
} from "./terminalFlowConstants.ts";
import {
attachSessionToTerminal,
getFlowController,
notePendingOutputScrollIfEnabled,
tryAttachSessionToTerminal,
writeSessionData,
} from "./terminalSessionAttachment.ts";
import {
clearTerminalSessionFlowAck,
flushTerminalSessionFlowAck,
} from "./terminalFlowAckBuffer.ts";
import { flushTerminalWriteCoalescer } from "./terminalWriteCoalescer.ts";
import {
clearDeferredTerminalWriteAck,
getDeferredTerminalWriteAckBytes,
} from "./terminalWriteAckDeferral.ts";
import { prioritizeTerminalInput } from "./terminalOutputPipeline";
const createFakeTerm = (activeType = "normal") => {
const writes: string[] = [];
const markerLines: number[] = [];
const disposedMarkerLines: number[] = [];
let cursorLine = 0;
const term = {
buffer: {
active: { type: activeType },
},
write(data: string, callback?: () => void) {
writes.push(data);
for (const char of data) {
if (char === "\n") {
cursorLine += 1;
}
}
callback?.();
},
registerMarker(offset: number) {
const line = cursorLine + offset;
markerLines.push(line);
const marker = {
line,
isDisposed: false,
dispose() {
marker.isDisposed = true;
disposedMarkerLines.push(line);
},
};
return marker;
},
scrollToBottom() {},
} as unknown as XTerm;
return { term, writes, markerLines, disposedMarkerLines };
};
const createContext = (showLineTimestamps: boolean, host: Record<string, unknown> = {}) => ({
host,
terminalSettingsRef: {
current: {
showLineTimestamps,
scrollOnOutput: false,
forcePromptNewLine: false,
},
},
terminalSettings: {
showLineTimestamps,
scrollOnOutput: false,
forcePromptNewLine: false,
},
terminalBackend: {},
sessionRef: { current: "session-1" },
promptLineBreakStateRef: { current: undefined },
});
const withDocumentVisibility = (
visibilityState: "visible" | "hidden",
run: () => void,
options: { hasFocus?: boolean } = {},
) => {
const hasFocus = options.hasFocus ?? visibilityState === "visible";
const original = Object.getOwnPropertyDescriptor(globalThis, "document");
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
visibilityState,
hasFocus: () => hasFocus,
},
});
try {
run();
} finally {
if (original) {
Object.defineProperty(globalThis, "document", original);
} else {
Reflect.deleteProperty(globalThis, "document");
}
}
};
const withAnimationFrameQueue = (run: (frames: Array<FrameRequestCallback>) => void) => {
const originalRequest = Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame");
const originalCancel = Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame");
const frames: Array<FrameRequestCallback> = [];
Object.defineProperty(globalThis, "requestAnimationFrame", {
configurable: true,
value: (callback: FrameRequestCallback) => {
frames.push(callback);
return frames.length;
},
});
Object.defineProperty(globalThis, "cancelAnimationFrame", {
configurable: true,
value: () => {},
});
try {
run(frames);
} finally {
if (originalRequest) {
Object.defineProperty(globalThis, "requestAnimationFrame", originalRequest);
} else {
Reflect.deleteProperty(globalThis, "requestAnimationFrame");
}
if (originalCancel) {
Object.defineProperty(globalThis, "cancelAnimationFrame", originalCancel);
} else {
Reflect.deleteProperty(globalThis, "cancelAnimationFrame");
}
}
};
test("notePendingOutputScrollIfEnabled leaves hidden output unmarked when scroll-on-output is disabled", () => {
const pendingOutputScrollRef = { current: false };
notePendingOutputScrollIfEnabled({
terminalSettingsRef: { current: { scrollOnOutput: false } },
pendingOutputScrollRef,
} as never);
assert.equal(pendingOutputScrollRef.current, false);
});
test("notePendingOutputScrollIfEnabled marks hidden output when scroll-on-output is enabled", () => {
const pendingOutputScrollRef = { current: false };
notePendingOutputScrollIfEnabled({
terminalSettingsRef: { current: { scrollOnOutput: true } },
pendingOutputScrollRef,
} as never);
assert.equal(pendingOutputScrollRef.current, true);
});
test("writeSessionData clears renderer backlog while deferring IPC ack", () => {
const term = {
buffer: { active: { type: "normal" } },
write(_data: string, callback?: () => void) {
callback?.();
},
scrollToBottom() {},
} as unknown as XTerm;
const ctx = createContext(false);
const ingressPerWrite = 100;
const writeCount = Math.floor((XTERM_WRITE_CALLBACK_BATCH_BYTES - 1) / ingressPerWrite);
for (let index = 0; index < writeCount; index += 1) {
writeSessionData(ctx as never, term, "x".repeat(ingressPerWrite));
}
flushTerminalWriteCoalescer(term);
const flow = getFlowController(ctx as never, term);
assert.equal(flow.pendingBytes(), 0);
assert.ok(getDeferredTerminalWriteAckBytes(term) > 0);
clearDeferredTerminalWriteAck(term);
});
test("writeSessionData flushes xterm writes while the page is hidden", () => {
clearTerminalSessionFlowAck("session-1");
const payload = "x".repeat(FLOW_CHAR_COUNT_ACK_SIZE + 1);
const writes: string[] = [];
const pendingCallbacks: Array<() => void> = [];
const writeBuffer = {
flushSync() {
while (pendingCallbacks.length > 0) {
pendingCallbacks.shift()?.();
}
},
};
const term = {
buffer: { active: { type: "normal" } },
_core: { _writeBuffer: writeBuffer },
write(data: string, callback?: () => void) {
writes.push(data);
if (callback) pendingCallbacks.push(callback);
},
scrollToBottom() {},
} as unknown as XTerm;
const acked: number[] = [];
const ctx = {
...createContext(false),
isVisibleRef: { current: true },
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
},
};
withDocumentVisibility("hidden", () => {
writeSessionData(ctx as never, term, payload);
});
flushTerminalSessionFlowAck("session-1");
assert.equal(writes.join(""), payload);
assert.deepEqual(writes.map((write) => write.length), [FLOW_CHAR_COUNT_ACK_SIZE, 1]);
assert.equal(pendingCallbacks.length, 0);
assert.equal(getFlowController(ctx as never, term).pendingBytes(), 0);
assert.equal(getDeferredTerminalWriteAckBytes(term), 0);
assert.equal(acked.reduce((total, bytes) => total + bytes, 0), payload.length);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData flushes xterm writes while the window is unfocused but visible", () => {
clearTerminalSessionFlowAck("session-1");
const payload = "x".repeat(FLOW_CHAR_COUNT_ACK_SIZE + 1);
const writes: string[] = [];
const pendingCallbacks: Array<() => void> = [];
const writeBuffer = {
flushSync() {
while (pendingCallbacks.length > 0) {
pendingCallbacks.shift()?.();
}
},
};
const term = {
buffer: { active: { type: "normal" } },
_core: { _writeBuffer: writeBuffer },
write(data: string, callback?: () => void) {
writes.push(data);
if (callback) pendingCallbacks.push(callback);
},
scrollToBottom() {},
} as unknown as XTerm;
const acked: number[] = [];
const ctx = {
...createContext(false),
isVisibleRef: { current: true },
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
},
};
withDocumentVisibility("visible", () => {
writeSessionData(ctx as never, term, payload);
}, { hasFocus: false });
flushTerminalSessionFlowAck("session-1");
assert.equal(writes.join(""), payload);
assert.equal(pendingCallbacks.length, 0);
assert.equal(getFlowController(ctx as never, term).pendingBytes(), 0);
assert.equal(getDeferredTerminalWriteAckBytes(term), 0);
assert.equal(acked.reduce((total, bytes) => total + bytes, 0), payload.length);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData flushes pending coalesced output with the hidden-page fast path", () => {
clearTerminalSessionFlowAck("session-1");
const pendingPayload = `${Array.from({ length: 20 }, () => "x".repeat(1000)).join("\n")}\n`;
assert.ok(pendingPayload.length > MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES);
const currentPayload = "current\n";
const writes: string[] = [];
const pendingCallbacks: Array<() => void> = [];
const writeBuffer = {
flushSync() {
while (pendingCallbacks.length > 0) {
pendingCallbacks.shift()?.();
}
},
};
const term = {
buffer: { active: { type: "normal" } },
_core: { _writeBuffer: writeBuffer },
write(data: string, callback?: () => void) {
writes.push(data);
if (callback) pendingCallbacks.push(callback);
},
scrollToBottom() {},
} as unknown as XTerm;
const acked: number[] = [];
const ctx = {
...createContext(false),
isVisibleRef: { current: true },
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
},
};
withAnimationFrameQueue((frames) => {
withDocumentVisibility("visible", () => {
writeSessionData(ctx as never, term, pendingPayload);
});
assert.equal(frames.length, 1);
assert.deepEqual(writes, []);
withDocumentVisibility("hidden", () => {
writeSessionData(ctx as never, term, currentPayload);
});
});
flushTerminalSessionFlowAck("session-1");
assert.equal(writes.join(""), `${pendingPayload}${currentPayload}`);
assert.deepEqual(
writes.map((write) => write.length),
[
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
pendingPayload.length - MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
currentPayload.length,
],
);
assert.equal(pendingCallbacks.length, 0);
assert.equal(getFlowController(ctx as never, term).pendingBytes(), 0);
assert.equal(getDeferredTerminalWriteAckBytes(term), 0);
assert.equal(
acked.reduce((total, bytes) => total + bytes, 0),
pendingPayload.length + currentPayload.length,
);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData flushes deferred IPC acks before small output can leave the source paused", async () => {
clearTerminalSessionFlowAck("session-1");
const term = {
buffer: { active: { type: "normal" } },
write(_data: string, callback?: () => void) {
callback?.();
},
scrollToBottom() {},
} as unknown as XTerm;
let mainUnackedBytes = 0;
let mainPaused = false;
const ctx = {
...createContext(false),
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
mainUnackedBytes = Math.max(0, mainUnackedBytes - bytes);
if (mainPaused && mainUnackedBytes <= FLOW_LOW_WATER_MARK) {
mainPaused = false;
}
},
},
};
// Deferred acks flush every time they reach XTERM_WRITE_CALLBACK_BATCH_BYTES,
// which sits far below FLOW_HIGH_WATER_MARK (issue #1961 raised the watermark
// to 1MB), so deferral alone can never push the main process into a pause.
assert.ok(XTERM_WRITE_CALLBACK_BATCH_BYTES < FLOW_HIGH_WATER_MARK);
const chunk = "x".repeat(512);
const chunksPerThresholdFlush = Math.ceil(XTERM_WRITE_CALLBACK_BATCH_BYTES / chunk.length);
const residueChunks = 7;
const writeCount = chunksPerThresholdFlush * 2 + residueChunks;
const expectedDeferredBytes = residueChunks * chunk.length;
assert.ok(expectedDeferredBytes > 0);
assert.ok(expectedDeferredBytes < XTERM_WRITE_CALLBACK_BATCH_BYTES);
for (let index = 0; index < writeCount; index += 1) {
mainUnackedBytes += chunk.length;
if (mainUnackedBytes >= FLOW_HIGH_WATER_MARK) {
mainPaused = true;
}
writeSessionData(ctx as never, term, chunk);
}
flushTerminalWriteCoalescer(term);
assert.equal(mainPaused, false);
assert.equal(mainUnackedBytes, expectedDeferredBytes);
assert.equal(getDeferredTerminalWriteAckBytes(term), expectedDeferredBytes);
await new Promise((resolve) => { setTimeout(resolve, 25); });
assert.equal(mainPaused, false);
assert.equal(mainUnackedBytes, 0);
assert.equal(getDeferredTerminalWriteAckBytes(term), 0);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData acks ingress bytes to match main-process trackEmitted", () => {
clearTerminalSessionFlowAck("session-1");
const { term } = createFakeTerm();
const acked: number[] = [];
const ctx = {
...createContext(false),
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
},
};
writeSessionData(ctx as never, term, "hello");
flushTerminalWriteCoalescer(term);
const deferred = clearDeferredTerminalWriteAck(term);
if (deferred > 0) {
ctx.terminalBackend.ackSessionFlow!("session-1", deferred);
}
flushTerminalSessionFlowAck("session-1");
assert.deepEqual(acked, [5]);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData acks original ingress bytes when display data is expanded", () => {
clearTerminalSessionFlowAck("session-1");
const term = {
buffer: { active: { type: "normal" } },
write(_data: string, callback?: () => void) {
callback?.();
},
scrollToBottom() {},
} as unknown as XTerm;
const acked: number[] = [];
const ctx = {
...createContext(false),
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
},
};
writeSessionData(ctx as never, term, "a\nb", 2);
flushTerminalWriteCoalescer(term);
const deferred = clearDeferredTerminalWriteAck(term);
if (deferred > 0) {
ctx.terminalBackend.ackSessionFlow!("session-1", deferred);
}
flushTerminalSessionFlowAck("session-1");
assert.deepEqual(acked, [2]);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData batches IPC acks using the VS Code ack size", () => {
clearTerminalSessionFlowAck("session-1");
const term = {
buffer: { active: { type: "normal" } },
write(_data: string, callback?: () => void) {
callback?.();
},
scrollToBottom() {},
} as unknown as XTerm;
const acked: number[] = [];
const ctx = {
...createContext(false),
sessionRef: { current: "session-1" },
terminalBackend: {
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
},
};
writeSessionData(ctx as never, term, `${"x".repeat(FLOW_CHAR_COUNT_ACK_SIZE)}\n`);
flushTerminalWriteCoalescer(term);
flushTerminalSessionFlowAck("session-1");
assert.deepEqual(acked, [FLOW_CHAR_COUNT_ACK_SIZE, 1]);
clearTerminalSessionFlowAck("session-1");
});
test("writeSessionData records terminal output timestamps without changing output bytes", () => {
const { term, writes, markerLines } = createFakeTerm();
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "hello\r\nnext");
assert.equal(writes.join(""), "hello\r\nnext");
assert.equal((writes.join("").match(/\[\d{2}:\d{2}:\d{2}\]/g) ?? []).length, 0);
assert.deepEqual(markerLines, [0, 1]);
});
test("writeSessionData keeps timestamp metadata when the host gutter is disabled", () => {
const { term, writes, markerLines } = createFakeTerm();
writeSessionData(createContext(true, { showLineTimestamps: false }) as never, term, "hello");
assert.deepEqual(writes, ["hello"]);
assert.deepEqual(markerLines, [0]);
});
test("writeSessionData records timestamps for hosts with timestamps enabled", () => {
const { term, writes, markerLines } = createFakeTerm();
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "hello");
assert.equal(writes.join(""), "hello");
assert.deepEqual(markerLines, [0]);
});
test("writeSessionData skips timestamps on the alternate screen", () => {
const { term, writes, markerLines } = createFakeTerm("alternate");
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "vim screen");
assert.deepEqual(writes, ["vim screen"]);
assert.deepEqual(markerLines, []);
});
test("writeSessionData does not timestamp output that enters alternate screen in the same chunk", () => {
const { term, writes, markerLines } = createFakeTerm();
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "\x1b[?1049hvim screen");
assert.deepEqual(writes, ["\x1b[?1049hvim screen"]);
assert.deepEqual(markerLines, []);
});
test("writeSessionData resumes timestamps after leaving alternate screen in the same chunk", () => {
const { term, writes, markerLines } = createFakeTerm("alternate");
writeSessionData(createContext(false, { showLineTimestamps: true }) as never, term, "\x1b[?1049lprompt");
assert.equal(writes.join(""), "\x1b[?1049lprompt");
assert.deepEqual(markerLines, [0]);
});
test("writeSessionData inserts erase-scrollback immediately after normal full clear", () => {
const { term, writes } = createFakeTerm();
writeSessionData(createContext(false) as never, term, "\x1b[H\x1b[2Jfresh output");
assert.equal(writes.join(""), "\x1b[H\x1b[2J\x1b[3Jfresh output");
});
test("writeSessionData preserves scrollback after normal full clear when disabled", () => {
const { term, writes } = createFakeTerm();
const ctx = createContext(false);
ctx.terminalSettingsRef.current.clearWipesScrollback = false;
ctx.terminalSettings.clearWipesScrollback = false;
writeSessionData(ctx as never, term, "\x1b[H\x1b[2Jfresh output");
assert.equal(writes.join(""), "\x1b[H\x1b[2Jfresh output");
});
test("writeSessionData does not duplicate existing erase-scrollback after full clear", () => {
const { term, writes } = createFakeTerm();
writeSessionData(createContext(false) as never, term, "\x1b[H\x1b[2J\x1b[3Jfresh output");
assert.equal(writes.join(""), "\x1b[H\x1b[2J\x1b[3Jfresh output");
});
test("writeSessionData does not add erase-scrollback inside synchronized output", () => {
const { term, writes } = createFakeTerm();
writeSessionData(createContext(false) as never, term, "\x1b[?2026h\x1b[H\x1b[2Jframe\x1b[?2026l");
assert.equal(writes.join(""), "\x1b[?2026h\x1b[H\x1b[2Jframe\x1b[?2026l");
});
test("writeSessionData preserves timestamps across host gutter visibility changes", () => {
const { term, writes, markerLines, disposedMarkerLines } = createFakeTerm();
const ctx = createContext(false, { showLineTimestamps: false });
writeSessionData(ctx as never, term, "before\r\n");
ctx.host = { showLineTimestamps: true };
writeSessionData(ctx as never, term, "enabled\r\n");
ctx.host = { showLineTimestamps: false };
writeSessionData(ctx as never, term, "disabled");
assert.equal(writes.join(""), "before\r\nenabled\r\ndisabled");
assert.deepEqual(markerLines, [0, 1, 2]);
assert.deepEqual(disposedMarkerLines, []);
});
test("attachSessionToTerminal resets timestamp state for a reused terminal", () => {
const { term, writes } = createFakeTerm();
const ctx = {
...createContext(false, { showLineTimestamps: true }),
sessionId: "session-1",
sessionRef: { current: null },
hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
terminalBackend: {
onSessionData: () => () => {},
onSessionExit: () => () => {},
},
updateStatus: () => {},
setError: () => {},
onSessionExit: () => {},
};
writeSessionData(ctx as never, term, "unfinished");
attachSessionToTerminal(ctx as never, term, "session-2");
writeSessionData(ctx as never, term, "fresh");
assert.equal(writes.length, 2);
assert.equal(writes[1], "fresh");
});
test("attachSessionToTerminal keeps interrupt-time output visible", () => {
clearTerminalSessionFlowAck("session-1");
const { term, writes } = createFakeTerm();
const acked: number[] = [];
const output: string[] = [];
const logs: string[] = [];
let onData: ((data: string) => void) | null = null;
const ctx = {
...createContext(false),
sessionId: "session-1",
sessionRef: { current: null },
hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
onTerminalOutput: (chunk: string) => output.push(chunk),
onTerminalLogData: (chunk: string) => logs.push(chunk),
terminalBackend: {
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return () => {};
},
onSessionExit: () => () => {},
ackSessionFlow: (_sessionId: string, bytes: number) => {
acked.push(bytes);
},
setSessionFlowPaused: () => {},
},
updateStatus: () => {},
setError: () => {},
onSessionExit: () => {},
};
attachSessionToTerminal(ctx as never, term, "session-1");
const flow = getFlowController(ctx as never, term);
flow.received(FLOW_LOW_WATER_MARK);
prioritizeTerminalInput(
term,
"session-1",
flow,
ctx.terminalBackend,
(callback: () => void) => callback(),
{
reason: "interrupt",
drainStaleOutput: false,
quietMs: 500,
promptQuietMs: 80,
maxDrainMs: 1000,
},
);
onData?.("old output");
flushTerminalWriteCoalescer(term);
assert.equal(writes.join(""), "old output");
assert.equal(output.join(""), "old output");
assert.equal(logs.join(""), "old output");
assert.deepEqual(acked, []);
onData?.("^");
flushTerminalWriteCoalescer(term);
assert.equal(writes.join(""), "old output^");
assert.equal(output.join(""), "old output^");
assert.equal(logs.join(""), "old output^");
assert.deepEqual(acked, []);
onData?.("C\r\n$ ");
flushTerminalWriteCoalescer(term);
flushTerminalSessionFlowAck("session-1");
assert.equal(writes.join(""), "old output^C\r\n$ ");
assert.equal(output.join(""), "old output^C\r\n$ ");
assert.equal(logs.join(""), "old output^C\r\n$ ");
assert.deepEqual(acked, []);
assert.equal(getDeferredTerminalWriteAckBytes(term), 16);
clearDeferredTerminalWriteAck(term);
clearTerminalSessionFlowAck("session-1");
});
test("attachSessionToTerminal hints for sudo password prompts and fills on confirm", () => {
const { term, writes } = createFakeTerm();
const sent: Array<{ id: string; data: string; automated?: boolean }> = [];
const hints: boolean[] = [];
let onData: ((data: string) => void) | null = null;
const sudoAutofillRef = { current: null };
const ctx = {
...createContext(false),
sessionId: "session-1",
sessionRef: { current: null },
hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
sudoAutofillRef,
onSudoHint: (active: boolean) => hints.push(active),
terminalBackend: {
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return () => {};
},
onSessionExit: () => () => {},
writeToSession: (id: string, data: string, options?: { automated?: boolean }) => {
sent.push({ id, data, automated: options?.automated });
},
},
updateStatus: () => {},
setError: () => {},
onSessionExit: () => {},
};
attachSessionToTerminal(ctx as never, term, "session-1", {
sudoAutofillPassword: "secret",
});
sudoAutofillRef.current?.armForCommand("sudo whoami");
onData?.("sudo whoami\r\n");
onData?.("[sudo] password for alice: ");
// Confirm-to-fill model: detecting the prompt raises a hint but never sends
// the password on its own.
assert.deepEqual(hints, [true]);
assert.deepEqual(sent, []);
assert.equal(writes[0], "sudo whoami\r\n");
assert.equal(writes[1], "[sudo] password for alice: ");
// The password is only written once the user confirms (presses Enter).
sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, [{ id: "session-1", data: "secret\n", automated: true }]);
});
test("attachSessionToTerminal does not auto-fill unarmed sudo-looking output", () => {
const { term } = createFakeTerm();
const sent: string[] = [];
let onData: ((data: string) => void) | null = null;
const ctx = {
...createContext(false),
sessionId: "session-1",
sessionRef: { current: null },
hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
sudoAutofillRef: { current: null },
terminalBackend: {
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return () => {};
},
onSessionExit: () => () => {},
writeToSession: (_id: string, data: string) => {
sent.push(data);
},
},
updateStatus: () => {},
setError: () => {},
onSessionExit: () => {},
};
attachSessionToTerminal(ctx as never, term, "session-1", {
sudoAutofillPassword: "secret",
});
onData?.("[sudo] password for alice: ");
assert.deepEqual(sent, []);
});
test("attachSessionToTerminal leaves sudo prompts alone without an autofill password", () => {
const { term } = createFakeTerm();
const sent: string[] = [];
let onData: ((data: string) => void) | null = null;
const ctx = {
...createContext(false),
sessionId: "session-1",
sessionRef: { current: null },
hasConnectedRef: { current: true },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
terminalBackend: {
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return () => {};
},
onSessionExit: () => () => {},
writeToSession: (_id: string, data: string) => {
sent.push(data);
},
},
updateStatus: () => {},
setError: () => {},
onSessionExit: () => {},
};
attachSessionToTerminal(ctx as never, term, "session-1");
onData?.("[sudo] password for alice: ");
assert.deepEqual(sent, []);
});
test("tryAttachSessionToTerminal closes orphan sessions after unmount", () => {
const { term } = createFakeTerm();
const closed: string[] = [];
let dataSubscribed = false;
const ctx = {
...createContext(false),
sessionId: "session-1",
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
isBootActiveRef: { current: false },
terminalBackend: {
closeSession: (id: string) => {
closed.push(id);
},
onSessionData: () => {
dataSubscribed = true;
return () => {};
},
onSessionExit: () => () => {},
},
updateStatus: () => {},
setError: () => {},
onSessionExit: () => {},
};
const attached = tryAttachSessionToTerminal(ctx as never, term, "backend-session");
assert.equal(attached, false);
assert.deepEqual(closed, ["backend-session"]);
assert.equal(dataSubscribed, false);
assert.equal(ctx.sessionRef.current, null);
});