Files
NetMesh/electron/bridges/ptyOutputBuffer.cjs

521 lines
17 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
/**
* Coalescing output buffer for terminal/PTY data on its way to the renderer.
*
* Incoming shell data is accumulated and delivered to `sendFn` in batches to
* keep IPC traffic down, but the batch is flushed on the *next event-loop turn*
* (`setImmediate`) rather than after a fixed time interval. A fixed interval
* adds that whole interval as latency to interactive echo every keystroke
* round-trips through the buffer and waits out the timer before it can paint.
* Turn-based flushing coalesces only the data that has already arrived in the
* current turn, so a single echoed keystroke is forwarded almost immediately
* while bursts of output still collapse into one send.
*
* Once a burst reaches the soft cap, switch to a very short timer. That gives
* urgent control input (Ctrl+C/close) room to run instead of letting a flood
* repeatedly send synchronously. A larger hard cap still flushes immediately so
* buffered sends stay bounded even if a source emits while renderer flow is
* paused.
*
* @param {(data: string) => void} sendFn delivers an accumulated batch
* @param {{
* maxBufferSize?: number,
* shouldAcceptOutput?: () => boolean,
* floodFlushDelayMs?: number,
* burstCoalesceDelayMs?: number,
* burstDetectionWindowMs?: number,
* maxFloodBufferSize?: number,
* maxPendingBytes?: number,
* onPendingBytesChange?: (bytes: number) => void,
* maxDroppedStateScanBytes?: number,
* }} [options]
* @returns {{ bufferData: (data: string, meta?: object) => void, flush: () => void, flushPaced: (onDrained?: () => void) => void, takePending: () => string, takePendingEntry: () => { data: string, meta?: object }, discard: () => number }}
*/
function createPtyOutputBuffer(sendFn, options = {}) {
const maxBufferSize = options.maxBufferSize ?? 128 * 1024;
const maxFloodBufferSize = options.maxFloodBufferSize ?? Math.max(768 * 1024, maxBufferSize);
const defaultMaxPendingBytes = Math.max(maxFloodBufferSize * 4, 8 * 1024 * 1024);
const maxPendingBytes = Math.max(
maxFloodBufferSize,
options.maxPendingBytes ?? defaultMaxPendingBytes,
);
const floodFlushDelayMs = options.floodFlushDelayMs ?? 1;
const burstCoalesceDelayMs = Math.max(0, options.burstCoalesceDelayMs ?? 3);
const burstDetectionWindowMs = Math.max(
burstCoalesceDelayMs,
options.burstDetectionWindowMs ?? 8,
);
const maxDroppedStateScanBytes = Math.max(256, options.maxDroppedStateScanBytes ?? 2048);
const shouldAcceptOutput = options.shouldAcceptOutput ?? (() => true);
const onPendingBytesChange = typeof options.onPendingBytesChange === "function"
? options.onPendingBytesChange
: null;
let dataBuffer = "";
const queuedBuffers = [];
let pendingBytes = 0;
let scheduled = null;
let scheduledType = null;
let lastOutputArrivalAt = null;
let nextSendMeta = null;
const drainCallbacks = [];
const ESC = "\x1b";
const ALT_SCREEN_MODES = new Set([47, 1047, 1049]);
const readCsiSequence = (input, startIndex) => {
if (input[startIndex] !== ESC || input[startIndex + 1] !== "[") return null;
for (let index = startIndex + 2; index < input.length; index += 1) {
const code = input.charCodeAt(index);
if (code >= 0x40 && code <= 0x7e) {
return { sequence: input.slice(startIndex, index + 1), end: index + 1 };
}
}
return null;
};
const getAlternateScreenAction = (sequence) => {
if (!sequence.startsWith("\x1b[") || sequence.length < 3) return null;
const final = sequence.at(-1);
if (final !== "h" && final !== "l") return null;
const params = sequence.slice(2, -1);
if (!params.startsWith("?")) return null;
const modes = params
.slice(1)
.split(";")
.map((part) => Number.parseInt(part, 10))
.filter(Number.isFinite);
if (!modes.some((mode) => ALT_SCREEN_MODES.has(mode))) return null;
return final === "h" ? "enter" : "leave";
};
const inspectAlternateScreenWindow = (text) => {
let mayAffectTerminalState = false;
let finalAction = null;
let hasIncompleteSequence = false;
for (let index = 0; index < text.length; index += 1) {
if (text[index] !== ESC) continue;
const sequence = readCsiSequence(text, index);
if (sequence) {
const action = getAlternateScreenAction(sequence.sequence);
if (action) {
mayAffectTerminalState = true;
finalAction = action;
}
index = sequence.end - 1;
continue;
}
if (text.slice(index).startsWith("\x1b[?")) {
mayAffectTerminalState = true;
hasIncompleteSequence = true;
finalAction = null;
}
}
return { mayAffectTerminalState, finalAction, hasIncompleteSequence };
};
const inspectAlternateScreenSequenceStartedBeforeBoundary = (text, boundary) => {
let mayAffectTerminalState = false;
let finalAction = null;
for (let index = 0; index < Math.min(boundary, text.length); index += 1) {
if (text[index] !== ESC) continue;
const sequence = readCsiSequence(text, index);
if (!sequence) {
if (text.slice(index).startsWith("\x1b[?")) {
mayAffectTerminalState = true;
}
continue;
}
if (sequence.end > boundary) {
const action = getAlternateScreenAction(sequence.sequence);
if (action) {
mayAffectTerminalState = true;
finalAction = action;
}
}
index = sequence.end - 1;
}
return { mayAffectTerminalState, finalAction };
};
const inspectDroppedTerminalState = ({
droppedHead,
droppedTail,
retainedHead,
droppedBytes,
}) => {
if (droppedBytes <= 0) {
return { mayAffectTerminalState: false, finalAlternateScreenAction: undefined };
}
const headInspection = inspectAlternateScreenWindow(droppedHead);
const tailContext = droppedTail === droppedHead ? droppedHead : droppedTail;
const tailInspection = inspectAlternateScreenWindow(tailContext);
const tailWithRetainedInspection = inspectAlternateScreenWindow(`${tailContext}${retainedHead}`);
const splitTailInspection = inspectAlternateScreenSequenceStartedBeforeBoundary(
`${tailContext}${retainedHead}`,
tailContext.length,
);
const sampledBytes = droppedHead.length + (droppedTail === droppedHead ? 0 : droppedTail.length);
const hasUninspectedMiddle = droppedBytes > sampledBytes;
const finalAlternateScreenAction = splitTailInspection.finalAction
|| tailInspection.finalAction
|| (!hasUninspectedMiddle ? headInspection.finalAction : undefined)
|| undefined;
return {
mayAffectTerminalState: Boolean(
headInspection.hasIncompleteSequence
|| tailWithRetainedInspection.hasIncompleteSequence
|| hasUninspectedMiddle
|| headInspection.finalAction
|| tailInspection.finalAction
|| splitTailInspection.mayAffectTerminalState
|| tailWithRetainedInspection.finalAction
),
finalAlternateScreenAction,
};
};
const mergeNextSendMeta = (meta) => {
if (!meta) return;
const nextAction = meta.droppedOutputMayAffectTerminalState
? meta.droppedOutputAlternateScreenAction
: (meta.droppedOutputAlternateScreenAction ?? nextSendMeta?.droppedOutputAlternateScreenAction);
const merged = {
...(nextSendMeta || {}),
...meta,
droppedOutputMayAffectTerminalState: Boolean(
nextSendMeta?.droppedOutputMayAffectTerminalState
|| meta.droppedOutputMayAffectTerminalState,
),
droppedOutputAlternateScreenAction: nextAction,
};
if (!merged.droppedOutputAlternateScreenAction) {
delete merged.droppedOutputAlternateScreenAction;
}
nextSendMeta = merged;
};
const takeNextSendMeta = () => {
const meta = nextSendMeta;
nextSendMeta = null;
return meta || undefined;
};
const sendBufferedChunk = (chunk) => {
sendFn(chunk, takeNextSendMeta());
};
const reportPendingBytes = () => {
try {
onPendingBytesChange?.(pendingBytes);
} catch {}
};
const cancelScheduled = () => {
if (scheduled) {
if (scheduledType === "immediate") {
clearImmediate(scheduled);
} else {
clearTimeout(scheduled);
}
scheduled = null;
scheduledType = null;
}
};
const notifyDrained = () => {
if (hasPendingData()) return;
const callbacks = drainCallbacks.splice(0, drainCallbacks.length);
for (const callback of callbacks) {
try {
callback();
} catch {}
}
};
const readPendingHead = (limit) => {
if (limit <= 0) return "";
let head = "";
for (const chunk of queuedBuffers) {
if (head.length >= limit) break;
head += chunk.slice(0, limit - head.length);
}
if (head.length < limit && dataBuffer.length > 0) {
head += dataBuffer.slice(0, limit - head.length);
}
return head;
};
const dropOldestPendingBytes = (bytesToDrop) => {
let remaining = Math.max(0, bytesToDrop);
let dropped = 0;
let droppedHead = "";
let droppedTail = "";
const recordDropped = (text) => {
if (!text) return;
if (droppedHead.length < maxDroppedStateScanBytes) {
droppedHead += text.slice(0, maxDroppedStateScanBytes - droppedHead.length);
}
droppedTail = `${droppedTail}${text}`.slice(-maxDroppedStateScanBytes);
};
while (remaining > 0 && queuedBuffers.length > 0) {
const chunk = queuedBuffers[0];
if (chunk.length <= remaining) {
queuedBuffers.shift();
remaining -= chunk.length;
dropped += chunk.length;
recordDropped(chunk);
continue;
}
const droppedChunk = chunk.slice(0, remaining);
queuedBuffers[0] = chunk.slice(remaining);
dropped += remaining;
recordDropped(droppedChunk);
remaining = 0;
}
if (remaining > 0 && dataBuffer.length > 0) {
if (dataBuffer.length <= remaining) {
dropped += dataBuffer.length;
remaining -= dataBuffer.length;
recordDropped(dataBuffer);
dataBuffer = "";
} else {
const droppedChunk = dataBuffer.slice(0, remaining);
dataBuffer = dataBuffer.slice(remaining);
dropped += remaining;
recordDropped(droppedChunk);
remaining = 0;
}
}
if (dropped > 0) {
pendingBytes = Math.max(0, pendingBytes - dropped);
}
const terminalState = inspectDroppedTerminalState({
droppedHead,
droppedTail,
retainedHead: readPendingHead(maxDroppedStateScanBytes),
droppedBytes: dropped,
});
return {
dropped,
droppedOutputMayAffectTerminalState: terminalState.mayAffectTerminalState,
droppedOutputAlternateScreenAction: terminalState.finalAlternateScreenAction,
};
};
const enforcePendingByteLimit = () => {
if (pendingBytes <= maxPendingBytes) return;
const dropped = dropOldestPendingBytes(pendingBytes - maxPendingBytes);
if (dropped.dropped > 0) {
if (dropped.droppedOutputMayAffectTerminalState) {
mergeNextSendMeta({
droppedOutputMayAffectTerminalState: true,
droppedOutputAlternateScreenAction: dropped.droppedOutputAlternateScreenAction,
});
}
reportPendingBytes();
}
};
const clearPendingBuffers = () => {
const discardedBytes = pendingBytes;
queuedBuffers.length = 0;
dataBuffer = "";
pendingBytes = 0;
reportPendingBytes();
return discardedBytes;
};
const flushNow = () => {
scheduled = null;
scheduledType = null;
if (!shouldAcceptOutput()) {
return;
}
while (queuedBuffers.length > 0) {
const chunk = queuedBuffers.shift();
sendBufferedChunk(chunk);
pendingBytes = Math.max(0, pendingBytes - chunk.length);
reportPendingBytes();
}
if (dataBuffer.length > 0) {
while (dataBuffer.length > maxFloodBufferSize) {
const chunk = dataBuffer.slice(0, maxFloodBufferSize);
dataBuffer = dataBuffer.slice(maxFloodBufferSize);
sendBufferedChunk(chunk);
pendingBytes = Math.max(0, pendingBytes - chunk.length);
reportPendingBytes();
}
if (dataBuffer.length > 0) {
const pending = dataBuffer;
dataBuffer = "";
sendBufferedChunk(pending);
pendingBytes = Math.max(0, pendingBytes - pending.length);
reportPendingBytes();
}
}
};
const appendBoundedData = (data) => {
pendingBytes += data.length;
let remaining = data;
while (remaining.length > 0) {
const available = maxFloodBufferSize - dataBuffer.length;
if (available <= 0) {
queuedBuffers.push(dataBuffer);
dataBuffer = "";
continue;
}
dataBuffer += remaining.slice(0, available);
remaining = remaining.slice(available);
if (dataBuffer.length >= maxFloodBufferSize && remaining.length > 0) {
queuedBuffers.push(dataBuffer);
dataBuffer = "";
}
}
reportPendingBytes();
enforcePendingByteLimit();
};
const scheduleTurnFlush = () => {
if (scheduled) return;
scheduledType = "immediate";
scheduled = setImmediate(flushNow);
};
const scheduleBurstFlush = () => {
if (scheduled) return;
scheduledType = "burst";
scheduled = setTimeout(flushNow, burstCoalesceDelayMs);
};
const scheduleFloodFlush = () => {
if (scheduledType === "flood") return;
cancelScheduled();
scheduledType = "flood";
scheduled = setTimeout(sendNextPendingChunk, floodFlushDelayMs);
};
const bufferData = (data, meta) => {
const arrivedAt = Date.now();
const followsRecentOutput = data.length > 0
&& lastOutputArrivalAt !== null
&& arrivedAt - lastOutputArrivalAt >= 0
&& arrivedAt - lastOutputArrivalAt <= burstDetectionWindowMs;
if (data.length > 0) lastOutputArrivalAt = arrivedAt;
mergeNextSendMeta(meta);
appendBoundedData(data);
if (!shouldAcceptOutput()) {
return;
}
if (scheduledType === "paced" && hasPendingData()) {
return;
}
if (queuedBuffers.length > 0 || dataBuffer.length >= maxFloodBufferSize) {
cancelScheduled();
sendNextPendingChunk();
} else if (dataBuffer.length >= maxBufferSize) {
scheduleFloodFlush();
} else if (!scheduled) {
// Forward the first output immediately. If more network chunks arrive in
// adjacent turns, hold only the continuation for a couple of milliseconds
// so one remote burst does not fan out into hundreds of IPC messages.
if (followsRecentOutput && burstCoalesceDelayMs > 0) {
scheduleBurstFlush();
} else {
scheduleTurnFlush();
}
}
};
const flush = () => {
lastOutputArrivalAt = null;
cancelScheduled();
flushNow();
};
const hasPendingData = () => queuedBuffers.length > 0 || dataBuffer.length > 0;
const sendNextPendingChunk = () => {
if (!shouldAcceptOutput()) {
scheduled = null;
scheduledType = null;
if (drainCallbacks.length > 0) {
clearPendingBuffers();
notifyDrained();
}
return;
}
if (queuedBuffers.length > 0) {
const chunk = queuedBuffers.shift();
sendBufferedChunk(chunk);
pendingBytes = Math.max(0, pendingBytes - chunk.length);
reportPendingBytes();
} else if (dataBuffer.length > maxFloodBufferSize) {
const chunk = dataBuffer.slice(0, maxFloodBufferSize);
dataBuffer = dataBuffer.slice(maxFloodBufferSize);
sendBufferedChunk(chunk);
pendingBytes = Math.max(0, pendingBytes - chunk.length);
reportPendingBytes();
} else if (dataBuffer.length > 0) {
const pending = dataBuffer;
dataBuffer = "";
sendBufferedChunk(pending);
pendingBytes = Math.max(0, pendingBytes - pending.length);
reportPendingBytes();
}
if (!hasPendingData()) {
scheduled = null;
scheduledType = null;
notifyDrained();
return;
}
scheduledType = "paced";
scheduled = setTimeout(sendNextPendingChunk, floodFlushDelayMs);
};
const flushPaced = (onDrained) => {
if (typeof onDrained === "function") drainCallbacks.push(onDrained);
cancelScheduled();
if (typeof onDrained === "function" && !shouldAcceptOutput()) {
clearPendingBuffers();
notifyDrained();
return;
}
sendNextPendingChunk();
notifyDrained();
};
const takePendingEntry = () => {
lastOutputArrivalAt = null;
cancelScheduled();
const pending = `${queuedBuffers.join("")}${dataBuffer}`;
const meta = takeNextSendMeta();
queuedBuffers.length = 0;
dataBuffer = "";
pendingBytes = 0;
reportPendingBytes();
notifyDrained();
return { data: pending, meta };
};
const takePending = () => {
return takePendingEntry().data;
};
const discard = () => {
lastOutputArrivalAt = null;
cancelScheduled();
const discardedBytes = clearPendingBuffers();
nextSendMeta = null;
drainCallbacks.splice(0, drainCallbacks.length);
return discardedBytes;
};
return { bufferData, flush, flushPaced, takePending, takePendingEntry, discard };
}
module.exports = { createPtyOutputBuffer };