mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-09 15:58:34 +00:00
476e441057
* 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>
139 lines
5.7 KiB
TypeScript
139 lines
5.7 KiB
TypeScript
import { createRequire } from "node:module";
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import terminalFlowConstantsJson from "../../../infrastructure/config/terminalFlowConstants.json";
|
|
import {
|
|
FLOW_CHAR_COUNT_ACK_SIZE,
|
|
FLOW_HIGH_WATER_MARK,
|
|
FLOW_LOW_WATER_MARK,
|
|
MAX_PENDING_WRITE_COALESCE_BYTES,
|
|
MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD,
|
|
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
|
|
MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES,
|
|
MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES,
|
|
TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS,
|
|
TERMINAL_LONG_LINE_PRESSURE_BYTES,
|
|
XTERM_WRITE_CALLBACK_BATCH_BYTES,
|
|
XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES,
|
|
} from "./terminalFlowConstants.ts";
|
|
import { createOutputFlowController } from "./outputFlowController.ts";
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const sharedConstantsCjs = require("../../../infrastructure/config/terminalFlowConstants.cjs") as typeof terminalFlowConstantsJson;
|
|
|
|
test("renderer flow constants match shared terminalFlowConstants.json", () => {
|
|
assert.equal(FLOW_HIGH_WATER_MARK, terminalFlowConstantsJson.FLOW_HIGH_WATER_MARK);
|
|
assert.equal(FLOW_LOW_WATER_MARK, terminalFlowConstantsJson.FLOW_LOW_WATER_MARK);
|
|
assert.equal(FLOW_CHAR_COUNT_ACK_SIZE, terminalFlowConstantsJson.FLOW_CHAR_COUNT_ACK_SIZE);
|
|
assert.equal(
|
|
MAX_PENDING_WRITE_COALESCE_BYTES,
|
|
terminalFlowConstantsJson.MAX_PENDING_WRITE_COALESCE_BYTES,
|
|
);
|
|
assert.equal(
|
|
MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD,
|
|
terminalFlowConstantsJson.MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD,
|
|
);
|
|
assert.equal(
|
|
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
|
|
terminalFlowConstantsJson.MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
|
|
);
|
|
assert.equal(
|
|
MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES,
|
|
terminalFlowConstantsJson.MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES,
|
|
);
|
|
assert.equal(
|
|
MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES,
|
|
terminalFlowConstantsJson.MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES,
|
|
);
|
|
assert.equal(
|
|
TERMINAL_LONG_LINE_PRESSURE_BYTES,
|
|
terminalFlowConstantsJson.TERMINAL_LONG_LINE_PRESSURE_BYTES,
|
|
);
|
|
assert.equal(
|
|
TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS,
|
|
terminalFlowConstantsJson.TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS,
|
|
);
|
|
assert.equal(
|
|
XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES,
|
|
terminalFlowConstantsJson.XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES,
|
|
);
|
|
assert.equal(
|
|
XTERM_WRITE_CALLBACK_BATCH_BYTES,
|
|
terminalFlowConstantsJson.XTERM_WRITE_CALLBACK_BATCH_BYTES,
|
|
);
|
|
assert.deepEqual(sharedConstantsCjs, terminalFlowConstantsJson);
|
|
assert.ok(FLOW_CHAR_COUNT_ACK_SIZE <= FLOW_LOW_WATER_MARK);
|
|
assert.ok(MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD < MAX_PENDING_WRITE_COALESCE_BYTES);
|
|
});
|
|
|
|
test("terminal flood limits keep interactive acks responsive", () => {
|
|
assert.ok(FLOW_LOW_WATER_MARK <= 8 * 1024);
|
|
assert.ok(FLOW_CHAR_COUNT_ACK_SIZE <= 4 * 1024);
|
|
assert.ok(MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD <= 8 * 1024);
|
|
assert.ok(MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES <= 16 * 1024);
|
|
assert.ok(MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES <= 4 * 1024);
|
|
assert.ok(MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES <= FLOW_HIGH_WATER_MARK);
|
|
assert.ok(TERMINAL_LONG_LINE_PRESSURE_BYTES >= MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES);
|
|
assert.ok(TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS >= TERMINAL_LONG_LINE_PRESSURE_BYTES);
|
|
assert.ok(XTERM_WRITE_CALLBACK_BATCH_BYTES <= FLOW_HIGH_WATER_MARK);
|
|
});
|
|
|
|
test("terminal flow allows a large TUI repaint before applying back-pressure", () => {
|
|
const events: string[] = [];
|
|
const controller = createOutputFlowController({
|
|
highWaterMark: FLOW_HIGH_WATER_MARK,
|
|
lowWaterMark: FLOW_LOW_WATER_MARK,
|
|
onPause: () => events.push("pause"),
|
|
onResume: () => events.push("resume"),
|
|
});
|
|
|
|
const tuiFrameBytes = 80 * 1024;
|
|
const chunkBytes = 4 * 1024;
|
|
for (let received = 0; received < tuiFrameBytes; received += chunkBytes) {
|
|
controller.received(chunkBytes);
|
|
}
|
|
|
|
assert.deepEqual(events, []);
|
|
});
|
|
|
|
test("flow high-water mark stays clear of the ssh2 channel window (issue #1961)", () => {
|
|
// Pausing the source stream for SSH means calling ssh2 channel pause(),
|
|
// which stops the remote from sending until resume() + a full round-trip.
|
|
// ssh2's own channel window is 2MB (WINDOW_THRESHOLD 1MB), so a small
|
|
// high-water mark makes Netcatty pause/resume dozens of times during a
|
|
// multi-MB dump (e.g. `tail -2000f big.log`). Each cycle costs ~1 RTT, so
|
|
// on a WAN link the dump crawls (reported ~20s vs ~2s in other clients).
|
|
// Keep the high-water mark near the ssh2 window so bulk output flows in a
|
|
// handful of pause cycles instead of dozens.
|
|
const SSH2_CHANNEL_WINDOW_THRESHOLD_BYTES = 1024 * 1024;
|
|
assert.ok(
|
|
FLOW_HIGH_WATER_MARK >= SSH2_CHANNEL_WINDOW_THRESHOLD_BYTES,
|
|
`FLOW_HIGH_WATER_MARK (${FLOW_HIGH_WATER_MARK}) should be at least the ssh2 window threshold (${SSH2_CHANNEL_WINDOW_THRESHOLD_BYTES})`,
|
|
);
|
|
|
|
// A 4MB bulk dump should trigger only a few pause cycles, not dozens.
|
|
const events: string[] = [];
|
|
const controller = createOutputFlowController({
|
|
highWaterMark: FLOW_HIGH_WATER_MARK,
|
|
lowWaterMark: FLOW_LOW_WATER_MARK,
|
|
onPause: () => events.push("pause"),
|
|
onResume: () => events.push("resume"),
|
|
});
|
|
const totalBytes = 4 * 1024 * 1024;
|
|
const chunkBytes = 32 * 1024; // ssh2 PACKET_SIZE
|
|
let delivered = 0;
|
|
let pauses = 0;
|
|
for (let sent = 0; sent < totalBytes; sent += chunkBytes) {
|
|
controller.received(chunkBytes);
|
|
if (controller.isPaused()) {
|
|
pauses += 1;
|
|
// Renderer catches up and drains the backlog before the source resumes.
|
|
controller.written(controller.pendingBytes());
|
|
delivered = sent + chunkBytes;
|
|
}
|
|
}
|
|
controller.written(totalBytes - delivered);
|
|
assert.ok(pauses <= 8, `expected few pause cycles for a 4MB dump, got ${pauses}`);
|
|
});
|