Files
NetMesh/electron/bridges/ptyOutputBuffer.test.cjs

691 lines
19 KiB
JavaScript
Raw Permalink Normal View History

const test = require("node:test");
const assert = require("node:assert/strict");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
/** Resolve after one event-loop turn (immediates have run). */
const tick = () => new Promise((resolve) => setImmediate(resolve));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const waitFor = async (predicate, timeoutMs = 100) => {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() > deadline) {
throw new Error("Timed out waiting for condition");
}
await sleep(1);
}
};
const withFakeOutputScheduler = (run) => {
const originalSetImmediate = global.setImmediate;
const originalClearImmediate = global.clearImmediate;
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
const originalDateNow = Date.now;
const immediates = [];
const timers = [];
let now = 0;
global.setImmediate = (callback) => {
const handle = { callback, cleared: false };
immediates.push(handle);
return handle;
};
global.clearImmediate = (handle) => {
if (handle) handle.cleared = true;
};
global.setTimeout = (callback, ms) => {
const handle = { callback, ms, dueAt: now + ms, cleared: false };
timers.push(handle);
return handle;
};
global.clearTimeout = (handle) => {
if (handle) handle.cleared = true;
};
Date.now = () => now;
try {
return run({
immediates,
timers,
setNow(value) { now = value; },
advance(ms) {
now += ms;
for (const timer of timers) {
if (!timer.cleared && timer.dueAt <= now) {
timer.cleared = true;
timer.callback();
}
}
},
});
} finally {
global.setImmediate = originalSetImmediate;
global.clearImmediate = originalClearImmediate;
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
Date.now = originalDateNow;
}
};
test("coalesces data buffered within the same turn into a single send", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("a");
buffer.bufferData("b");
buffer.bufferData("c");
// Nothing is sent synchronously while still in the same turn.
assert.equal(sends.length, 0);
await tick();
assert.deepEqual(sends, ["abc"]);
});
test("flushes within a single event-loop turn (not on a fixed delay)", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("x");
// A fixed-interval (e.g. 8ms) buffer would NOT have flushed after one
// immediate turn. Turn-based flushing must have delivered it by now.
await tick();
assert.deepEqual(sends, ["x"]);
});
test("coalesces network-dribbled bursts after forwarding the first chunk immediately", () => {
const sends = [];
withFakeOutputScheduler(({ immediates, timers, setNow }) => {
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("Reading database ... 1%");
immediates.shift().callback();
assert.deepEqual(sends, ["Reading database ... 1%"]);
setNow(1);
buffer.bufferData("\rReading database ... 2%");
setNow(2);
buffer.bufferData("\rReading database ... 3%");
assert.deepEqual(sends, ["Reading database ... 1%"]);
assert.equal(timers.length, 1);
assert.ok(timers[0].ms > 0 && timers[0].ms <= 4);
timers[0].callback();
assert.deepEqual(sends, [
"Reading database ... 1%",
"\rReading database ... 2%\rReading database ... 3%",
]);
setNow(20);
buffer.bufferData("prompt after idle");
assert.equal(immediates.length, 1);
immediates.shift().callback();
assert.equal(sends.at(-1), "prompt after idle");
});
});
test("cuts IPC sends for a 1ms network-dribbled apt-style burst", () => {
let sends = 0;
let output = "";
withFakeOutputScheduler(({ immediates, advance }) => {
const buffer = createPtyOutputBuffer((data) => {
sends += 1;
output += data;
});
const chunks = Array.from({ length: 1031 }, (_, index) => (
index % 2 === 0
? `\rReading database ... ${index % 101}%`
: `Preparing package-${index} for upgrade\n`
));
for (const chunk of chunks) {
buffer.bufferData(chunk);
for (const immediate of immediates.splice(0)) {
if (!immediate.cleared) immediate.callback();
}
advance(1);
}
buffer.flush();
assert.equal(output, chunks.join(""));
assert.ok(sends <= 350, `expected at most 350 IPC sends, got ${sends}`);
});
});
test("paces size-cap flushes with a short flood delay", () => {
const originalSetTimeout = global.setTimeout;
const originalClearTimeout = global.clearTimeout;
const timers = [];
global.setTimeout = (callback, ms) => {
const timer = { callback, ms, cleared: false };
timers.push(timer);
return timer;
};
global.clearTimeout = (timer) => {
if (timer) timer.cleared = true;
};
const sends = [];
try {
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
floodFlushDelayMs: 5,
});
buffer.bufferData("ab");
assert.equal(sends.length, 0); // under cap, still pending
buffer.bufferData("cd"); // now "abcd" hits the 4-byte cap
// Flood-sized output is paced instead of synchronously spamming IPC.
assert.deepEqual(sends, []);
assert.equal(timers.length, 1);
assert.equal(timers[0].ms, 5);
timers[0].callback();
assert.deepEqual(sends, ["abcd"]);
} finally {
global.setTimeout = originalSetTimeout;
global.clearTimeout = originalClearTimeout;
}
});
test("hard cap starts a paced drain instead of flushing a whole burst", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
floodFlushDelayMs: 1,
});
buffer.bufferData("1234567890abcdefgh");
assert.deepEqual(sends, ["12345678"]);
await waitFor(() => sends.length >= 3);
assert.deepEqual(sends, ["12345678", "90abcdef", "gh"]);
});
test("single large accepted append obeys the total pending cap", async () => {
const sends = [];
const reports = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
maxPendingBytes: 16,
floodFlushDelayMs: 1,
onPendingBytesChange: (bytes) => reports.push(bytes),
});
buffer.bufferData("0123456789abcdefghij");
assert.equal(reports.at(-1), 12);
assert.deepEqual(sends, ["4567"]);
await waitFor(() => sends.join("") === "456789abcdefghij");
assert.deepEqual(sends, ["4567", "89abcdef", "ghij"]);
});
test("default hard cap keeps flood-sized renderer sends bounded", () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
floodFlushDelayMs: 50,
});
const hardCap = 768 * 1024;
const payload = `${"x".repeat(hardCap)}y`;
buffer.bufferData(payload);
assert.deepEqual(sends.map((send) => send.length), [hardCap]);
});
test("flush() forces a synchronous send and cancels the pending turn", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("hello");
buffer.flush();
assert.deepEqual(sends, ["hello"]);
await tick();
assert.deepEqual(sends, ["hello"]); // not sent twice
});
test("flush() with an empty buffer does not send", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.flush();
assert.equal(sends.length, 0);
});
test("discard() drops pending data and cancels the pending turn", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("tail");
assert.equal(buffer.discard(), 4);
assert.deepEqual(sends, []);
await tick();
assert.deepEqual(sends, []);
});
test("flushPaced callback clears paused backlog without materializing it", async () => {
const sends = [];
let drained = false;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
shouldAcceptOutput: () => false,
});
buffer.bufferData("1234567890abcdefgh");
buffer.flushPaced(() => {
drained = true;
});
assert.equal(buffer.discard(), 0);
await sleep(5);
assert.deepEqual(sends, []);
assert.equal(drained, true);
});
test("takePending() returns pending data without sending it", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("tail");
assert.equal(buffer.takePending(), "tail");
assert.deepEqual(sends, []);
await tick();
assert.deepEqual(sends, []);
});
test("takePendingEntry() returns pending data with metadata", () => {
const sends = [];
let accept = false;
const buffer = createPtyOutputBuffer((data, meta) => sends.push({ data, meta }), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
maxPendingBytes: 8,
shouldAcceptOutput: () => accept,
});
buffer.bufferData(`\x1b[?1049h${"x".repeat(16)}tail`);
const pending = buffer.takePendingEntry();
assert.equal(pending.data.length, 8);
assert.deepEqual(pending.meta, {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "enter",
});
assert.deepEqual(sends, []);
});
test("new unknown dropped-output risk clears stale alternate-screen action metadata", () => {
const buffer = createPtyOutputBuffer(() => {});
buffer.bufferData("first", {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "leave",
});
buffer.bufferData("second", {
droppedOutputMayAffectTerminalState: true,
});
assert.deepEqual(buffer.takePendingEntry().meta, {
droppedOutputMayAffectTerminalState: true,
});
});
test("buffers incoming data while shouldAcceptOutput returns false", async () => {
const sends = [];
let accept = true;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
shouldAcceptOutput: () => accept,
});
buffer.bufferData("before");
accept = false;
buffer.bufferData("buffered");
await tick();
assert.deepEqual(sends, []);
accept = true;
buffer.flush();
assert.deepEqual(sends, ["beforebuffered"]);
});
test("flushes data buffered while output is not accepted in bounded chunks", async () => {
const sends = [];
let accept = false;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
shouldAcceptOutput: () => accept,
});
buffer.bufferData("1234");
buffer.bufferData("5678");
buffer.bufferData("90ab");
await tick();
assert.deepEqual(sends, []);
accept = true;
buffer.flush();
assert.deepEqual(sends, ["12345678", "90ab"]);
});
test("paused backlog keeps only the newest data after the total pending cap", async () => {
const sends = [];
const reports = [];
let accept = false;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
maxPendingBytes: 16,
shouldAcceptOutput: () => accept,
onPendingBytesChange: (bytes) => reports.push(bytes),
});
buffer.bufferData("0123456789abcdefghij");
await tick();
assert.equal(reports.at(-1), 16);
assert.deepEqual(sends, []);
accept = true;
buffer.flushPaced();
await waitFor(() => sends.join("") === "456789abcdefghij");
assert.deepEqual(sends, ["4567", "89abcdef", "ghij"]);
});
test("flushPaced releases paused backlog over multiple turns", async () => {
const sends = [];
let accept = false;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
floodFlushDelayMs: 1,
shouldAcceptOutput: () => accept,
});
buffer.bufferData("1234567890abcdefgh");
await tick();
assert.deepEqual(sends, []);
accept = true;
buffer.flushPaced();
assert.deepEqual(sends, ["12345678"]);
await waitFor(() => sends.length >= 3);
assert.deepEqual(sends, ["12345678", "90abcdef", "gh"]);
});
test("fresh output does not cancel an active paced backlog drain", async () => {
const sends = [];
let accept = false;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
floodFlushDelayMs: 1,
shouldAcceptOutput: () => accept,
});
buffer.bufferData("1234567890abcdefgh");
await tick();
accept = true;
buffer.flushPaced();
assert.deepEqual(sends, ["12345678"]);
buffer.bufferData("NEW");
assert.deepEqual(sends, ["12345678"]);
await waitFor(() => sends.length >= 3);
assert.deepEqual(sends, ["12345678", "90abcdef", "ghNEW"]);
});
test("fresh output during an active paced drain obeys the total pending cap", async () => {
const sends = [];
const reports = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
maxPendingBytes: 16,
floodFlushDelayMs: 5,
onPendingBytesChange: (bytes) => reports.push(bytes),
});
buffer.bufferData("0123456789abcdefghij");
assert.deepEqual(sends, ["4567"]);
buffer.bufferData("K".repeat(20));
assert.equal(reports.at(-1), 16);
assert.deepEqual(sends, ["4567"]);
await waitFor(() => sends.join("") === `4567${"K".repeat(16)}`);
assert.deepEqual(sends, ["4567", "KKKKKKKK", "KKKKKKKK"]);
});
test("flushPaced callback runs after the paced backlog drains", async () => {
const sends = [];
let drained = false;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
floodFlushDelayMs: 1,
});
buffer.bufferData("1234567890abcdefgh");
buffer.flushPaced(() => {
drained = true;
});
assert.equal(drained, false);
assert.deepEqual(sends, ["12345678", "90abcdef"]);
await waitFor(() => drained);
assert.deepEqual(sends, ["12345678", "90abcdef", "gh"]);
});
test("flushPaced callback runs when output is paused by discarding pending data", async () => {
const sends = [];
const reports = [];
let drained = false;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
shouldAcceptOutput: () => false,
onPendingBytesChange: (bytes) => reports.push(bytes),
});
buffer.bufferData("1234567890abcdefgh");
buffer.flushPaced(() => {
drained = true;
});
assert.equal(drained, true);
assert.deepEqual(sends, []);
assert.equal(reports.at(-1), 0);
});
test("flushPaced callback runs if output pauses during a paced drain", async () => {
const sends = [];
const reports = [];
let accept = false;
let drained = false;
const buffer = createPtyOutputBuffer((data) => {
sends.push(data);
accept = false;
}, {
maxBufferSize: 4,
maxFloodBufferSize: 8,
floodFlushDelayMs: 1,
shouldAcceptOutput: () => accept,
onPendingBytesChange: (bytes) => reports.push(bytes),
});
buffer.bufferData("1234567890abcdefgh");
accept = true;
buffer.flushPaced(() => {
drained = true;
});
assert.equal(drained, false);
assert.deepEqual(sends, ["12345678"]);
await waitFor(() => drained);
assert.deepEqual(sends, ["12345678"]);
assert.equal(reports.at(-1), 0);
assert.equal(buffer.discard(), 0);
});
test("keeps a single paused append bounded before output is accepted", async () => {
const sends = [];
let accept = false;
const buffer = createPtyOutputBuffer((data) => sends.push(data), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
shouldAcceptOutput: () => accept,
});
buffer.bufferData("1234567890abcdefgh");
await tick();
assert.deepEqual(sends, []);
accept = true;
buffer.flush();
assert.deepEqual(sends, ["12345678", "90abcdef", "gh"]);
});
test("keeps batching after a flush", async () => {
const sends = [];
const buffer = createPtyOutputBuffer((data) => sends.push(data));
buffer.bufferData("first");
await tick();
buffer.bufferData("second");
await waitFor(() => sends.length === 2);
assert.deepEqual(sends, ["first", "second"]);
});
test("pending cap marks next send when dropped output may affect terminal state", () => {
const sends = [];
let accept = false;
const buffer = createPtyOutputBuffer((data, meta) => sends.push({ data, meta }), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
maxPendingBytes: 8,
shouldAcceptOutput: () => accept,
});
buffer.bufferData(`\x1b[?1049h${"x".repeat(16)}tail`);
assert.deepEqual(sends, []);
accept = true;
buffer.flush();
assert.equal(sends[0].meta?.droppedOutputMayAffectTerminalState, true);
assert.equal(sends[0].meta?.droppedOutputAlternateScreenAction, "enter");
});
test("pending cap does not mark plain dropped output as terminal state risk", () => {
const sends = [];
let accept = false;
const buffer = createPtyOutputBuffer((data, meta) => sends.push({ data, meta }), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
maxPendingBytes: 8,
shouldAcceptOutput: () => accept,
});
buffer.bufferData("abcdefghijklmnopqrstuv");
accept = true;
buffer.flush();
assert.equal(sends[0].meta, undefined);
});
test("pending cap forwards dropped alternate-screen leave as recovery state", () => {
const sends = [];
let accept = false;
const buffer = createPtyOutputBuffer((data, meta) => sends.push({ data, meta }), {
maxBufferSize: 4,
maxFloodBufferSize: 8,
maxPendingBytes: 8,
shouldAcceptOutput: () => accept,
});
buffer.bufferData(`\x1b[?1049ltail${"x".repeat(16)}`);
accept = true;
buffer.flush();
assert.deepEqual(sends[0].meta, {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "leave",
});
});
test("pending cap reports alternate-screen action split across dropped and retained bytes", () => {
let accept = false;
const buffer = createPtyOutputBuffer(() => {}, {
maxBufferSize: 1,
maxFloodBufferSize: 1,
maxPendingBytes: "lREADY".length,
shouldAcceptOutput: () => accept,
});
buffer.bufferData("\x1b[?1049lREADY");
const pending = buffer.takePendingEntry();
assert.equal(pending.data, "lREADY");
assert.deepEqual(pending.meta, {
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: "leave",
});
});
test("pending cap does not mark retained alternate-screen leave as already dropped", () => {
const sends = [];
let accept = false;
const retained = "\x1b[?1049ltail";
const buffer = createPtyOutputBuffer((data, meta) => sends.push({ data, meta }), {
maxBufferSize: retained.length,
maxFloodBufferSize: retained.length,
maxPendingBytes: retained.length,
shouldAcceptOutput: () => accept,
});
buffer.bufferData(`${"x".repeat(16)}${retained}`);
accept = true;
buffer.flush();
assert.deepEqual(sends[0].meta, {
droppedOutputMayAffectTerminalState: true,
});
});