import { spawn } from 'node:child_process' import { LinterToolError } from '../diagnostic.js' const DEFAULT_TIMEOUT_MS = 60_000 const DEFAULT_OUTPUT_LIMIT_BYTES = 16 * 1024 * 1024 const DEFAULT_TERMINATION_GRACE_MS = 1_000 export type OxlintProcessOptions = { signal?: AbortSignal timeoutMs?: number outputLimitBytes?: number terminationGraceMs?: number } export type OxlintProcessResult = { status: number | null signal: NodeJS.Signals | null stdout: string stderr: string } type TerminationReason = | { kind: 'cancelled'; cause: unknown } | { kind: 'output-limit'; limitBytes: number } | { kind: 'timeout'; timeoutMs: number } /** * Run Oxlint under the runtime it declares, independent of the SDK CLI's * parent runtime. The published CLI intentionally runs under Bun, while * Oxlint's executable is a Node program. */ export async function runOxlintProcess( bin: string, args: readonly string[], cwd: string, options: OxlintProcessOptions = {}, ): Promise { const timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 'timeoutMs') const outputLimitBytes = positiveInteger( options.outputLimitBytes, DEFAULT_OUTPUT_LIMIT_BYTES, 'outputLimitBytes', ) const terminationGraceMs = nonNegativeInteger( options.terminationGraceMs, DEFAULT_TERMINATION_GRACE_MS, 'terminationGraceMs', ) if (options.signal?.aborted) throw cancelledError(options.signal.reason) let child: ReturnType try { child = spawn('node', [bin, ...args], { cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }) } catch (cause) { throw startError(bin, cause) } return await new Promise((resolve, reject) => { const stdout: Buffer[] = [] const stderr: Buffer[] = [] let capturedBytes = 0 let settled = false let processError: unknown let terminationReason: TerminationReason | undefined let forceKillTimer: NodeJS.Timeout | undefined const timeout = setTimeout(() => { terminate({ kind: 'timeout', timeoutMs }) }, timeoutMs) timeout.unref?.() const onAbort = () => terminate({ kind: 'cancelled', cause: options.signal?.reason }) options.signal?.addEventListener('abort', onAbort, { once: true }) const onStdout = (chunk: Buffer | string) => capture(stdout, chunk) const onStderr = (chunk: Buffer | string) => capture(stderr, chunk) child.stdout?.on('data', onStdout) child.stderr?.on('data', onStderr) // `close` follows both normal exit and spawn errors. Record an error here // but settle only after `close`, so the process handle and stdio are always // reaped before the caller continues. child.once('error', (cause) => { processError = cause clearTimeout(timeout) }) child.once('close', (status, signal) => { finish(() => { if (terminationReason) { reject(terminationError(terminationReason)) return } if (processError) { reject(startError(bin, processError)) return } resolve({ status, signal, stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'), }) }) }) // Close the race between the pre-spawn check and listener registration. if (options.signal?.aborted) onAbort() function capture(target: Buffer[], chunk: Buffer | string): void { if (terminationReason) return const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) const remaining = outputLimitBytes - capturedBytes if (remaining > 0) { const accepted = Math.min(remaining, buffer.byteLength) target.push(buffer.subarray(0, accepted)) capturedBytes += accepted } if (buffer.byteLength > remaining) { terminate({ kind: 'output-limit', limitBytes: outputLimitBytes }) } } function terminate(reason: TerminationReason): void { if (settled || terminationReason) return terminationReason = reason try { child.kill('SIGTERM') } catch { forceKill() return } forceKillTimer = setTimeout(forceKill, terminationGraceMs) forceKillTimer.unref?.() } function forceKill(): void { if (settled || child.exitCode !== null || child.signalCode !== null) return try { child.kill('SIGKILL') } catch { // The close/error event remains the authoritative process outcome. } } function finish(settle: () => void): void { if (settled) return settled = true clearTimeout(timeout) if (forceKillTimer) clearTimeout(forceKillTimer) options.signal?.removeEventListener('abort', onAbort) child.stdout?.off('data', onStdout) child.stderr?.off('data', onStderr) settle() } }) } function startError(bin: string, cause: unknown): LinterToolError { return new LinterToolError(`Could not start Oxlint with Node at ${bin}.`, { code: 'OXLINT_START_FAILED', cause, }) } function cancelledError(cause: unknown): LinterToolError { return new LinterToolError('Oxlint was cancelled and its process was terminated.', { code: 'OXLINT_CANCELLED', cause, }) } function terminationError(reason: TerminationReason): LinterToolError { switch (reason.kind) { case 'cancelled': return cancelledError(reason.cause) case 'output-limit': return new LinterToolError( `Oxlint exceeded the ${formatBytes(reason.limitBytes)} output limit and was terminated.`, { code: 'OXLINT_OUTPUT_LIMIT' }, ) case 'timeout': return new LinterToolError( `Oxlint exceeded the ${formatDuration(reason.timeoutMs)} timeout and was terminated.`, { code: 'OXLINT_TIMEOUT' }, ) } } function positiveInteger(value: number | undefined, fallback: number, name: string): number { const result = value ?? fallback if (!Number.isSafeInteger(result) || result <= 0) { throw new TypeError(`${name} must be a positive integer.`) } return result } function nonNegativeInteger(value: number | undefined, fallback: number, name: string): number { const result = value ?? fallback if (!Number.isSafeInteger(result) || result < 0) { throw new TypeError(`${name} must be a non-negative integer.`) } return result } function formatDuration(milliseconds: number): string { return milliseconds % 1_000 === 0 ? `${milliseconds / 1_000}s` : `${milliseconds}ms` } function formatBytes(bytes: number): string { const mib = 1024 * 1024 return bytes % mib === 0 ? `${bytes / mib} MiB` : `${bytes} bytes` }