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
521 lines
17 KiB
JavaScript
521 lines
17 KiB
JavaScript
"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 };
|