Compare commits

...
5 changed files with 528 additions and 9 deletions

No files matched your search

+18 -1
View File
@@ -34,4 +34,21 @@ function parseLiveShellProbe(output, marker) {
return { kind: null };
}
module.exports = { buildLiveShellProbe, parseLiveShellProbe };
// Deadline fallback for a lost _Q sentinel: salvage the shell name from a
// complete _P line so the fallback wrapper matches the shell the probe
// actually detected instead of the pre-probe kind. Returns "fish", "posix",
// or null for the last complete _P line, or undefined when no _P line
// arrived (the caller then keeps the pre-probe shell kind).
function parsePartialLiveShellProbeKind(output, marker) {
const lines = String(output).replace(/\r/g, "\n").split("\n");
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index];
if (!line.startsWith(`${marker}_P:`)) continue;
const name = line.slice(marker.length + 3).trim().split("/").pop().replace(/^-/, "");
return name === "fish" ? "fish"
: /^(?:ba|da|z|k|a)?sh$/.test(name) ? "posix" : null;
}
return undefined;
}
module.exports = { buildLiveShellProbe, parseLiveShellProbe, parsePartialLiveShellProbeKind };
+94 -1
View File
@@ -1,9 +1,35 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs');
const {
buildLiveShellProbe,
parseLiveShellProbe,
parsePartialLiveShellProbeKind,
} = require('./liveShellProbe.cjs');
const { startPtyJob } = require('./ptyExec.cjs');
test('partial probe parse salvages _P shell names without the _Q sentinel', () => {
const marker = '__NCMCP_probe__';
assert.equal(parsePartialLiveShellProbeKind(`${marker}_P:/usr/bin/fish\n`, marker), 'fish');
assert.equal(parsePartialLiveShellProbeKind(`${marker}_P:sh\n`, marker), 'posix');
assert.equal(parsePartialLiveShellProbeKind(`${marker}_P:python\n${marker}_P:sh\n`, marker), 'posix');
assert.equal(parsePartialLiveShellProbeKind(`${marker}_P:fi`, marker), null);
assert.equal(parsePartialLiveShellProbeKind('unrelated output\n', marker), undefined);
});
test('probe deadline fallback keeps a partial _P shell result when _Q is lost', async () => {
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => writes.push(data);
const job = startPtyJob(pty, 'printf success', { shellKind: 'posix', probeLiveShell: true, timeoutMs: 200 });
pty.emit('data', `${job.marker}_P:fish\n`);
await job.resultPromise;
assert.ok(writes.length >= 2, JSON.stringify(writes));
const wrapper = writes.slice(1).join('');
assert.ok(wrapper.includes('set -l'), wrapper);
assert.ok(!wrapper.includes("=0; printf"), wrapper);
});
test('live shell response excludes echoed commands, stale markers and partial lines', () => {
const marker = '__NCMCP_probe__';
assert.equal(parseLiveShellProbe(buildLiveShellProbe(marker), marker), null);
@@ -30,6 +56,73 @@ test('probe waits for complete reply before choosing the first wrapper', async (
assert.equal((await job.resultPromise).exitCode, 0);
});
test('probe deadline fallback consumes a probe reply buffered in the paused flow', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
pty.write = (data) => {
if (data === '\x03') return;
writes.push(String(data));
};
const job = startPtyJob(pty, 'printf resumed', {
shellKind: 'posix',
probeLiveShell: true,
timeoutMs: 60000,
onInterrupt: () => {
// Resuming the session-owned flow releases the complete probe reply
// that was buffered while the renderer flow was paused.
pty.emit('data', `${job.marker}_P:fish\n${job.marker}_Q`);
},
});
// Drain paced typing of the full probe; the probe deadline is only armed
// once delivery completes.
while (!writes.join('').includes(`${job.marker}_Q'\n`)) t.mock.timers.tick(30);
t.mock.timers.tick(20000);
// The resumed buffered reply must still reach the probe parser and select
// the live shell kind, so the fallback wrapper matches the nested fish.
const wrapper = writes.slice(1).join('');
assert.ok(wrapper.includes('set -l'), wrapper);
assert.ok(!wrapper.includes('=0; printf'), wrapper);
pty.emit('data', `${job.marker}_S\r\nresumed\r\n${job.marker}_E:0\r\n`);
const result = await job.resultPromise;
assert.equal(result.exitCode, 0, JSON.stringify(result));
});
test('probe deadline fallback resumes session flow control before typing the wrapper', async () => {
const pty = new EventEmitter();
const writes = [];
const events = [];
pty.write = (data) => writes.push(data);
const job = startPtyJob(pty, 'printf resumed', {
shellKind: 'posix',
probeLiveShell: true,
timeoutMs: 2000,
onInterrupt: () => events.push('interrupt'),
});
// The probe's _Q sentinel never arrives (renderer flow stays paused), so
// the probe deadline fires the fallback: it must resume the session-owned
// flow (clearSessionFlowState) before typing the wrapper, otherwise the
// buffered start/end markers cannot reach onData and the job times out
// even though the command executed remotely. The deadline is armed only
// after the paced probe delivery completes, then waits min(15s, 75% of
// the budget), so poll instead of sleeping a fixed interval.
for (let i = 0; i < 100 && !events.length; i += 1) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(events.includes('interrupt'), 'fallback must invoke onInterrupt');
// The fallback wrapper is typed with pacing; finishing the job cancels any
// remaining delivery, so wait for the full wrapper before completing it.
for (let i = 0; i < 100 && !writes.join('').includes('printf resumed'); i += 1) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.ok(writes.join('').includes('printf resumed'),
'fallback must type the wrapper after resuming flow');
pty.emit('data', `${job.marker}_S\r\nresumed\r\n${job.marker}_E:0\r\n`);
const result = await job.resultPromise;
assert.equal(result.exitCode, 0, JSON.stringify(result));
assert.equal(events.filter((e) => e === 'interrupt').length, 1);
});
test('probe wrapper keeps the start marker separate when terminal echo is disabled', async () => {
const { spawnSync } = require('node:child_process');
const pty = new EventEmitter();
+261 -6
View File
@@ -28,7 +28,11 @@ const {
} = require("./ptyExecHelpers.cjs");
const { extractTrailingIdlePrompt } = require("./shellUtils.cjs");
const { buildLiveShellProbe, parseLiveShellProbe } = require("./liveShellProbe.cjs");
const {
buildLiveShellProbe,
parseLiveShellProbe,
parsePartialLiveShellProbeKind,
} = require("./liveShellProbe.cjs");
const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024;
const END_MARKER_PROMPT_WAIT_MS = 30000;
@@ -99,6 +103,14 @@ function startPtyJob(ptyStream, command, options) {
let probingShell = usesLiveShellProbe;
let deliveringInput = false;
let probeOutput = "";
// Retained result of the last complete _P line seen while probing. The
// rolling probeOutput buffer caps at 16,384 chars, so a lost _Q sentinel
// followed by a burst of terminal output can evict the _P line before
// finishProbeFallback()'s salvage reparse runs; keeping each parsed kind
// as it arrives preserves the probe's detection across such eviction.
// undefined = no _P line arrived yet (keep the pre-probe shell kind);
// null = a _P line arrived but its shell name is unrecognized.
let partialProbeKind;
let output = "";
let foundStart = false;
@@ -114,6 +126,12 @@ function startPtyJob(ptyStream, command, options) {
let wallTimeoutId = null;
let startupTimeoutId = null;
let promptFallbackTimer = null;
let probeTimeoutId = null;
// Fallback grace timer: after the probe deadline fires, this keeps probe
// parsing active briefly so a complete _P/_Q response buffered in a paused
// renderer flow can still be consumed by onData's probe parser once
// onInterrupt() resumes that flow.
let probeResumeTimeoutId = null;
let endMarkerWaitTimer = null;
let cancelRetryTimerId = null;
// Track one-shot timers scheduled inside requestCancel so finish() can
@@ -183,8 +201,14 @@ function startPtyJob(ptyStream, command, options) {
// model can fall back to terminal_start). Default is off so existing
// foreground execution paths (Catty Agent) keep their inactivity-based
// timeout for long-running streaming commands.
let wallClockArmed = false;
let wallStartMs = 0;
function armWallTimeout() {
if (!enforceWallTimeout || maxBufferedChars > 0) return;
// Remember when the hard deadline started so the probe fallback below can
// measure the remaining wall-clock budget instead of the full one.
wallClockArmed = true;
wallStartMs = Date.now();
wallTimeoutId = setTimeout(() => {
if (finished) return;
if (pendingEnd) {
@@ -224,6 +248,179 @@ function startPtyJob(ptyStream, command, options) {
}
}
// Bounded live-shell probe deadline (issue #3445): if the probe's _Q
// sentinel never arrives (lossy link, exotic shell, paused renderer flow,
// missing trailing newline on the typed probe), probingShell used to stay
// latched forever, so the wrapped command was never typed and the job
// burned the entire command timeout while holding the session lock.
// After a short deadline, fall back to typing the wrapper with the
// pre-probe shell kind so the command still gets delivered.
const PROBE_DEADLINE_MS = 15000;
// After the probe deadline fires, abortProbeToWrapper() resumes a paused
// renderer flow and keeps probe parsing active this long so a complete
// _P/_Q response buffered in the paused stream is consumed by onData's
// probe parser instead of bypassing it. Bounded so a genuinely lost
// sentinel still falls back to typing the wrapper promptly. The effective
// grace is capped at 1/8 of the deadline budget so short-timeout jobs
// still type the fallback wrapper before their output/startup deadline.
const PROBE_RESUME_GRACE_MS = 500;
function probeResumeGraceMs() {
const deadlineBudgetMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
return Math.min(PROBE_RESUME_GRACE_MS, Math.floor(deadlineBudgetMs / 8));
}
function clearProbeTimeout() {
if (probeTimeoutId) {
clearTimeout(probeTimeoutId);
probeTimeoutId = null;
}
}
function clearProbeResumeTimeout() {
if (probeResumeTimeoutId) {
clearTimeout(probeResumeTimeoutId);
probeResumeTimeoutId = null;
}
}
// Mirrors writeInput()'s pacing (128-char batches every 30ms for oversized
// probe/wrapper input) so callers can estimate how long a text delivery
// takes and reserve that time inside the hard wall-clock budget.
const INPUT_PACING_MS = 30;
function estimateInputDeliveryMs(text) {
if (!text) return 0;
const batchChars = (usesLiveShellProbe || bastionKeystrokes) && text.length > 1024 ? 128 : text.length;
return Math.ceil(text.length / batchChars) * INPUT_PACING_MS;
}
function wrappedCommandText(shellKind = resolvedShellKind) {
return `${buildPendingInputClearPrefix(shellKind)}${buildWrappedCommand(command, shellKind, marker, probeLiveShell)}`;
}
// Fall back to typing the wrapper with the pre-probe (or partially
// detected) shell kind. The _Q sentinel can be lost after a complete _P
// shell name already arrived (lossy output); salvage that partial result so
// the fallback wrapper matches the shell the probe actually detected
// instead of the pre-probe kind — otherwise a nested fish inside a POSIX
// session (or vice versa) receives syntactically incompatible wrapper code.
function finishProbeFallback() {
probingShell = false;
// Prefer the kind retained when each _P line first arrived; the buffer
// reparse is only a fallback for chunks that never reached onData.
const partialKind = partialProbeKind !== undefined
? partialProbeKind
: parsePartialLiveShellProbeKind(stripAnsi(probeOutput), marker);
if (partialKind) resolvedShellKind = partialKind;
// When a hard wall-clock deadline is armed, waiting for resumed buffered
// probe data may have consumed part of the budget. If the remaining
// budget cannot fit the wrapper's paced delivery, leave it untyped and
// let the wall timer finish the job with a timeout error (mirrors
// armProbeTimeout()'s "wrapper cannot fit" handling), so no partially
// typed wrapper is cancelled mid-delivery by the wall deadline.
if (wallClockArmed) {
const wrapperDeliveryMs = Math.max(
estimateInputDeliveryMs(wrappedCommandText("posix")),
estimateInputDeliveryMs(wrappedCommandText("fish")),
);
const remainingMs = timeoutMs - (Date.now() - wallStartMs);
if (remainingMs <= wrapperDeliveryMs) return;
}
writeWrappedCommand();
}
function abortProbeToWrapper() {
// A paused renderer flow is one of the reasons the probe's _Q sentinel
// never arrives, so resuming the session-owned flow is part of this
// fallback: without it, buffered probe/start/end markers cannot reach
// onData and the wrapper may execute remotely while the job still times
// out waiting for its start marker. Mirrors sendInterrupt()'s best-effort
// recovery (the supported callers wire onInterrupt to
// clearSessionFlowState). Resume BEFORE leaving probe mode: when flow
// control is why the deadline fired, the complete _P/_Q response may
// already be buffered in the paused stream, and keeping probe parsing
// active lets onData's probe parser consume that released data and pick
// the live shell kind — leaving probe mode first would let the resumed
// response bypass parseLiveShellProbe while the partial salvage only
// sees the pre-resume buffer, typing the wrong wrapper for a nested
// shell of the other kind.
try {
onInterrupt?.();
} catch {
// Best-effort recovery must not prevent the fallback wrapper from
// being typed.
}
clearProbeTimeout();
// Wait briefly for the resumed buffered response to reach onData. If the
// sentinel is genuinely lost (no buffered response), this small grace
// window then selects the fallback wrapper from the partial pre-resume
// buffer. With a hard wall-clock deadline, skip the grace when the
// remaining budget is too tight to afford grace plus the wrapper's
// delivery, so the fallback can still finish typing in time.
let graceMs = probeResumeGraceMs();
if (wallClockArmed) {
const wrapperDeliveryMs = Math.max(
estimateInputDeliveryMs(wrappedCommandText("posix")),
estimateInputDeliveryMs(wrappedCommandText("fish")),
);
const remainingMs = timeoutMs - (Date.now() - wallStartMs);
if (remainingMs <= wrapperDeliveryMs + PROBE_RESUME_GRACE_MS) graceMs = 0;
}
probeResumeTimeoutId = setTimeout(() => {
probeResumeTimeoutId = null;
if (finished || cancelRequested || !probingShell) return;
finishProbeFallback();
}, graceMs);
}
// The output/startup deadlines (timeoutMs for foreground jobs,
// BG_STARTUP_TIMEOUT_MS for background jobs) are armed before the probe
// timer, so with an equal delay they fire first (earlier registration
// wins in the timer queue), call finish(), and clear probeTimeoutId
// before the fallback could type the wrapper — a lost _Q sentinel would
// still prevent the command from ever being sent. Cap the probe window
// at 75% of that budget so the fallback always wins the race with
// headroom; writeWrappedCommand() then re-arms a fresh full budget for
// the wrapped command's delivery and start marker.
// When enforceWallTimeout armed the hard wall-clock deadline (MCP
// terminal_execute), that deadline started before the probe was typed, so
// paced probe delivery already consumed part of the budget. Measure the
// fallback against the remaining wall-clock budget and reserve the
// fallback wrapper's own paced-delivery time inside it — the fallback must
// still have enough wall time to FINISH typing before finish() cancels it,
// not merely to start. abortProbeToWrapper() may salvage a different shell
// kind from a partial _P line (e.g. fish recorded but a nested POSIX shell
// detected), so reserve delivery time for the longest wrapper among the
// kinds the fallback could end up typing — probingShell only runs for
// posix/fish sessions, so that set is exactly {"posix", "fish"}. When the
// remaining budget cannot fit the wrapper at all, skip the probe (or do
// not arm the fallback) so no partially typed wrapper is cancelled
// mid-delivery by the wall deadline.
function armProbeTimeout() {
clearProbeTimeout();
const deadlineBudgetMs = maxBufferedChars > 0 ? BG_STARTUP_TIMEOUT_MS : timeoutMs;
// Reserve the fallback's resume-grace window inside the 75% cap so the
// grace plus the wrapper's paced delivery still finish before the
// output/startup deadline that writeWrappedCommand() re-arms against.
const fallbackReserveMs = probeResumeGraceMs();
let delayMs = Math.min(
PROBE_DEADLINE_MS,
Math.max(0, Math.floor(deadlineBudgetMs * 3 / 4) - fallbackReserveMs),
);
if (wallClockArmed) {
const wrapperDeliveryMs = Math.max(
estimateInputDeliveryMs(wrappedCommandText("posix")),
estimateInputDeliveryMs(wrappedCommandText("fish")),
);
const remainingMs = deadlineBudgetMs - (Date.now() - wallStartMs);
if (remainingMs <= wrapperDeliveryMs) {
// Even an immediate fallback cannot finish typing the wrapper before
// the wall deadline. Leave the wrapper untyped; the hard wall timer
// (registered earlier) will finish the job with a timeout error.
return;
}
delayMs = Math.min(delayMs, remainingMs - wrapperDeliveryMs - fallbackReserveMs);
if (delayMs < 0) delayMs = 0;
}
probeTimeoutId = setTimeout(() => {
probeTimeoutId = null;
if (finished || cancelRequested || !probingShell) return;
abortProbeToWrapper();
}, delayMs);
}
function sendInterrupt() {
try {
if (typeof ptyStream.signal === "function") {
@@ -441,6 +638,8 @@ function startPtyJob(ptyStream, command, options) {
clearTimeout(timeoutId);
clearTimeout(wallTimeoutId);
clearStartupTimeout();
clearProbeTimeout();
clearProbeResumeTimeout();
clearPromptFallback();
clearEndMarkerWait();
clearCancelRetryTimer();
@@ -547,16 +746,36 @@ function startPtyJob(ptyStream, command, options) {
if (!pendingEnd && !deliveringInput) armOutputTimeout();
if (probingShell) {
probeOutput = (probeOutput + text).slice(-16384);
const combinedProbeOutput = probeOutput + text;
// Salvage the shell kind from the combined (pre-truncation) buffer:
// a single data event can carry the complete _P line plus more than
// the 16,384-char cap of subsequent output (lost _Q sentinel), and
// truncating first would evict the _P line before any parser saw it.
const preTruncationKind = parsePartialLiveShellProbeKind(
stripAnsi(combinedProbeOutput),
marker,
);
if (preTruncationKind !== undefined) partialProbeKind = preTruncationKind;
probeOutput = combinedProbeOutput.slice(-16384);
if (cancelRequested && hasExpectedPromptSuffix(probeOutput, expectedPrompt)) {
finish("", -1, "Cancelled");
return;
}
const probe = parseLiveShellProbe(stripAnsi(probeOutput), marker);
const strippedProbeOutput = stripAnsi(probeOutput);
const probe = parseLiveShellProbe(strippedProbeOutput, marker);
if (!probe) return;
probingShell = false;
clearProbeTimeout();
clearProbeResumeTimeout();
probeOutput = "";
if (probe.kind) resolvedShellKind = probe.kind;
// A completed parse with no kind means the _Q sentinel arrived but the
// _P line was evicted from the rolling buffer by subsequent output; the
// retained kind from the _P line is still the probe's detection, so use
// it instead of falling back to the pre-probe shell kind.
const completedKind = probe.kind
?? (partialProbeKind === undefined ? null : partialProbeKind);
partialProbeKind = undefined;
if (completedKind) resolvedShellKind = completedKind;
if (finished || cancelRequested) return;
writeWrappedCommand();
return;
@@ -765,6 +984,7 @@ function startPtyJob(ptyStream, command, options) {
deliveringInput = false;
if (!pendingEnd) armOutputTimeout();
if (!foundStart) armStartupTimeout();
if (probingShell) armProbeTimeout();
}
}
@@ -821,8 +1041,7 @@ function startPtyJob(ptyStream, command, options) {
}
function writeWrappedCommand() {
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${wrapped}`);
writeInput(wrappedCommandText());
}
// Prime the renderer's display suppression before the first byte is typed
@@ -840,6 +1059,42 @@ function startPtyJob(ptyStream, command, options) {
// Display suppression must never prevent the command from starting.
}
}
// With a hard wall-clock deadline (MCP terminal_execute), a short timeoutMs
// may leave room for the wrapper's delivery but not for the probe's
// delivery on top of it. Skip the probe and type the wrapper directly with
// the pre-probe shell kind so the command gets the largest possible share
// of the remaining wall budget; when even the wrapper alone cannot fit,
// keep the probe path (the wall timer will finish the job cleanly).
// A successful probe may switch the shell kind (e.g. a recorded fish
// session wrapping a nested POSIX shell), and writeWrappedCommand() then
// types the detected kind's wrapper — so reserve the worst-case wrapper
// delivery among {"posix", "fish"} here too, mirroring armProbeTimeout()
// and abortProbeToWrapper(); otherwise the probe could be sent even though
// the detected wrapper cannot finish typing within the remaining budget.
if (probingShell && wallClockArmed) {
const remainingMs = timeoutMs - (Date.now() - wallStartMs);
const probeDeliveryMs = estimateInputDeliveryMs(
`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`,
);
const wrapperDeliveryMs = Math.max(
estimateInputDeliveryMs(wrappedCommandText("posix")),
estimateInputDeliveryMs(wrappedCommandText("fish")),
);
if (probeDeliveryMs + wrapperDeliveryMs > remainingMs && wrapperDeliveryMs <= remainingMs) {
probingShell = false;
// Skipping the probe also skips abortProbeToWrapper()'s flow recovery.
// A renderer flow paused before this job (clearSessionFlowState) would
// keep the wrapper's buffered _S/_E markers away from onData, so the
// command would execute remotely while the job still reports a wall
// timeout. Resume session-owned flow before typing the wrapper, again
// best-effort so recovery can never prevent the command from starting.
try {
onInterrupt?.();
} catch {
// Best-effort recovery must not prevent the wrapper from being typed.
}
}
}
if (probingShell) {
writeInput(`${buildPendingInputClearPrefix(resolvedShellKind)}${buildLiveShellProbe(marker)}`);
} else {
+123
View File
@@ -1447,6 +1447,129 @@ test("echo suppression prime is delivered before the first typed input (#3384)",
}
});
test("live shell probe deadline falls back to typing the wrapped command (#3445)", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const writes = [];
const pty = new EventEmitter();
pty.write = (data) => {
if (data === "\x03") return;
writes.push(String(data));
};
const job = startPtyJob(pty, "mkdir /tmp/silent_dir", {
shellKind: "posix",
probeLiveShell: true,
timeoutMs: 60000,
});
// Drain paced typing of the full probe (batched at 128 chars per 30ms);
// the probe deadline is only armed once delivery completes.
while (!writes.join("").includes(`${job.marker}_Q'\n`)) t.mock.timers.tick(30);
assert.ok(!writes.join("").includes("mkdir /tmp/silent_dir"));
// Advance past the 15s probe deadline without the probe's _Q sentinel.
t.mock.timers.tick(15000);
// The wrapped command must now be typed with the pre-probe shell kind.
// Keep draining pacing ticks so the full wrapper delivery completes.
let drain = 0;
while (!writes.join("").includes("mkdir /tmp/silent_dir") && drain++ < 200) {
t.mock.timers.tick(30);
}
assert.ok(drain <= 200, "wrapper delivery drained");
// A late probe reply must not re-trigger probing or a second injection.
pty.emit("data", `${job.marker}_P:fish\n${job.marker}_Q`);
t.mock.timers.tick(30);
assert.equal(
writes.join("").split("mkdir /tmp/silent_dir").length - 1,
1,
"wrapper typed exactly once",
);
pty.emit("data", Buffer.from(`${job.marker}_S\n${job.marker}_E:0\n`));
const result = await job.resultPromise;
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.exitCode, 0);
});
test("live shell probe salvages the _P kind from an oversized chunk when _Q is lost (#3447)", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const writes = [];
const pty = new EventEmitter();
pty.write = (data) => {
if (data === "\x03") return;
writes.push(String(data));
};
const job = startPtyJob(pty, "mkdir /tmp/silent_dir", {
shellKind: "posix",
probeLiveShell: true,
timeoutMs: 60000,
});
while (!writes.join("").includes(`${job.marker}_Q'\n`)) t.mock.timers.tick(30);
// A single data event carries the complete _P:fish line followed by more
// than the 16,384-char probe buffer cap of terminal output, with the _Q
// sentinel lost. Truncating before parsing would evict the _P line and
// the fallback would then type a POSIX wrapper into the nested fish shell.
pty.emit("data", `${job.marker}_P:fish\n${"x".repeat(20000)}`);
// Advance past the 15s probe deadline plus the resume grace window.
t.mock.timers.tick(15000);
let drain = 0;
while (!writes.join("").includes("mkdir /tmp/silent_dir") && drain++ < 400) {
t.mock.timers.tick(30);
}
assert.ok(drain <= 400, "wrapper delivery drained");
const typed = writes.join("");
assert.ok(typed.includes("set -l"), "fish wrapper typed for detected fish shell");
assert.ok(!typed.includes(`${job.marker}=0`), "POSIX wrapper not typed");
pty.emit("data", Buffer.from(`${job.marker}_S\n${job.marker}_E:0\n`));
const result = await job.resultPromise;
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.exitCode, 0);
});
test("live shell probe respects the remaining wall-clock budget", async (t) => {
const { buildPendingInputClearPrefix, buildWrappedCommand } = require("./ptyExecHelpers.cjs");
for (const timeoutMs of [1000, 1500]) {
t.mock.timers.reset();
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
const writes = [];
const pty = new EventEmitter();
pty.write = (data) => {
if (data === "\x03") return;
writes.push(String(data));
};
const job = startPtyJob(pty, "mkdir /tmp/silent_dir", {
shellKind: "posix",
probeLiveShell: true,
timeoutMs,
enforceWallTimeout: true,
});
const expectedWrapper = `${buildPendingInputClearPrefix("posix")}${buildWrappedCommand(
"mkdir /tmp/silent_dir",
"posix",
job.marker,
true,
)}`;
// Advance in pacing steps without a probe reply. The wrapped command must
// be COMPLETELY delivered before the wall deadline calls finish() —
// either the probe was skipped because the budget cannot fit both the
// probe's and the wrapper's paced delivery (1000ms), or the fallback was
// armed early enough to reserve the wrapper's delivery time (1500ms).
let drained = 0;
while (!writes.join("").includes(expectedWrapper) && drained++ < 200) {
t.mock.timers.tick(30);
}
assert.ok(drained < 200, `wrapper fully delivered before the wall deadline (${timeoutMs}ms)`);
assert.equal(
writes.join("").includes(`${job.marker}_Q`),
timeoutMs > 1000,
`probe typed only when the budget fits probe + wrapper (${timeoutMs}ms)`,
);
// Keep advancing past the wall deadline so the job resolves via the hard
// wall-clock timeout (the command never starts because the probe reply
// never arrives and no PTY echo follows).
t.mock.timers.tick(timeoutMs);
const result = await job.resultPromise;
assert.equal(result.ok, false, JSON.stringify(result));
assert.match(result.error, /timed out/i);
}
});
test("a throwing echo suppression prime callback still types the command (#3384)", () => {
const writes = [];
const pty = new EventEmitter();
@@ -5,6 +5,32 @@ const { startPtyJob } = require('./ptyExec.cjs');
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
test('short wall-clock bypass resumes session flow control before typing the wrapper', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const pty = new EventEmitter();
const writes = [];
const events = [];
pty.write = (data) => writes.push(String(data));
// The probe needs ~600ms of paced delivery and the wrapper ~330ms, so a
// 800ms wall budget skips the probe and types the wrapper directly. That
// bypass must still resume session-owned flow (clearSessionFlowState)
// before typing the wrapper, otherwise a renderer flow paused before the
// job keeps the buffered start/end markers away from onData and the job
// times out despite the command executing remotely.
const job = startPtyJob(pty, 'printf resumed', {
shellKind: 'posix', probeLiveShell: true, timeoutMs: 800, enforceWallTimeout: true,
onInterrupt: () => events.push('interrupt'),
});
assert.ok(events.includes('interrupt'), 'bypass must invoke onInterrupt before typing');
const totalLength = 2 + buildWrappedCommand('printf resumed', 'posix', job.marker, true).length;
while (writes.join('').length < totalLength) t.mock.timers.tick(30);
assert.ok(!writes.join('').includes('function __ncmcp_int'),
'bypass must skip the probe and type the wrapper directly');
pty.emit('data', `${job.marker}_S\r\nresumed\r\n${job.marker}_E:0\r\n`);
const result = await job.resultPromise;
assert.equal(result.exitCode, 0, JSON.stringify(result));
});
for (const background of [true, false]) {
test(`paced ${background ? 'background' : 'silent foreground'} delivery does not consume startup time`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
@@ -52,10 +78,15 @@ test('completed delivery still has a bounded wait for a missing probe reply', as
const length = 2 + buildLiveShellProbe(job.marker).length;
while (writes.join('').length < length) t.mock.timers.tick(30);
// Unrelated output must not keep a never-started command alive forever.
for (let i = 0; i < 6; i++) {
// The probe fallback fires before the command deadline (75% of the
// budget) so a lost _Q sentinel still gets the wrapper typed.
let resolved = false;
job.resultPromise.then(() => { resolved = true; });
for (let i = 0; i < 40 && !resolved; i++) {
pty.emit('data', 'unrelated output\n');
t.mock.timers.tick(100);
}
assert.ok(writes.join('').includes('echo never'), 'probe fallback typed the wrapped command');
const result = await job.resultPromise;
assert.match(result.error, /Command startup timed out/);
assert.ok(writes.includes('\x03'));