blob: 4db4668d3bdd6e9d8f5e383e716583f22368ec8a [file]
import { spawn, type ChildProcessByStdio } from 'node:child_process';
import type { Readable, Writable } from 'node:stream';
import {
buildSpawnStdio,
closeChildFdSources,
type ChildFdInput,
writeChildFdInputs,
} from '../child-fd-input.js';
import {
DEFAULT_PROCESS_TERMINATION_GRACE_MS,
terminateChildProcessTree,
} from '../process-tree-terminator.js';
export const FILESYSTEM_WORKER_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
export const FILESYSTEM_WORKER_MAX_STDERR_BYTES = 1024 * 1024;
export const FILESYSTEM_WORKER_DEFAULT_TIMEOUT_MS = 120_000;
export interface FilesystemWorkerProcessRunInput {
argv: readonly string[];
cwd: string;
env: Readonly<Record<string, string | undefined>>;
stdin: string;
fdInputs?: readonly ChildFdInput[];
timeoutMs?: number;
abortSignal?: AbortSignal;
maxResponseBytes?: number;
maxStderrBytes?: number;
killGraceMs?: number;
}
export interface FilesystemWorkerProcessRunResult {
exitCode: number;
stdout: string;
stderrTail: string;
timedOut: boolean;
aborted: boolean;
responseOverflow: boolean;
}
export type FilesystemWorkerProcessRunner = (
input: FilesystemWorkerProcessRunInput,
) => Promise<FilesystemWorkerProcessRunResult>;
type WorkerChildProcess = ChildProcessByStdio<Writable, Readable, Readable>;
export async function runFilesystemWorkerProcess(
input: FilesystemWorkerProcessRunInput,
): Promise<FilesystemWorkerProcessRunResult> {
const program = input.argv[0];
if (!program) throw new Error('Filesystem worker argv must include a program.');
let child: WorkerChildProcess;
try {
child = spawn(program, input.argv.slice(1), {
cwd: input.cwd,
env: input.env as NodeJS.ProcessEnv,
shell: false,
stdio: buildSpawnStdio(input.fdInputs, 'pipe'),
detached: process.platform !== 'win32',
}) as WorkerChildProcess;
} finally {
closeChildFdSources(input.fdInputs);
}
return await observeWorker(child, input);
}
async function observeWorker(
child: WorkerChildProcess,
input: FilesystemWorkerProcessRunInput,
): Promise<FilesystemWorkerProcessRunResult> {
return await new Promise((resolvePromise, reject) => {
const responseLimit = input.maxResponseBytes ?? FILESYSTEM_WORKER_MAX_RESPONSE_BYTES;
const stderrLimit = input.maxStderrBytes ?? FILESYSTEM_WORKER_MAX_STDERR_BYTES;
const timeoutMs = input.timeoutMs ?? FILESYSTEM_WORKER_DEFAULT_TIMEOUT_MS;
const killGraceMs = input.killGraceMs ?? DEFAULT_PROCESS_TERMINATION_GRACE_MS;
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrTail: Buffer<ArrayBufferLike> = Buffer.alloc(0);
let responseOverflow = false;
let termination: 'timeout' | 'abort' | 'overflow' | undefined;
let settled = false;
let killTimer: NodeJS.Timeout | undefined;
const timeout = setTimeout(() => terminate('timeout'), timeoutMs);
const abort = () => terminate('abort');
if (input.abortSignal) {
if (input.abortSignal.aborted) abort();
else input.abortSignal.addEventListener('abort', abort, { once: true });
}
child.stdout.on('data', (chunk: Buffer) => {
if (responseOverflow) return;
stdoutBytes += chunk.length;
if (stdoutBytes > responseLimit) {
responseOverflow = true;
stdoutChunks.length = 0;
terminate('overflow');
} else {
stdoutChunks.push(chunk);
}
});
child.stderr.on('data', (chunk: Buffer) => {
stderrTail = appendBoundedTail(stderrTail, chunk, stderrLimit);
});
child.once('error', (error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
});
child.once('close', (exitCode) => {
if (settled) return;
settled = true;
cleanup();
resolvePromise({
exitCode: exitCode ?? 1,
stdout: responseOverflow ? '' : Buffer.concat(stdoutChunks).toString('utf8'),
stderrTail: stderrTail.toString('utf8'),
timedOut: termination === 'timeout',
aborted: termination === 'abort',
responseOverflow,
});
});
child.stdin.once('error', () => {});
try {
writeChildFdInputs(child, input.fdInputs);
} catch (error) {
settled = true;
cleanup();
void terminateChildProcessTree(child, 'SIGKILL');
reject(error);
return;
}
child.stdin.end(input.stdin);
function terminate(reason: 'timeout' | 'abort' | 'overflow'): void {
if (termination || settled) return;
termination = reason;
terminateChildProcessTree(child, 'SIGTERM');
killTimer = setTimeout(() => terminateChildProcessTree(child, 'SIGKILL'), killGraceMs);
}
function cleanup(): void {
clearTimeout(timeout);
if (killTimer) clearTimeout(killTimer);
input.abortSignal?.removeEventListener('abort', abort);
}
});
}
function appendBoundedTail(current: Buffer, chunk: Buffer, limit: number): Buffer {
if (limit <= 0) return Buffer.alloc(0);
if (chunk.length >= limit) return chunk.subarray(chunk.length - limit);
if (current.length + chunk.length <= limit) return Buffer.concat([current, chunk]);
return Buffer.concat([current.subarray(current.length - (limit - chunk.length)), chunk]);
}