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
233 lines
7.3 KiB
JavaScript
233 lines
7.3 KiB
JavaScript
"use strict";
|
|
|
|
const {
|
|
FLOW_HIGH_WATER_MARK,
|
|
FLOW_LOW_WATER_MARK,
|
|
} = require("../../infrastructure/config/terminalFlowConstants.cjs");
|
|
const { logTerminalOutputPerf } = require("./terminalPerformanceDiagnostics.cjs");
|
|
|
|
function getFlowTarget(session) {
|
|
return session?.stream || session?.proc || session?.socket || session?.serialPort || null;
|
|
}
|
|
|
|
function ensureFlowState(session) {
|
|
if (!session.flowState) {
|
|
session.flowState = {
|
|
rendererPaused: false,
|
|
unackedBytes: 0,
|
|
bufferedBytes: 0,
|
|
appliedPause: false,
|
|
outputPaused: false,
|
|
firstUnackedAt: 0,
|
|
lastEmittedAt: 0,
|
|
lastAckAt: 0,
|
|
lastPerfAckLogAt: 0,
|
|
sessionId: null,
|
|
};
|
|
}
|
|
const state = session.flowState;
|
|
state.rendererPaused = Boolean(state.rendererPaused);
|
|
state.unackedBytes = Number.isFinite(state.unackedBytes) ? Math.max(0, state.unackedBytes) : 0;
|
|
state.bufferedBytes = Number.isFinite(state.bufferedBytes) ? Math.max(0, state.bufferedBytes) : 0;
|
|
state.appliedPause = Boolean(state.appliedPause);
|
|
state.firstUnackedAt = Number.isFinite(state.firstUnackedAt) ? Math.max(0, state.firstUnackedAt) : 0;
|
|
state.lastEmittedAt = Number.isFinite(state.lastEmittedAt) ? Math.max(0, state.lastEmittedAt) : 0;
|
|
state.lastAckAt = Number.isFinite(state.lastAckAt) ? Math.max(0, state.lastAckAt) : 0;
|
|
state.lastPerfAckLogAt = Number.isFinite(state.lastPerfAckLogAt) ? Math.max(0, state.lastPerfAckLogAt) : 0;
|
|
state.sessionId = typeof state.sessionId === "string" && state.sessionId ? state.sessionId : null;
|
|
if (typeof state.outputPaused !== "boolean") {
|
|
state.outputPaused = state.appliedPause && (state.rendererPaused || state.unackedBytes >= FLOW_HIGH_WATER_MARK);
|
|
}
|
|
return session.flowState;
|
|
}
|
|
|
|
function applyPause(session, target) {
|
|
try {
|
|
target.pause?.();
|
|
} catch (err) {
|
|
if (err?.code !== "EPIPE" && err?.code !== "ERR_STREAM_DESTROYED") {
|
|
console.warn("Flow control pause failed", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyResume(session, target) {
|
|
try {
|
|
target.resume?.();
|
|
} catch (err) {
|
|
if (err?.code !== "EPIPE" && err?.code !== "ERR_STREAM_DESTROYED") {
|
|
console.warn("Flow control resume failed", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getFlowPerfDetails(session, extra = {}) {
|
|
const state = ensureFlowState(session);
|
|
return {
|
|
sessionId: state.sessionId || session?.id || session?.sessionId || null,
|
|
protocol: session?.protocol || session?.type || null,
|
|
rendererPaused: state.rendererPaused,
|
|
outputPaused: state.outputPaused,
|
|
appliedPause: state.appliedPause,
|
|
unackedBytes: state.unackedBytes,
|
|
bufferedBytes: state.bufferedBytes,
|
|
highWaterMark: FLOW_HIGH_WATER_MARK,
|
|
lowWaterMark: FLOW_LOW_WATER_MARK,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function rememberSessionId(state, sessionId) {
|
|
if (typeof sessionId === "string" && sessionId) {
|
|
state.sessionId = sessionId;
|
|
}
|
|
}
|
|
|
|
// NOTE on FLOW_HIGH_WATER_MARK size (issue #1961): for SSH sessions the flow
|
|
// target is the ssh2 channel, so pause() stops the remote until resume() plus a
|
|
// full round-trip. The watermark is kept near ssh2's own 2MB channel window
|
|
// (WINDOW_THRESHOLD 1MB); a small watermark pauses/resumes dozens of times
|
|
// during a multi-MB dump (e.g. `tail -2000f big.log`) and adds ~1 RTT per cycle
|
|
// on WAN links, which is what made those dumps crawl.
|
|
function reconcileSessionFlow(session) {
|
|
if (!session) return;
|
|
const state = ensureFlowState(session);
|
|
const target = getFlowTarget(session);
|
|
if (!target) return;
|
|
|
|
if (!state.outputPaused && (state.rendererPaused || state.unackedBytes >= FLOW_HIGH_WATER_MARK)) {
|
|
state.outputPaused = true;
|
|
} else if (state.outputPaused && !state.rendererPaused && state.unackedBytes <= FLOW_LOW_WATER_MARK) {
|
|
state.outputPaused = false;
|
|
}
|
|
|
|
const pendingBytes = state.unackedBytes + state.bufferedBytes;
|
|
const shouldPause = state.outputPaused || pendingBytes >= FLOW_HIGH_WATER_MARK;
|
|
const shouldResume = !state.outputPaused && pendingBytes <= FLOW_LOW_WATER_MARK;
|
|
|
|
if (!state.appliedPause && shouldPause) {
|
|
logTerminalOutputPerf("backend-flow-pause", getFlowPerfDetails(session, { pendingBytes }));
|
|
applyPause(session, target);
|
|
state.appliedPause = true;
|
|
return;
|
|
}
|
|
|
|
if (state.appliedPause && shouldResume) {
|
|
logTerminalOutputPerf("backend-flow-resume", getFlowPerfDetails(session, { pendingBytes }));
|
|
applyResume(session, target);
|
|
state.appliedPause = false;
|
|
}
|
|
}
|
|
|
|
function setRendererFlowPaused(session, paused) {
|
|
if (!session) return;
|
|
const state = ensureFlowState(session);
|
|
state.rendererPaused = Boolean(paused);
|
|
reconcileSessionFlow(session);
|
|
}
|
|
|
|
function trackEmitted(session, bytes, sessionId) {
|
|
if (!session || !Number.isFinite(bytes) || bytes <= 0) return;
|
|
const state = ensureFlowState(session);
|
|
rememberSessionId(state, sessionId);
|
|
const now = Date.now();
|
|
if (state.unackedBytes === 0) {
|
|
state.firstUnackedAt = now;
|
|
}
|
|
state.lastEmittedAt = now;
|
|
state.unackedBytes += bytes;
|
|
reconcileSessionFlow(session);
|
|
}
|
|
|
|
function trackAck(session, bytes, sessionId) {
|
|
if (!session || !Number.isFinite(bytes) || bytes <= 0) return;
|
|
const state = ensureFlowState(session);
|
|
rememberSessionId(state, sessionId);
|
|
const now = Date.now();
|
|
const unackedBefore = state.unackedBytes;
|
|
state.unackedBytes = Math.max(0, state.unackedBytes - bytes);
|
|
state.lastAckAt = now;
|
|
const ackAgeMs = state.firstUnackedAt ? now - state.firstUnackedAt : 0;
|
|
if (
|
|
unackedBefore > 0
|
|
&& (
|
|
now - state.lastPerfAckLogAt >= 1000
|
|
|| (
|
|
state.unackedBytes === 0
|
|
&& (ackAgeMs >= 250 || unackedBefore >= 64 * 1024)
|
|
)
|
|
)
|
|
) {
|
|
state.lastPerfAckLogAt = now;
|
|
logTerminalOutputPerf("backend-flow-ack", getFlowPerfDetails(session, {
|
|
bytes,
|
|
unackedBefore,
|
|
unackedAfter: state.unackedBytes,
|
|
ackAgeMs,
|
|
sinceLastEmitMs: state.lastEmittedAt ? now - state.lastEmittedAt : null,
|
|
}));
|
|
}
|
|
if (state.unackedBytes === 0) {
|
|
state.firstUnackedAt = 0;
|
|
}
|
|
reconcileSessionFlow(session);
|
|
}
|
|
|
|
function setBufferedOutputBytes(session, bytes) {
|
|
if (!session) return;
|
|
const state = ensureFlowState(session);
|
|
state.bufferedBytes = Number.isFinite(bytes) ? Math.max(0, bytes) : 0;
|
|
reconcileSessionFlow(session);
|
|
}
|
|
|
|
function shouldAcceptSessionOutput(session) {
|
|
if (!session) return true;
|
|
const state = ensureFlowState(session);
|
|
return !state.outputPaused;
|
|
}
|
|
|
|
function isTransferSentryActive(transferSentry) {
|
|
try {
|
|
return Boolean(transferSentry?.isActive?.());
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function shouldProcessSessionOutput(session, transferSentry) {
|
|
return Boolean(session) || isTransferSentryActive(transferSentry);
|
|
}
|
|
|
|
function clearSessionFlowState(session, options = {}) {
|
|
if (!session?.flowState) return;
|
|
const target = getFlowTarget(session);
|
|
if (session.flowState.appliedPause && target && options.resume !== false) {
|
|
applyResume(session, target);
|
|
}
|
|
session.flowState = {
|
|
rendererPaused: false,
|
|
unackedBytes: 0,
|
|
bufferedBytes: 0,
|
|
appliedPause: false,
|
|
outputPaused: false,
|
|
firstUnackedAt: 0,
|
|
lastEmittedAt: 0,
|
|
lastAckAt: 0,
|
|
lastPerfAckLogAt: 0,
|
|
sessionId: null,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
FLOW_HIGH_WATER_MARK,
|
|
FLOW_LOW_WATER_MARK,
|
|
setRendererFlowPaused,
|
|
setBufferedOutputBytes,
|
|
trackEmitted,
|
|
trackAck,
|
|
shouldAcceptSessionOutput,
|
|
shouldProcessSessionOutput,
|
|
clearSessionFlowState,
|
|
reconcileSessionFlow,
|
|
};
|