blob: 2c8115b9ff961e45193ee5057ca380b73086e8ab [file]
import assert from 'node:assert/strict';
import { describe, test } from 'node:test';
import { existsSync, promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { killWindowsTree } from '../process-tree-terminator.js';
import { runProcessWithBoundedTail, runShellWithBoundedTail } from '../shell-exec.js';
const base = (over: Record<string, unknown> = {}) => ({
cwd: process.cwd(),
timeoutMs: 30_000,
...over,
});
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function readWhenAvailable(path: string, timeoutMs = 5_000): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (true) {
try {
return await fs.readFile(path, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT' || Date.now() >= deadline) throw error;
await delay(20);
}
}
}
async function waitForProcessExit(pid: number, timeoutMs = 3_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (true) {
try {
process.kill(pid, 0);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return;
throw error;
}
if (Date.now() >= deadline) throw new Error(`Detached descendant ${pid} is still running`);
await delay(20);
}
}
function findPwsh(): string | undefined {
const exeNames = process.platform === 'win32' ? ['pwsh.exe'] : ['pwsh', 'pwsh-preview'];
for (const dir of (process.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':')) {
if (!dir) continue;
for (const name of exeNames) {
const candidate = join(dir, name);
if (existsSync(candidate)) return candidate;
}
}
return undefined;
}
describe('runShellWithBoundedTail', () => {
test('returns full small output and exit 0 without throwing', async () => {
const r = await runShellWithBoundedTail("printf 'hello\\nworld\\n'", base());
assert.deepEqual(
{
exitCode: r.exitCode,
stdout: r.stdout,
stderr: r.stderr,
timedOut: r.timedOut,
aborted: r.aborted,
},
{ exitCode: 0, stdout: 'hello\nworld\n', stderr: '', timedOut: false, aborted: false },
);
});
test('keeps only the bounded, line-aligned TAIL of large output (never killed by size)', async () => {
const r = await runShellWithBoundedTail(
"printf 'HEADMARK\\n'; seq 1 50; printf 'TAILMARK\\n'",
base({ maxRetainedChars: 12 }),
);
assert.equal(r.exitCode, 0);
assert.ok(r.stdout.includes('TAILMARK'), 'tail retained');
assert.ok(!r.stdout.includes('HEADMARK'), 'head dropped — it is a tail');
assert.ok(r.stdout.length <= 12, `tail bounded to cap, got ${r.stdout.length}`);
assert.equal(r.stdoutTruncated, true);
});
test('captures stderr and a non-zero exit code as data (does not reject)', async () => {
const r = await runShellWithBoundedTail("printf 'oops\\n' >&2; exit 3", base());
assert.equal(r.exitCode, 3);
assert.equal(r.stderr, 'oops\n');
assert.equal(r.stdout, '');
});
test('times out a slow command, kills it, and reports timedOut', async () => {
const r = await runShellWithBoundedTail('sleep 5', base({ timeoutMs: 150 }));
assert.equal(r.timedOut, true);
assert.equal(r.exitCode, 124);
});
test('does not spawn a process for an already-aborted invocation', async () => {
const dir = await fs.mkdtemp(join(tmpdir(), 'shell-exec-pre-abort-'));
const marker = join(dir, 'spawned');
const abort = new AbortController();
abort.abort();
try {
const result = await runProcessWithBoundedTail(
process.execPath,
['-e', `require('node:fs').writeFileSync(${JSON.stringify(marker)}, 'spawned')`],
base({ abortSignal: abort.signal }),
);
assert.equal(result.aborted, true);
assert.equal(result.exitCode, 130);
await assert.rejects(() => fs.readFile(marker, 'utf8'), { code: 'ENOENT' });
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
test('bounds output drain after the root exits while a detached descendant retains stdout', {
skip: process.platform === 'win32' ? 'POSIX detached process-group semantics required' : false,
}, async () => {
const dir = await fs.mkdtemp(join(tmpdir(), 'shell-exec-drain-'));
const pidFile = join(dir, 'child.pid');
let childPid: number | undefined;
try {
const script = `const {spawn}=require('node:child_process');const {writeFileSync}=require('node:fs');const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['ignore',process.stdout,'ignore']});child.unref();writeFileSync(${JSON.stringify(pidFile)},String(child.pid));process.stdout.write('ROOT\\n')`;
const startedAt = Date.now();
const result = await runProcessWithBoundedTail(
process.execPath,
['-e', script],
base({ ioDrainTimeoutMs: 100 }),
);
childPid = Number.parseInt(await fs.readFile(pidFile, 'utf8'), 10);
assert.ok(Date.now() - startedAt < 2_000);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, 'ROOT\n');
assert.equal(result.stdoutTruncated, true);
assert.equal(result.stderrTruncated, false);
} finally {
if (childPid) {
try {
process.kill(-childPid, 'SIGKILL');
} catch {
try {
process.kill(childPid, 'SIGKILL');
} catch {
/* descendant already exited */
}
}
}
await fs.rm(dir, { recursive: true, force: true });
}
});
test('on timeout, escalates SIGTERM->SIGKILL and resolves only after the child is actually dead', async () => {
// A child that traps/ignores SIGTERM would, under the old "kill then resolve
// immediately" path, keep running (and emitting) after we told the caller
// "timed out". Now we wait for the child to actually exit (SIGKILL after the
// grace) before resolving — proven by the heartbeat file no longer growing.
const dir = await fs.mkdtemp(join(tmpdir(), 'shell-exec-kill-'));
const beat = join(dir, 'beat');
const cmd = `trap '' TERM; while true; do echo STILL; echo x >> '${beat}'; sleep 0.02; done`;
const emits: string[] = [];
try {
const r = await runShellWithBoundedTail(
cmd,
base({
timeoutMs: 100,
killGraceMs: 150,
emitOutput: (_s: string, c: string) => emits.push(c),
}),
);
assert.equal(r.timedOut, true);
assert.equal(r.exitCode, 124);
const sizeAtResolve = (await fs.stat(beat)).size;
const emitsAtResolve = emits.length;
await delay(300); // a live child would append ~15 more lines in this window
const sizeLater = (await fs.stat(beat)).size;
assert.ok(
sizeLater - sizeAtResolve <= 2, // tolerate at most one in-flight append
`child kept writing after resolve (grew ${sizeLater - sizeAtResolve} bytes) — not actually killed`,
);
assert.equal(emits.length, emitsAtResolve, 'no emitOutput after resolve');
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
test('on abort, kills detached descendants of a directly spawned process', async () => {
const dir = await fs.mkdtemp(join(tmpdir(), 'shell-exec-tree-'));
const pidFile = join(dir, 'child.pid');
const parentFile = join(dir, 'parent.cjs');
const childScript = 'setInterval(() => {}, 1000)';
await fs.writeFile(
parentFile,
`
const { spawn } = require('node:child_process');
const { writeFileSync } = require('node:fs');
const child = spawn(process.execPath, ['-e', ${JSON.stringify(childScript)}], {
detached: true,
stdio: 'ignore',
});
child.unref();
writeFileSync(${JSON.stringify(pidFile)}, String(child.pid));
setInterval(() => {}, 1000);
`,
);
const abort = new AbortController();
const running = runProcessWithBoundedTail(
process.execPath,
[parentFile],
base({
timeoutMs: 10_000,
killGraceMs: 150,
abortSignal: abort.signal,
}),
);
try {
const childPid = Number((await readWhenAvailable(pidFile)).trim());
assert.ok(Number.isInteger(childPid) && childPid > 0, 'detached descendant recorded its pid');
abort.abort();
const r = await running;
assert.equal(r.aborted, true);
assert.equal(r.exitCode, 130);
await waitForProcessExit(childPid);
} finally {
abort.abort();
await running.catch(() => undefined);
await fs.rm(dir, { recursive: true, force: true });
}
});
test('surfaces a safety marker (not bare empty) when an oversized no-newline line is dropped', async () => {
// One 500-char line with no newline, cap 50: BashTailBuffer drops it whole
// (no safe truncation boundary), so without a marker the result would look
// like the command produced nothing.
const r = await runShellWithBoundedTail(
"head -c 500 /dev/zero | tr '\\0' x",
base({ maxRetainedChars: 50 }),
);
assert.equal(r.exitCode, 0);
assert.ok(!r.stdout.includes('xxxx'), 'dropped content is not leaked');
assert.ok(r.stdout.includes('omitted for safety'), 'a recoverable safety marker is present');
assert.equal(r.stdoutTruncated, true);
// Shares the recovery hint with truncateToolOutput: re-run only when safe.
assert.ok(r.stdout.includes('safe to re-run'), 'recovery guidance is conditioned on safety');
assert.ok(r.stdout.includes('side effects'), 'warns about repeating side effects');
});
test('spawns a detected PowerShell explicitly with non-interactive flags (not via shell:true)', async () => {
// /bin/echo stands in for pwsh.exe: if the spawn plan is honoured, the
// "shell" receives the flags plus the command as argv and echoes them back.
// If the command were still run via shell:true, stdout would be plain
// 'wired-marker' with no flags.
const r = await runShellWithBoundedTail(
'echo wired-marker',
base({
shell: { kind: 'pwsh', displayName: 'PowerShell 7 (pwsh)', exe: '/bin/echo' },
}),
);
assert.equal(r.exitCode, 0);
assert.ok(
r.stdout.startsWith('-NoLogo -NoProfile -NonInteractive -Command echo wired-marker\n'),
`flags then verbatim command, got: ${r.stdout}`,
);
assert.ok(
r.stdout.includes('exit $LASTEXITCODE'),
'exit-code wrapper is part of the command argument',
);
});
test('a native command exit code survives the PowerShell -Command path (requires pwsh)', async (t) => {
// Without the wrapper, pwsh -Command maps any non-zero native exit code to
// 1 (verified against real pwsh). node stands in for the native command.
const pwsh = findPwsh();
if (!pwsh) return t.skip('pwsh not installed');
const r = await runShellWithBoundedTail(
`& '${process.execPath}' -e 'process.exit(42)'`,
base({ shell: { kind: 'pwsh', displayName: 'PowerShell 7 (pwsh)', exe: pwsh } }),
);
assert.equal(r.exitCode, 42);
});
test('deliberate boundary: earlier native exit code wins over a final cmdlet failure (requires pwsh)', async (t) => {
// Plain pwsh -Command would exit 1 here (last statement is a cmdlet
// failure). The wrapper cannot tell WHICH statement tripped $? , and the
// only observable ($Error growth) would misreport the far more common
// "cmdlet noise, then native command fails last" shape back to 1. So when
// the final statement failed, the wrapper deliberately prefers the last
// native exit code over the generic 1 — both are non-zero, stderr still
// carries the cmdlet error. Pinned so a wrapper change surfaces here.
const pwsh = findPwsh();
if (!pwsh) return t.skip('pwsh not installed');
const r = await runShellWithBoundedTail(
`& '${process.execPath}' -e 'process.exit(42)'\nGet-Item ./maka-definitely-missing-file`,
base({ shell: { kind: 'pwsh', displayName: 'PowerShell 7 (pwsh)', exe: pwsh } }),
);
assert.equal(r.exitCode, 42);
assert.match(r.stderr, /maka-definitely-missing-file/);
});
test('emits every chunk live via emitOutput', async () => {
const seen: Array<[string, string]> = [];
await runShellWithBoundedTail(
"printf 'aaa'; printf 'bbb' >&2",
base({ emitOutput: (s: 'stdout' | 'stderr', c: string) => seen.push([s, c]) }),
);
assert.ok(seen.some(([s, c]) => s === 'stdout' && c.includes('aaa')));
assert.ok(seen.some(([s, c]) => s === 'stderr' && c.includes('bbb')));
});
test('caps live emitOutput per stream with a single suppressed marker (result keeps full tail)', async () => {
const seen: Array<[string, string]> = [];
const r = await runShellWithBoundedTail(
"printf 'HEAD\\n'; seq 1 2000; printf 'TAIL\\n'",
base({
maxLiveEmitChars: 20, // tiny cap so the stream trips it almost immediately
emitOutput: (s: 'stdout' | 'stderr', c: string) => seen.push([s, c]),
}),
);
assert.equal(r.exitCode, 0);
const stdoutEmits = seen.filter(([s]) => s === 'stdout');
const markers = stdoutEmits.filter(([, c]) => c.includes('live output suppressed'));
assert.equal(markers.length, 1, 'exactly one suppressed marker, not one per chunk');
const liveChars = stdoutEmits
.filter(([, c]) => !c.includes('live output suppressed'))
.reduce((n, [, c]) => n + c.length, 0);
assert.ok(liveChars <= 20, `live emit bounded to cap, got ${liveChars}`);
// The suppressed LIVE feed does not lose the result: the retained tail still
// carries the real output (the last bytes the command produced).
assert.ok(r.stdout.includes('TAIL'), 'retained tail keeps the command output');
});
test('killWindowsTree resolves false when taskkill cannot act on the target', async () => {
// On non-Windows hosts `taskkill` is absent, so spawn emits an async 'error'
// (ENOENT). Without the error listener that would be an unhandled 'error'
// event and crash the process. Awaiting the real child outcome also pins the
// ordering needed before a caller may fall back to killing only the root.
assert.equal(await killWindowsTree(999_999), false);
});
});