1044 lines
35 KiB
TypeScript
1044 lines
35 KiB
TypeScript
|
|
import type { Terminal as XTerm } from "@xterm/xterm";
|
|||
|
|
|
|||
|
|
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
|
|||
|
|
import { FLOW_LOW_WATER_MARK } from "./terminalFlowConstants";
|
|||
|
|
import type { OutputFlowController } from "./outputFlowController";
|
|||
|
|
import {
|
|||
|
|
abortTerminalWriteCoalescer,
|
|||
|
|
resetTerminalWriteCoalescer,
|
|||
|
|
} from "./terminalWriteCoalescer";
|
|||
|
|
import {
|
|||
|
|
clearDeferredTerminalWriteAck,
|
|||
|
|
getDeferredTerminalWriteAckBytes,
|
|||
|
|
} from "./terminalWriteAckDeferral";
|
|||
|
|
import {
|
|||
|
|
abortTerminalWriteQueue,
|
|||
|
|
getTerminalWriteQueueDepth,
|
|||
|
|
} from "./terminalWriteQueue";
|
|||
|
|
import {
|
|||
|
|
ackTerminalSessionFlow,
|
|||
|
|
clearTerminalSessionFlowAck,
|
|||
|
|
flushTerminalSessionFlowAck,
|
|||
|
|
} from "./terminalFlowAckBuffer";
|
|||
|
|
|
|||
|
|
type FlowBackend = {
|
|||
|
|
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
|
|||
|
|
ackSessionFlow?: (sessionId: string, bytes: number) => void;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type ResumeScheduler = (callback: () => void) => void;
|
|||
|
|
|
|||
|
|
type TerminalInputPriorityReason = "interrupt" | "input";
|
|||
|
|
|
|||
|
|
export type TerminalInputPriorityOptions = {
|
|||
|
|
reason?: TerminalInputPriorityReason;
|
|||
|
|
drainStaleOutput?: boolean;
|
|||
|
|
now?: number;
|
|||
|
|
quietMs?: number;
|
|||
|
|
promptQuietMs?: number;
|
|||
|
|
maxDrainMs?: number;
|
|||
|
|
promptCandidateBytes?: number;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export type TerminalInterruptDisplayFilterReason =
|
|||
|
|
| "inactive"
|
|||
|
|
| "draining"
|
|||
|
|
| "interrupt-echo"
|
|||
|
|
| "prompt-candidate"
|
|||
|
|
| "prompt-gap"
|
|||
|
|
| "password-prompt"
|
|||
|
|
| "quiet-gap"
|
|||
|
|
| "max-drain";
|
|||
|
|
|
|||
|
|
export type TerminalInterruptDisplayFilterResult = {
|
|||
|
|
accepted: boolean;
|
|||
|
|
data: string;
|
|||
|
|
droppedBytes: number;
|
|||
|
|
acceptedBytes?: number;
|
|||
|
|
reason: TerminalInterruptDisplayFilterReason;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export type TerminalInputPrioritySnapshot = {
|
|||
|
|
sessionId: string | null;
|
|||
|
|
backlogBytes: number;
|
|||
|
|
writeQueueDepth: number;
|
|||
|
|
deferredAckBytes: number;
|
|||
|
|
ackAfterInputBytes: number;
|
|||
|
|
scheduledBackendResume: boolean;
|
|||
|
|
skippedReason?: "missing-session" | "below-threshold";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const scheduleAfterCurrentInput: ResumeScheduler = (callback) => {
|
|||
|
|
setTimeout(callback, 0);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const DEFAULT_INTERRUPT_DISPLAY_QUIET_MS = 240;
|
|||
|
|
const DEFAULT_INTERRUPT_DISPLAY_PROMPT_QUIET_MS = 80;
|
|||
|
|
const DEFAULT_INTERRUPT_DISPLAY_MAX_DRAIN_MS = 1200;
|
|||
|
|
const DEFAULT_INTERRUPT_DISPLAY_PROMPT_CANDIDATE_BYTES = 512;
|
|||
|
|
|
|||
|
|
type TerminalInterruptDisplayGate = {
|
|||
|
|
active: boolean;
|
|||
|
|
startedAt: number;
|
|||
|
|
lastDroppedAt: number;
|
|||
|
|
quietMs: number;
|
|||
|
|
promptQuietMs: number;
|
|||
|
|
maxDrainMs: number;
|
|||
|
|
promptCandidateBytes: number;
|
|||
|
|
droppedBytes: number;
|
|||
|
|
droppedChunks: number;
|
|||
|
|
pendingInterruptCaret: boolean;
|
|||
|
|
pendingDisplayControl: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const TERMINAL_INTERRUPT_DISPLAY_GATE_KEY = Symbol.for("netcatty.terminalInterruptDisplayGate");
|
|||
|
|
const TERMINAL_INTERRUPT_DISPLAY_GATES_KEY = Symbol.for("netcatty.terminalInterruptDisplayGates");
|
|||
|
|
const terminalInterruptDisplayGateStore = globalThis as typeof globalThis & Record<PropertyKey, unknown>;
|
|||
|
|
const terminalInterruptDisplayGates = (
|
|||
|
|
terminalInterruptDisplayGateStore[TERMINAL_INTERRUPT_DISPLAY_GATES_KEY] as
|
|||
|
|
| WeakMap<XTerm, TerminalInterruptDisplayGate>
|
|||
|
|
| undefined
|
|||
|
|
) ?? new WeakMap<XTerm, TerminalInterruptDisplayGate>();
|
|||
|
|
terminalInterruptDisplayGateStore[TERMINAL_INTERRUPT_DISPLAY_GATES_KEY] = terminalInterruptDisplayGates;
|
|||
|
|
|
|||
|
|
const readTerminalInterruptDisplayGate = (
|
|||
|
|
term: XTerm,
|
|||
|
|
): TerminalInterruptDisplayGate | undefined => {
|
|||
|
|
const termStore = term as XTerm & Record<PropertyKey, unknown>;
|
|||
|
|
return (
|
|||
|
|
termStore[TERMINAL_INTERRUPT_DISPLAY_GATE_KEY] as TerminalInterruptDisplayGate | undefined
|
|||
|
|
) ?? terminalInterruptDisplayGates.get(term);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const writeTerminalInterruptDisplayGate = (
|
|||
|
|
term: XTerm,
|
|||
|
|
gate: TerminalInterruptDisplayGate,
|
|||
|
|
): void => {
|
|||
|
|
terminalInterruptDisplayGates.set(term, gate);
|
|||
|
|
try {
|
|||
|
|
(term as XTerm & Record<PropertyKey, unknown>)[TERMINAL_INTERRUPT_DISPLAY_GATE_KEY] = gate;
|
|||
|
|
} catch {
|
|||
|
|
// Some test doubles or future terminal objects may be non-extensible.
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const clearTerminalInterruptDisplayGate = (term: XTerm): void => {
|
|||
|
|
terminalInterruptDisplayGates.delete(term);
|
|||
|
|
try {
|
|||
|
|
delete (term as XTerm & Record<PropertyKey, unknown>)[TERMINAL_INTERRUPT_DISPLAY_GATE_KEY];
|
|||
|
|
} catch {
|
|||
|
|
// Best effort only; the WeakMap entry is already gone for this module.
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const nowFromPriorityOptions = (options: Pick<TerminalInputPriorityOptions, "now"> = {}): number =>
|
|||
|
|
Number.isFinite(options.now) ? Number(options.now) : Date.now();
|
|||
|
|
|
|||
|
|
const charLength = (value: string): number => value.length;
|
|||
|
|
|
|||
|
|
const ANSI_ESCAPE = String.fromCharCode(27);
|
|||
|
|
const ANSI_SEQUENCE_PATTERN = new RegExp(`${ANSI_ESCAPE}\\[[0-?]*[ -/]*[@-~]`, "g");
|
|||
|
|
const OSC_SEQUENCE_PATTERN = new RegExp(
|
|||
|
|
`${ANSI_ESCAPE}\\][\\s\\S]*?(?:\\x07|${ANSI_ESCAPE}\\\\)`,
|
|||
|
|
"g",
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const stripAnsi = (value: string): string =>
|
|||
|
|
value
|
|||
|
|
.replace(OSC_SEQUENCE_PATTERN, "")
|
|||
|
|
.replace(ANSI_SEQUENCE_PATTERN, "");
|
|||
|
|
|
|||
|
|
const TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN = new RegExp(
|
|||
|
|
`${ANSI_ESCAPE}\\[[0-?]*[ -/]*[@-~]|${ANSI_ESCAPE}[=>]`,
|
|||
|
|
"g",
|
|||
|
|
);
|
|||
|
|
const RESTORE_PRIVATE_MODE_PARAMS = new Set([
|
|||
|
|
1,
|
|||
|
|
47,
|
|||
|
|
1000,
|
|||
|
|
1002,
|
|||
|
|
1003,
|
|||
|
|
1004,
|
|||
|
|
1005,
|
|||
|
|
1006,
|
|||
|
|
1015,
|
|||
|
|
1047,
|
|||
|
|
1048,
|
|||
|
|
1049,
|
|||
|
|
2004,
|
|||
|
|
]);
|
|||
|
|
const SHOW_CURSOR_PRIVATE_MODE_PARAM = 25;
|
|||
|
|
const PRIVATE_MODE_PATTERN = new RegExp(`^${ANSI_ESCAPE}\\[\\?([0-9;:]*)([hl])$`);
|
|||
|
|
const TRAILING_RESTORE_CONTROL_PREFIX_PATTERN = new RegExp(
|
|||
|
|
`^${ANSI_ESCAPE}\\[\\?[0-9;:]*$`,
|
|||
|
|
);
|
|||
|
|
// Incomplete CSI (params/intermediates, no final byte) — e.g. ESC[0 or ESC[31
|
|||
|
|
const TRAILING_CSI_CONTROL_PREFIX_PATTERN = new RegExp(
|
|||
|
|
`^${ANSI_ESCAPE}\\[[0-?]*[ -/]*$`,
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const getPrivateModeParams = (raw: string): { params: number[]; final: "h" | "l" } | null => {
|
|||
|
|
const match = PRIVATE_MODE_PATTERN.exec(raw);
|
|||
|
|
if (!match) return null;
|
|||
|
|
const params = match[1]!
|
|||
|
|
.split(/[;:]/)
|
|||
|
|
.map((param) => Number(param))
|
|||
|
|
.filter((param) => Number.isFinite(param));
|
|||
|
|
if (params.length === 0) return null;
|
|||
|
|
return { params, final: match[2] as "h" | "l" };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const shouldPreserveTerminalStateRestore = (raw: string): boolean => {
|
|||
|
|
if (raw === "\x1b>") return true;
|
|||
|
|
const privateModes = getPrivateModeParams(raw);
|
|||
|
|
if (!privateModes) return false;
|
|||
|
|
if (privateModes.final === "h") {
|
|||
|
|
return privateModes.params.every((param) => param === SHOW_CURSOR_PRIVATE_MODE_PARAM);
|
|||
|
|
}
|
|||
|
|
return privateModes.params.every((param) => RESTORE_PRIVATE_MODE_PARAMS.has(param));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTrailingRestoreControlPrefix = (text: string): string => {
|
|||
|
|
const escapeIndex = text.lastIndexOf(ANSI_ESCAPE);
|
|||
|
|
if (escapeIndex < 0) return "";
|
|||
|
|
const suffix = text.slice(escapeIndex);
|
|||
|
|
if (suffix === ANSI_ESCAPE) return suffix;
|
|||
|
|
// Hold any incomplete CSI (ESC[ / ESC[0 / ESC[31 / ESC[?1049), not only
|
|||
|
|
// private-mode restore prefixes — styled password prompts can split mid-SGR.
|
|||
|
|
if (TRAILING_CSI_CONTROL_PREFIX_PATTERN.test(suffix)) return suffix;
|
|||
|
|
if (
|
|||
|
|
suffix.startsWith(`${ANSI_ESCAPE}[?`)
|
|||
|
|
&& TRAILING_RESTORE_CONTROL_PREFIX_PATTERN.test(suffix)
|
|||
|
|
) {
|
|||
|
|
return suffix;
|
|||
|
|
}
|
|||
|
|
return "";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTrailingOscControlPrefix = (text: string): string => {
|
|||
|
|
const oscIndex = text.lastIndexOf(`${ANSI_ESCAPE}]`);
|
|||
|
|
if (oscIndex < 0) return "";
|
|||
|
|
const suffix = text.slice(oscIndex);
|
|||
|
|
if (suffix.includes("\x07") || suffix.includes(`${ANSI_ESCAPE}\\`)) return "";
|
|||
|
|
return suffix;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTrailingDisplayControlPrefix = (text: string): string =>
|
|||
|
|
getTrailingRestoreControlPrefix(text) || getTrailingOscControlPrefix(text);
|
|||
|
|
|
|||
|
|
const extractTerminalStateRestoreControls = (
|
|||
|
|
text: string,
|
|||
|
|
options: { holdTrailingPartial?: boolean } = {},
|
|||
|
|
): { preserved: string; pending: string; droppedBytes: number } => {
|
|||
|
|
const pending = options.holdTrailingPartial ? getTrailingDisplayControlPrefix(text) : "";
|
|||
|
|
const searchableText = pending ? text.slice(0, -pending.length) : text;
|
|||
|
|
let preserved = "";
|
|||
|
|
for (const match of searchableText.matchAll(TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN)) {
|
|||
|
|
const raw = match[0];
|
|||
|
|
if (shouldPreserveTerminalStateRestore(raw)) {
|
|||
|
|
preserved += raw;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
preserved,
|
|||
|
|
pending,
|
|||
|
|
droppedBytes: Math.max(0, charLength(text) - charLength(preserved) - charLength(pending)),
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const takePendingDisplayControl = (gate: TerminalInterruptDisplayGate): string => {
|
|||
|
|
const pending = gate.pendingDisplayControl;
|
|||
|
|
gate.pendingDisplayControl = "";
|
|||
|
|
return pending;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const finalizeAcceptedTextAfterPendingDisplayControl = (
|
|||
|
|
pending: string,
|
|||
|
|
text: string,
|
|||
|
|
): { data: string; droppedBytes: number } => {
|
|||
|
|
if (!pending) return { data: text, droppedBytes: 0 };
|
|||
|
|
const combined = `${pending}${text}`;
|
|||
|
|
TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.lastIndex = 0;
|
|||
|
|
const restoreMatch = TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.exec(combined);
|
|||
|
|
TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.lastIndex = 0;
|
|||
|
|
if (restoreMatch?.index === 0 && restoreMatch[0].length > pending.length) {
|
|||
|
|
const raw = restoreMatch[0];
|
|||
|
|
const remainder = combined.slice(raw.length);
|
|||
|
|
if (shouldPreserveTerminalStateRestore(raw)) {
|
|||
|
|
return { data: `${raw}${remainder}`, droppedBytes: 0 };
|
|||
|
|
}
|
|||
|
|
return { data: remainder, droppedBytes: charLength(raw) };
|
|||
|
|
}
|
|||
|
|
OSC_SEQUENCE_PATTERN.lastIndex = 0;
|
|||
|
|
const oscMatch = OSC_SEQUENCE_PATTERN.exec(combined);
|
|||
|
|
OSC_SEQUENCE_PATTERN.lastIndex = 0;
|
|||
|
|
if (oscMatch?.index === 0 && oscMatch[0].length > pending.length) {
|
|||
|
|
return { data: combined, droppedBytes: 0 };
|
|||
|
|
}
|
|||
|
|
// Held password-prompt prefixes (plain or SGR-styled). Keep them when
|
|||
|
|
// quiet/max-drain resumes so a split "Pass"+"word: " is not lost.
|
|||
|
|
if (isProbablePasswordPromptPrefix(pending)) {
|
|||
|
|
return { data: combined, droppedBytes: 0 };
|
|||
|
|
}
|
|||
|
|
return { data: text, droppedBytes: charLength(pending) };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const shouldArmTerminalInterruptDisplayGateForProtocol = (
|
|||
|
|
protocol: string | null | undefined,
|
|||
|
|
): boolean => {
|
|||
|
|
const normalized = String(protocol || "ssh").toLowerCase();
|
|||
|
|
return normalized === "ssh";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isCompletePasswordPrompt = (candidate: string): boolean => {
|
|||
|
|
const trimmed = candidate.trimEnd();
|
|||
|
|
if (!trimmed) return false;
|
|||
|
|
// Align with terminalSudoAutofill's Kylin coverage (#1293): prompts may end
|
|||
|
|
// with 密码/口令 and no colon (e.g. "用户 的密码"). Still require a password
|
|||
|
|
// keyword so ordinary lines like "Password authentication failed" stay out.
|
|||
|
|
return (
|
|||
|
|
/(?:\bpassword\b|密\s*码|口\s*令)/i.test(trimmed)
|
|||
|
|
&& (
|
|||
|
|
/[::]\s*$/.test(trimmed)
|
|||
|
|
|| /\[sudo/i.test(trimmed)
|
|||
|
|
|| /(?:密\s*码|口\s*令)\s*$/.test(trimmed)
|
|||
|
|
|| /^input\s+password\s*$/i.test(trimmed)
|
|||
|
|
|| /^password\s*$/i.test(trimmed)
|
|||
|
|
)
|
|||
|
|
);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isProbablePasswordPromptPrefix = (candidate: string): boolean => {
|
|||
|
|
// Strip SGR/OSC for matching so styled chunks like "\x1b[31mPass" still hold,
|
|||
|
|
// while callers keep the raw pending bytes for display.
|
|||
|
|
let trimmed = stripAnsi(candidate).trimEnd();
|
|||
|
|
// Incomplete CSI/OSC left after stripAnsi (e.g. trailing "\x1b[") must not
|
|||
|
|
// prevent matching a held password prefix.
|
|||
|
|
const trailingControl = getTrailingDisplayControlPrefix(trimmed);
|
|||
|
|
if (trailingControl) {
|
|||
|
|
trimmed = trimmed.slice(0, -trailingControl.length).trimEnd();
|
|||
|
|
}
|
|||
|
|
if (!trimmed || trimmed.length > 160) return false;
|
|||
|
|
if (/[\r\n]/.test(trimmed)) return false;
|
|||
|
|
if (isCompletePasswordPrompt(trimmed)) return false;
|
|||
|
|
|
|||
|
|
const lower = trimmed.toLowerCase();
|
|||
|
|
const prefixTargets = [
|
|||
|
|
"password",
|
|||
|
|
"password:",
|
|||
|
|
"password:",
|
|||
|
|
"[sudo",
|
|||
|
|
"密码",
|
|||
|
|
"密码:",
|
|||
|
|
"口令",
|
|||
|
|
"口令:",
|
|||
|
|
"输入密码",
|
|||
|
|
"输入密码:",
|
|||
|
|
"input password",
|
|||
|
|
"input password:",
|
|||
|
|
];
|
|||
|
|
if (prefixTargets.some((target) => target.startsWith(lower))) return true;
|
|||
|
|
|
|||
|
|
// Allow an unfinished "[sudo…]" tag, or "[sudo…] " + a password-word prefix.
|
|||
|
|
const sudoTag = trimmed.match(/^\[sudo[^\]]*\]?\s*/i);
|
|||
|
|
if (sudoTag) {
|
|||
|
|
const remainder = trimmed.slice(sudoTag[0].length);
|
|||
|
|
if (!remainder) {
|
|||
|
|
if (/^\[sudo(?:[^\]]*)\]?\s*$/i.test(trimmed)) return true;
|
|||
|
|
} else {
|
|||
|
|
const remLower = remainder.toLowerCase();
|
|||
|
|
const remTargets = [
|
|||
|
|
"password",
|
|||
|
|
"password:",
|
|||
|
|
"password:",
|
|||
|
|
"密码",
|
|||
|
|
"密码:",
|
|||
|
|
"口令",
|
|||
|
|
"口令:",
|
|||
|
|
"输入密码",
|
|||
|
|
"输入密码:",
|
|||
|
|
"input password",
|
|||
|
|
"input password:",
|
|||
|
|
];
|
|||
|
|
if (remTargets.some((target) => target.startsWith(remLower))) return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Prompts with leading text split mid-keyword, e.g. `alice@host's pass` +
|
|||
|
|
// `word:` or `用户 的密` + `码`. Hold when a trailing suffix is a real
|
|||
|
|
// password-keyword prefix at a word boundary.
|
|||
|
|
return hasTrailingPasswordKeywordPrefix(trimmed);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const hasTrailingPasswordKeywordPrefix = (trimmed: string): boolean => {
|
|||
|
|
const lower = trimmed.toLowerCase();
|
|||
|
|
const keywordTargets = [
|
|||
|
|
"password",
|
|||
|
|
"password:",
|
|||
|
|
"password:",
|
|||
|
|
"密码",
|
|||
|
|
"密码:",
|
|||
|
|
"口令",
|
|||
|
|
"口令:",
|
|||
|
|
"输入密码",
|
|||
|
|
"输入密码:",
|
|||
|
|
"input password",
|
|||
|
|
"input password:",
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
for (const target of keywordTargets) {
|
|||
|
|
const maxLen = Math.min(lower.length, target.length);
|
|||
|
|
// ASCII keywords: require >= 3 chars ("pas"/"pass") so lone "p"/"pa" mid-line
|
|||
|
|
// noise is not held. CJK keywords can match a single character ("密").
|
|||
|
|
// Ignore punctuation (incl. full-width :) so "password:" stays ASCII minLen.
|
|||
|
|
const keywordBody = target.replace(/[::\s]/g, "");
|
|||
|
|
let isAsciiKeyword = true;
|
|||
|
|
for (let i = 0; i < keywordBody.length; i += 1) {
|
|||
|
|
if (keywordBody.charCodeAt(i) > 0x7f) {
|
|||
|
|
isAsciiKeyword = false;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const minLen = isAsciiKeyword ? 3 : 1;
|
|||
|
|
for (let len = maxLen; len >= minLen; len -= 1) {
|
|||
|
|
const suffix = lower.slice(-len);
|
|||
|
|
if (!target.startsWith(suffix)) continue;
|
|||
|
|
const before = lower.slice(0, -len);
|
|||
|
|
if (before.length === 0) return true;
|
|||
|
|
const prev = before[before.length - 1]!;
|
|||
|
|
if (!/[a-z0-9]/i.test(prev)) return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getTrailingPasswordPromptPrefix = (text: string): string => {
|
|||
|
|
const lastBreak = Math.max(text.lastIndexOf("\n"), text.lastIndexOf("\r"));
|
|||
|
|
const trailing = text.slice(lastBreak + 1);
|
|||
|
|
if (!trailing) return "";
|
|||
|
|
return isProbablePasswordPromptPrefix(trailing) ? trailing : "";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Restore sequences already counted in `preserved` can also sit at the start of
|
|||
|
|
* the trailing password-prefix line (e.g. "stale\n\x1b[?1049lPass", or multiple
|
|||
|
|
* restores "\x1b[?25hstale\n\x1b[?1049lPass"). Strip only complete leading
|
|||
|
|
* restore sequences that are a real suffix of `preserved` — never peel chars
|
|||
|
|
* like the final "l" of "\x1b[?1049l" off "login pass".
|
|||
|
|
*/
|
|||
|
|
const stripLeadingPreservedOverlap = (
|
|||
|
|
passwordPending: string,
|
|||
|
|
preserved: string,
|
|||
|
|
): string => {
|
|||
|
|
let pending = passwordPending;
|
|||
|
|
let keep = preserved;
|
|||
|
|
if (!pending || !keep) return pending;
|
|||
|
|
if (pending.startsWith(keep)) return pending.slice(keep.length);
|
|||
|
|
|
|||
|
|
while (pending && keep) {
|
|||
|
|
TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.lastIndex = 0;
|
|||
|
|
const match = TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.exec(pending);
|
|||
|
|
TERMINAL_STATE_RESTORE_SEQUENCE_PATTERN.lastIndex = 0;
|
|||
|
|
if (!match || match.index !== 0) break;
|
|||
|
|
const seq = match[0];
|
|||
|
|
if (!shouldPreserveTerminalStateRestore(seq) || !keep.endsWith(seq)) break;
|
|||
|
|
pending = pending.slice(seq.length);
|
|||
|
|
keep = keep.slice(0, -seq.length);
|
|||
|
|
}
|
|||
|
|
return pending;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* When discarding a held prefix that ends mid-CSI, also drop the CSI final
|
|||
|
|
* byte(s) from the next chunk so "Pass\x1b[0" + "m$ " does not leak as "m$ ".
|
|||
|
|
*/
|
|||
|
|
const consumeTrailingCsiCompletion = (
|
|||
|
|
pending: string,
|
|||
|
|
text: string,
|
|||
|
|
): { text: string; extraDroppedBytes: number } => {
|
|||
|
|
const control = getTrailingDisplayControlPrefix(pending);
|
|||
|
|
if (!control || !control.startsWith(`${ANSI_ESCAPE}[`)) {
|
|||
|
|
return { text, extraDroppedBytes: 0 };
|
|||
|
|
}
|
|||
|
|
let i = 0;
|
|||
|
|
while (i < text.length) {
|
|||
|
|
const code = text.charCodeAt(i);
|
|||
|
|
// CSI parameter bytes 0–? and intermediate bytes SP–/
|
|||
|
|
if ((code >= 0x30 && code <= 0x3f) || (code >= 0x20 && code <= 0x2f)) {
|
|||
|
|
i += 1;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// CSI final byte @–~
|
|||
|
|
if (code >= 0x40 && code <= 0x7e) {
|
|||
|
|
return { text: text.slice(i + 1), extraDroppedBytes: i + 1 };
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
return { text, extraDroppedBytes: 0 };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isStandaloneHoldableControlPrefix = (pending: string): boolean => {
|
|||
|
|
if (!pending) return false;
|
|||
|
|
if (pending === ANSI_ESCAPE || pending === `${ANSI_ESCAPE}[`) return true;
|
|||
|
|
// Incomplete private-mode restore CSI and OSC title prefixes are safe to hold
|
|||
|
|
// alone; incomplete SGR CSI (ESC[31) is not — it can leak into "$ ".
|
|||
|
|
if (
|
|||
|
|
pending.startsWith(`${ANSI_ESCAPE}[?`)
|
|||
|
|
&& TRAILING_RESTORE_CONTROL_PREFIX_PATTERN.test(pending)
|
|||
|
|
) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
if (pending.startsWith(`${ANSI_ESCAPE}]`)) return true;
|
|||
|
|
return false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const extractDrainHold = (
|
|||
|
|
text: string,
|
|||
|
|
options: { holdTrailingPartial?: boolean } = {},
|
|||
|
|
): { preserved: string; pending: string; droppedBytes: number } => {
|
|||
|
|
const restoreControls = extractTerminalStateRestoreControls(text, options);
|
|||
|
|
if (!options.holdTrailingPartial) {
|
|||
|
|
return restoreControls;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// A styled prompt can split mid-CSI, e.g. "\x1b[31mPass\x1b[" + "0mword: ".
|
|||
|
|
// Keep both the password-prefix body and the trailing control prefix so the
|
|||
|
|
// next chunk can still complete "Password:" (#2010 Codex follow-up).
|
|||
|
|
const controlPending = restoreControls.pending;
|
|||
|
|
const textWithoutControl = controlPending
|
|||
|
|
? text.slice(0, -controlPending.length)
|
|||
|
|
: text;
|
|||
|
|
let passwordPending = getTrailingPasswordPromptPrefix(textWithoutControl);
|
|||
|
|
passwordPending = stripLeadingPreservedOverlap(
|
|||
|
|
passwordPending,
|
|||
|
|
restoreControls.preserved,
|
|||
|
|
);
|
|||
|
|
if (!passwordPending || !isProbablePasswordPromptPrefix(passwordPending)) {
|
|||
|
|
// Incomplete SGR CSI (ESC[31) must not be held alone — otherwise the next
|
|||
|
|
// shell prompt can be accepted as "\x1b[31$ " and leak stale color bytes.
|
|||
|
|
// Restore/OSC prefixes stay held as before.
|
|||
|
|
if (controlPending && !isStandaloneHoldableControlPrefix(controlPending)) {
|
|||
|
|
return {
|
|||
|
|
preserved: restoreControls.preserved,
|
|||
|
|
pending: "",
|
|||
|
|
droppedBytes: Math.max(
|
|||
|
|
0,
|
|||
|
|
charLength(text) - charLength(restoreControls.preserved),
|
|||
|
|
),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return restoreControls;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pending = `${passwordPending}${controlPending}`;
|
|||
|
|
return {
|
|||
|
|
preserved: restoreControls.preserved,
|
|||
|
|
pending,
|
|||
|
|
droppedBytes: Math.max(
|
|||
|
|
0,
|
|||
|
|
charLength(text) - charLength(restoreControls.preserved) - charLength(pending),
|
|||
|
|
),
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const isPasswordPrefixPending = (pending: string): boolean =>
|
|||
|
|
Boolean(pending) && isProbablePasswordPromptPrefix(pending);
|
|||
|
|
|
|||
|
|
const getLastVisibleLine = (text: string): string => {
|
|||
|
|
const normalized = stripAnsi(text).replace(/\r/g, "\n");
|
|||
|
|
const lastLineStart = normalized.lastIndexOf("\n") + 1;
|
|||
|
|
return normalized.slice(lastLineStart).trimEnd();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* A held "Pass" / "[sudo] pass" must only survive when the next chunk continues
|
|||
|
|
* or completes a password prompt. Otherwise discard it before the generic
|
|||
|
|
* shell-prompt matcher can accept junk like "Pass$ " (#2010 Codex follow-up).
|
|||
|
|
*/
|
|||
|
|
const resolveHeldPasswordPrefix = (
|
|||
|
|
pending: string,
|
|||
|
|
text: string,
|
|||
|
|
): { pending: string; text: string; droppedPendingBytes: number } => {
|
|||
|
|
if (!isPasswordPrefixPending(pending)) {
|
|||
|
|
return { pending, text, droppedPendingBytes: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Held prefixes must continue on the same line. A leading line break means
|
|||
|
|
// the next chunk is a fresh line (e.g. "Pass" then "\nPassword: "), not a
|
|||
|
|
// completion of the held prefix — discard so quiet-gap still applies.
|
|||
|
|
if (/^[\r\n]/.test(text)) {
|
|||
|
|
return {
|
|||
|
|
pending: "",
|
|||
|
|
text,
|
|||
|
|
droppedPendingBytes: charLength(pending),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const combined = `${pending}${text}`;
|
|||
|
|
const lastLine = getLastVisibleLine(combined);
|
|||
|
|
if (isCompletePasswordPrompt(lastLine) || isProbablePasswordPromptPrefix(lastLine)) {
|
|||
|
|
return { pending: "", text: combined, droppedPendingBytes: 0 };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const consumed = consumeTrailingCsiCompletion(pending, text);
|
|||
|
|
return {
|
|||
|
|
pending: "",
|
|||
|
|
text: consumed.text,
|
|||
|
|
droppedPendingBytes: charLength(pending) + consumed.extraDroppedBytes,
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const getPromptCandidateSuffix = (text: string): string | null => {
|
|||
|
|
const normalized = stripAnsi(text).replace(/\r/g, "\n");
|
|||
|
|
const lastLineStart = normalized.lastIndexOf("\n") + 1;
|
|||
|
|
const candidate = normalized.slice(lastLineStart).trimEnd();
|
|||
|
|
if (!candidate) return null;
|
|||
|
|
if (candidate.length > 160) return null;
|
|||
|
|
|
|||
|
|
// Password prompts are interactive resume points too. Without this, Ctrl+C
|
|||
|
|
// drain treats "[sudo] password for …:" as stale flood and drops it, so the
|
|||
|
|
// remote waits for a password while the terminal shows nothing (#2010).
|
|||
|
|
const looksLikePrompt = (
|
|||
|
|
isCompletePasswordPrompt(candidate)
|
|||
|
|
|| /^[#$>%]\s*$/.test(candidate)
|
|||
|
|
|| /^[^ \t\r\n<>]{1,80}[#$>%]\s*$/.test(candidate)
|
|||
|
|
|| /^[^\r\n<>]{1,120}[#$>%]\s*$/.test(candidate)
|
|||
|
|
|| /^<[^>\r\n]{1,80}>\s*$/.test(candidate)
|
|||
|
|
|| /^\[[^\]\r\n]{1,120}\]\s*[#$>%]\s*$/.test(candidate)
|
|||
|
|
);
|
|||
|
|
if (!looksLikePrompt) return null;
|
|||
|
|
|
|||
|
|
const rawLastBreak = Math.max(text.lastIndexOf("\n"), text.lastIndexOf("\r"));
|
|||
|
|
return text.slice(rawLastBreak + 1);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const armTerminalInterruptDisplayGate = (
|
|||
|
|
term: XTerm,
|
|||
|
|
options: TerminalInputPriorityOptions = {},
|
|||
|
|
): void => {
|
|||
|
|
writeTerminalInterruptDisplayGate(term, {
|
|||
|
|
active: true,
|
|||
|
|
startedAt: nowFromPriorityOptions(options),
|
|||
|
|
lastDroppedAt: 0,
|
|||
|
|
quietMs: Number.isFinite(options.quietMs)
|
|||
|
|
? Number(options.quietMs)
|
|||
|
|
: DEFAULT_INTERRUPT_DISPLAY_QUIET_MS,
|
|||
|
|
promptQuietMs: Number.isFinite(options.promptQuietMs)
|
|||
|
|
? Number(options.promptQuietMs)
|
|||
|
|
: DEFAULT_INTERRUPT_DISPLAY_PROMPT_QUIET_MS,
|
|||
|
|
maxDrainMs: Number.isFinite(options.maxDrainMs)
|
|||
|
|
? Number(options.maxDrainMs)
|
|||
|
|
: DEFAULT_INTERRUPT_DISPLAY_MAX_DRAIN_MS,
|
|||
|
|
promptCandidateBytes: Number.isFinite(options.promptCandidateBytes)
|
|||
|
|
? Number(options.promptCandidateBytes)
|
|||
|
|
: DEFAULT_INTERRUPT_DISPLAY_PROMPT_CANDIDATE_BYTES,
|
|||
|
|
droppedBytes: 0,
|
|||
|
|
droppedChunks: 0,
|
|||
|
|
pendingInterruptCaret: false,
|
|||
|
|
pendingDisplayControl: "",
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const disarmTerminalInterruptDisplayGate = (term: XTerm): void => {
|
|||
|
|
clearTerminalInterruptDisplayGate(term);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const filterTerminalInterruptDisplayOutput = (
|
|||
|
|
term: XTerm,
|
|||
|
|
data: string,
|
|||
|
|
options: Pick<TerminalInputPriorityOptions, "now"> = {},
|
|||
|
|
): TerminalInterruptDisplayFilterResult => {
|
|||
|
|
const incomingText = String(data || "");
|
|||
|
|
const gate = readTerminalInterruptDisplayGate(term);
|
|||
|
|
if (!gate?.active) {
|
|||
|
|
return { accepted: true, data: incomingText, droppedBytes: 0, reason: "inactive" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const now = nowFromPriorityOptions(options);
|
|||
|
|
const rawPendingDisplayControl = takePendingDisplayControl(gate);
|
|||
|
|
const hadHeldPasswordPrefix = isPasswordPrefixPending(rawPendingDisplayControl);
|
|||
|
|
const resolvedPasswordPrefix = resolveHeldPasswordPrefix(
|
|||
|
|
rawPendingDisplayControl,
|
|||
|
|
incomingText,
|
|||
|
|
);
|
|||
|
|
const prefixDropBytes = resolvedPasswordPrefix.droppedPendingBytes;
|
|||
|
|
// Only treat the held prefix as continued when it was merged into this chunk
|
|||
|
|
// (not discarded across a line break / non-prompt continuation).
|
|||
|
|
const heldPasswordPrefixContinued = (
|
|||
|
|
hadHeldPasswordPrefix
|
|||
|
|
&& prefixDropBytes === 0
|
|||
|
|
&& resolvedPasswordPrefix.pending === ""
|
|||
|
|
);
|
|||
|
|
if (prefixDropBytes > 0) {
|
|||
|
|
gate.droppedBytes += prefixDropBytes;
|
|||
|
|
gate.droppedChunks += 1;
|
|||
|
|
}
|
|||
|
|
const pendingDisplayControl = resolvedPasswordPrefix.pending;
|
|||
|
|
const text = resolvedPasswordPrefix.text;
|
|||
|
|
const combinedText = `${pendingDisplayControl}${text}`;
|
|||
|
|
const bytes = charLength(combinedText);
|
|||
|
|
const quietGapMs = gate.lastDroppedAt > 0 ? now - gate.lastDroppedAt : 0;
|
|||
|
|
const withPrefixDrop = (droppedBytes: number): number => droppedBytes + prefixDropBytes;
|
|||
|
|
|
|||
|
|
if (gate.pendingInterruptCaret) {
|
|||
|
|
gate.pendingInterruptCaret = false;
|
|||
|
|
if (text.startsWith("C")) {
|
|||
|
|
const restoreControls = extractTerminalStateRestoreControls(pendingDisplayControl);
|
|||
|
|
const droppedBytes = restoreControls.droppedBytes;
|
|||
|
|
gate.droppedBytes += droppedBytes;
|
|||
|
|
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
accepted: true,
|
|||
|
|
data: `${restoreControls.preserved}^${text}`,
|
|||
|
|
droppedBytes: withPrefixDrop(droppedBytes),
|
|||
|
|
acceptedBytes: bytes,
|
|||
|
|
reason: "interrupt-echo",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const interruptEchoIndex = combinedText.indexOf("^C");
|
|||
|
|
if (interruptEchoIndex >= 0) {
|
|||
|
|
const droppedPrefix = combinedText.slice(0, interruptEchoIndex);
|
|||
|
|
const restoreControls = extractTerminalStateRestoreControls(droppedPrefix);
|
|||
|
|
const droppedBytes = restoreControls.droppedBytes;
|
|||
|
|
gate.droppedBytes += droppedBytes;
|
|||
|
|
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
accepted: true,
|
|||
|
|
data: `${restoreControls.preserved}${combinedText.slice(interruptEchoIndex)}`,
|
|||
|
|
droppedBytes: withPrefixDrop(droppedBytes),
|
|||
|
|
reason: "interrupt-echo",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Complete password prompts resume immediately — including one-chunk prompts
|
|||
|
|
// before promptQuietMs, held-prefix completions, and last-line prompts that
|
|||
|
|
// arrive after a large stale prefix. Unlike shell prompts, password prompts
|
|||
|
|
// often emit nothing further until the user types; dropping them leaves a
|
|||
|
|
// blank terminal while the remote waits (#2010). Detect the last-line
|
|||
|
|
// password candidate independently of the whole-chunk promptCandidateBytes
|
|||
|
|
// cap used for ordinary shell prompts.
|
|||
|
|
{
|
|||
|
|
const passwordPromptCandidate = getPromptCandidateSuffix(combinedText);
|
|||
|
|
if (
|
|||
|
|
passwordPromptCandidate
|
|||
|
|
&& isCompletePasswordPrompt(stripAnsi(passwordPromptCandidate))
|
|||
|
|
) {
|
|||
|
|
const droppedPrefix = combinedText.slice(
|
|||
|
|
0,
|
|||
|
|
combinedText.length - passwordPromptCandidate.length,
|
|||
|
|
);
|
|||
|
|
const restoreControls = extractTerminalStateRestoreControls(droppedPrefix);
|
|||
|
|
const droppedBytes = restoreControls.droppedBytes;
|
|||
|
|
gate.droppedBytes += droppedBytes;
|
|||
|
|
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
accepted: true,
|
|||
|
|
data: `${restoreControls.preserved}${passwordPromptCandidate}`,
|
|||
|
|
droppedBytes: withPrefixDrop(droppedBytes),
|
|||
|
|
reason: heldPasswordPrefixContinued ? "prompt-gap" : "password-prompt",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const promptCandidate = bytes <= gate.promptCandidateBytes
|
|||
|
|
? getPromptCandidateSuffix(combinedText)
|
|||
|
|
: null;
|
|||
|
|
if (promptCandidate && gate.droppedBytes === 0) {
|
|||
|
|
const droppedPrefix = combinedText.slice(0, combinedText.length - promptCandidate.length);
|
|||
|
|
const restoreControls = extractTerminalStateRestoreControls(droppedPrefix);
|
|||
|
|
const droppedBytes = restoreControls.droppedBytes;
|
|||
|
|
gate.droppedBytes += droppedBytes;
|
|||
|
|
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
accepted: true,
|
|||
|
|
data: `${restoreControls.preserved}${promptCandidate}`,
|
|||
|
|
droppedBytes: withPrefixDrop(droppedBytes),
|
|||
|
|
reason: "prompt-candidate",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (promptCandidate && quietGapMs >= gate.promptQuietMs) {
|
|||
|
|
const droppedPrefix = combinedText.slice(0, combinedText.length - promptCandidate.length);
|
|||
|
|
const restoreControls = extractTerminalStateRestoreControls(droppedPrefix);
|
|||
|
|
const droppedBytes = restoreControls.droppedBytes;
|
|||
|
|
gate.droppedBytes += droppedBytes;
|
|||
|
|
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
accepted: true,
|
|||
|
|
data: `${restoreControls.preserved}${promptCandidate}`,
|
|||
|
|
droppedBytes: withPrefixDrop(droppedBytes),
|
|||
|
|
reason: "prompt-gap",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (quietGapMs >= gate.quietMs) {
|
|||
|
|
const accepted = finalizeAcceptedTextAfterPendingDisplayControl(pendingDisplayControl, text);
|
|||
|
|
gate.droppedBytes += accepted.droppedBytes;
|
|||
|
|
gate.droppedChunks += accepted.droppedBytes > 0 ? 1 : 0;
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
accepted: true,
|
|||
|
|
data: accepted.data,
|
|||
|
|
droppedBytes: withPrefixDrop(accepted.droppedBytes),
|
|||
|
|
reason: "quiet-gap",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (now - gate.startedAt >= gate.maxDrainMs) {
|
|||
|
|
const accepted = finalizeAcceptedTextAfterPendingDisplayControl(pendingDisplayControl, text);
|
|||
|
|
gate.droppedBytes += accepted.droppedBytes;
|
|||
|
|
gate.droppedChunks += accepted.droppedBytes > 0 ? 1 : 0;
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
accepted: true,
|
|||
|
|
data: accepted.data,
|
|||
|
|
droppedBytes: withPrefixDrop(accepted.droppedBytes),
|
|||
|
|
reason: "max-drain",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const restoreControls = extractDrainHold(combinedText, {
|
|||
|
|
holdTrailingPartial: true,
|
|||
|
|
});
|
|||
|
|
const droppedBytes = restoreControls.droppedBytes;
|
|||
|
|
gate.pendingDisplayControl = restoreControls.pending;
|
|||
|
|
gate.pendingInterruptCaret = text.endsWith("^");
|
|||
|
|
gate.lastDroppedAt = now;
|
|||
|
|
gate.droppedBytes += droppedBytes;
|
|||
|
|
gate.droppedChunks += droppedBytes > 0 ? 1 : 0;
|
|||
|
|
if (restoreControls.preserved) {
|
|||
|
|
return {
|
|||
|
|
accepted: true,
|
|||
|
|
data: restoreControls.preserved,
|
|||
|
|
droppedBytes: withPrefixDrop(droppedBytes),
|
|||
|
|
reason: "draining",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
accepted: false,
|
|||
|
|
data: "",
|
|||
|
|
droppedBytes: withPrefixDrop(droppedBytes),
|
|||
|
|
reason: "draining",
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const resolvePrioritizeTerminalInputArgs = (
|
|||
|
|
scheduleResumeOrOptions?: ResumeScheduler | TerminalInputPriorityOptions,
|
|||
|
|
maybeOptions?: TerminalInputPriorityOptions,
|
|||
|
|
): { scheduleResume: ResumeScheduler; options: TerminalInputPriorityOptions } => {
|
|||
|
|
if (typeof scheduleResumeOrOptions === "function") {
|
|||
|
|
return {
|
|||
|
|
scheduleResume: scheduleResumeOrOptions,
|
|||
|
|
options: maybeOptions ?? {},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
scheduleResume: scheduleAfterCurrentInput,
|
|||
|
|
options: scheduleResumeOrOptions ?? maybeOptions ?? {},
|
|||
|
|
};
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const acknowledgeDroppedBytes = (
|
|||
|
|
flow: OutputFlowController | undefined,
|
|||
|
|
bytes: number,
|
|||
|
|
backend: FlowBackend,
|
|||
|
|
sessionId: string | null,
|
|||
|
|
) => {
|
|||
|
|
if (bytes <= 0) return;
|
|||
|
|
flow?.written(bytes);
|
|||
|
|
ackTerminalSessionFlow(backend, sessionId, bytes);
|
|||
|
|
if (sessionId) {
|
|||
|
|
flushTerminalSessionFlowAck(sessionId);
|
|||
|
|
backend.setSessionFlowPaused?.(sessionId, false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const releaseTerminalFlowOutputForTerm = (
|
|||
|
|
term: XTerm,
|
|||
|
|
backend: FlowBackend,
|
|||
|
|
sessionId: string | null,
|
|||
|
|
flow: OutputFlowController | undefined,
|
|||
|
|
options: { resumeBackend?: boolean } = {},
|
|||
|
|
): void => {
|
|||
|
|
const resumeBackend = options.resumeBackend !== false;
|
|||
|
|
const onDropped = (bytes: number) => {
|
|||
|
|
acknowledgeDroppedBytes(flow, bytes, backend, sessionId);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
abortTerminalWriteCoalescer(term, onDropped);
|
|||
|
|
abortTerminalWriteQueue(term, onDropped);
|
|||
|
|
const deferredAck = clearDeferredTerminalWriteAck(term);
|
|||
|
|
if (deferredAck > 0) {
|
|||
|
|
ackTerminalSessionFlow(backend, sessionId, deferredAck);
|
|||
|
|
}
|
|||
|
|
flow?.reset({ resume: resumeBackend });
|
|||
|
|
if (sessionId) {
|
|||
|
|
flushTerminalSessionFlowAck(sessionId);
|
|||
|
|
if (resumeBackend) {
|
|||
|
|
backend.setSessionFlowPaused?.(sessionId, false);
|
|||
|
|
}
|
|||
|
|
clearTerminalSessionFlowAck(sessionId);
|
|||
|
|
}
|
|||
|
|
resetTerminalWriteCoalescer(term);
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const teardownTerminalOutputPipeline = (
|
|||
|
|
ctx: TerminalSessionStartersContext,
|
|||
|
|
term: XTerm,
|
|||
|
|
sessionId: string | null,
|
|||
|
|
flow: OutputFlowController,
|
|||
|
|
): void => {
|
|||
|
|
releaseTerminalFlowOutputForTerm(term, ctx.terminalBackend, sessionId, flow);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const prioritizeTerminalInput = (
|
|||
|
|
term: XTerm,
|
|||
|
|
sessionId: string | null,
|
|||
|
|
flow: OutputFlowController | undefined,
|
|||
|
|
backend: FlowBackend,
|
|||
|
|
scheduleResumeOrOptions?: ResumeScheduler | TerminalInputPriorityOptions,
|
|||
|
|
maybeOptions?: TerminalInputPriorityOptions,
|
|||
|
|
): TerminalInputPrioritySnapshot => {
|
|||
|
|
const { scheduleResume, options } = resolvePrioritizeTerminalInputArgs(
|
|||
|
|
scheduleResumeOrOptions,
|
|||
|
|
maybeOptions,
|
|||
|
|
);
|
|||
|
|
const isInterrupt = options.reason === "interrupt";
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
|
|||
|
|
if (!sessionId) {
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
sessionId,
|
|||
|
|
backlogBytes: 0,
|
|||
|
|
writeQueueDepth: 0,
|
|||
|
|
deferredAckBytes: 0,
|
|||
|
|
ackAfterInputBytes: 0,
|
|||
|
|
scheduledBackendResume: false,
|
|||
|
|
skippedReason: "missing-session",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const backlog = flow?.pendingBytes() ?? 0;
|
|||
|
|
const queueDepth = getTerminalWriteQueueDepth(term);
|
|||
|
|
const deferredAck = getDeferredTerminalWriteAckBytes(term);
|
|||
|
|
|
|||
|
|
if (backlog <= FLOW_LOW_WATER_MARK && queueDepth === 0 && deferredAck === 0) {
|
|||
|
|
disarmTerminalInterruptDisplayGate(term);
|
|||
|
|
return {
|
|||
|
|
sessionId,
|
|||
|
|
backlogBytes: backlog,
|
|||
|
|
writeQueueDepth: queueDepth,
|
|||
|
|
deferredAckBytes: deferredAck,
|
|||
|
|
ackAfterInputBytes: 0,
|
|||
|
|
scheduledBackendResume: false,
|
|||
|
|
skippedReason: "below-threshold",
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hasVisibleBacklog = backlog > FLOW_LOW_WATER_MARK || queueDepth > 0;
|
|||
|
|
if (!hasVisibleBacklog && deferredAck > 0) {
|
|||
|
|
const ackAfterInput = clearDeferredTerminalWriteAck(term);
|
|||
|
|
scheduleResume(() => {
|
|||
|
|
if (ackAfterInput > 0) {
|
|||
|
|
ackTerminalSessionFlow(backend, sessionId, ackAfterInput);
|
|||
|
|
}
|
|||
|
|
flushTerminalSessionFlowAck(sessionId);
|
|||
|
|
backend.setSessionFlowPaused?.(sessionId, false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
sessionId,
|
|||
|
|
backlogBytes: backlog,
|
|||
|
|
writeQueueDepth: queueDepth,
|
|||
|
|
deferredAckBytes: deferredAck,
|
|||
|
|
ackAfterInputBytes: ackAfterInput,
|
|||
|
|
scheduledBackendResume: true,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (hasVisibleBacklog && (!isInterrupt || options.drainStaleOutput !== true)) {
|
|||
|
|
let ackAfterInput = 0;
|
|||
|
|
if (deferredAck > 0) {
|
|||
|
|
ackAfterInput = clearDeferredTerminalWriteAck(term);
|
|||
|
|
scheduleResume(() => {
|
|||
|
|
if (ackAfterInput > 0) {
|
|||
|
|
ackTerminalSessionFlow(backend, sessionId, ackAfterInput);
|
|||
|
|
}
|
|||
|
|
flushTerminalSessionFlowAck(sessionId);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
sessionId,
|
|||
|
|
backlogBytes: backlog,
|
|||
|
|
writeQueueDepth: queueDepth,
|
|||
|
|
deferredAckBytes: deferredAck,
|
|||
|
|
ackAfterInputBytes: ackAfterInput,
|
|||
|
|
scheduledBackendResume: ackAfterInput > 0,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isInterrupt && hasVisibleBacklog && options.drainStaleOutput === true) {
|
|||
|
|
armTerminalInterruptDisplayGate(term, options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isInterrupt) {
|
|||
|
|
const ackAfterInput = clearDeferredTerminalWriteAck(term);
|
|||
|
|
if (ackAfterInput > 0) {
|
|||
|
|
scheduleResume(() => {
|
|||
|
|
ackTerminalSessionFlow(backend, sessionId, ackAfterInput);
|
|||
|
|
flushTerminalSessionFlowAck(sessionId);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
sessionId,
|
|||
|
|
backlogBytes: backlog,
|
|||
|
|
writeQueueDepth: queueDepth,
|
|||
|
|
deferredAckBytes: deferredAck,
|
|||
|
|
ackAfterInputBytes: ackAfterInput,
|
|||
|
|
scheduledBackendResume: ackAfterInput > 0,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let ackAfterInput = 0;
|
|||
|
|
|
|||
|
|
const onDropped = (bytes: number) => {
|
|||
|
|
if (bytes <= 0) return;
|
|||
|
|
ackAfterInput += bytes;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
abortTerminalWriteCoalescer(term, onDropped);
|
|||
|
|
abortTerminalWriteQueue(term, onDropped);
|
|||
|
|
const flushedDeferredAck = clearDeferredTerminalWriteAck(term);
|
|||
|
|
if (flushedDeferredAck > 0) {
|
|||
|
|
ackAfterInput += flushedDeferredAck;
|
|||
|
|
}
|
|||
|
|
flow?.reset({ resume: false });
|
|||
|
|
scheduleResume(() => {
|
|||
|
|
if (ackAfterInput > 0) {
|
|||
|
|
ackTerminalSessionFlow(backend, sessionId, ackAfterInput);
|
|||
|
|
}
|
|||
|
|
flushTerminalSessionFlowAck(sessionId);
|
|||
|
|
backend.setSessionFlowPaused?.(sessionId, false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
sessionId,
|
|||
|
|
backlogBytes: backlog,
|
|||
|
|
writeQueueDepth: queueDepth,
|
|||
|
|
deferredAckBytes: deferredAck,
|
|||
|
|
ackAfterInputBytes: ackAfterInput,
|
|||
|
|
scheduledBackendResume: true,
|
|||
|
|
};
|
|||
|
|
};
|