diff --git a/packages/cli/src/utils/__tests__/command.spec.ts b/packages/cli/src/utils/__tests__/command.spec.ts index 3eb4bbb5ac..71160c70ef 100644 --- a/packages/cli/src/utils/__tests__/command.spec.ts +++ b/packages/cli/src/utils/__tests__/command.spec.ts @@ -27,6 +27,34 @@ describe('command runners', () => { ).rejects.toThrow(/timed out after 200ms/); }); + it( + 'times out even when a grandchild inherits the stdio pipes', + { timeout: 10_000 }, + async () => { + // Arbitrary project code run by a config worker can spawn its own + // children. This child spawns a grandchild that inherits the piped + // stdio, then wedges like a blocking plugin factory. Without a tree + // kill the SIGKILL reaches only the direct child, the grandchild + // keeps the stdout pipe open, `close` never fires, and the promise + // never settles. The grandchild self-terminates after 15s so a + // failing run leaves nothing behind. + await expect( + runCommandSilently({ + command: process.execPath, + args: [ + '-e', + `const { spawn } = require('node:child_process'); + spawn(process.execPath, ['-e', 'setTimeout(() => {}, 15_000)'], { stdio: 'inherit' }); + setInterval(() => {}, 1000);`, + ], + cwd: process.cwd(), + envs: process.env, + timeoutMs: 500, + }), + ).rejects.toThrow(/timed out after 500ms/); + }, + ); + it('does not reject a fast child because a timeout is configured', async () => { const result = await runCommandSilently({ command: process.execPath, diff --git a/packages/cli/src/utils/command.ts b/packages/cli/src/utils/command.ts index ee1061e3e5..08ad07a92a 100644 --- a/packages/cli/src/utils/command.ts +++ b/packages/cli/src/utils/command.ts @@ -39,6 +39,9 @@ export async function runCommandSilently(options: RunCommandOptions): Promise((resolve, reject) => { const stdout: Buffer[] = []; @@ -51,7 +54,21 @@ export async function runCommandSilently(options: RunCommandOptions): Promise { timedOut = true; - child.kill('SIGKILL'); + if (process.platform !== 'win32' && child.pid) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch { + child.kill('SIGKILL'); + } + } else { + child.kill('SIGKILL'); + } + // A descendant that inherited the pipes can hold them open past + // the kill (a Windows child tree, or a POSIX process that left + // the group). Release our ends so `close` always fires; the + // timeout path rejects without reading the output anyway. + child.stdout?.destroy(); + child.stderr?.destroy(); }, options.timeoutMs); timer?.unref(); child.stdout?.on('data', (data) => {