fix: clean up command runner capture listeners

This commit is contained in:
Neil
2026-05-30 08:38:54 -07:00
parent 6ad4faac13
commit cb08a42d0d
2 changed files with 53 additions and 11 deletions
+28
View File
@@ -112,6 +112,10 @@ describe('commandExecFileAsync Windows command shims', () => {
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
expect(command.kill).not.toHaveBeenCalled()
expect(command.stdout.listenerCount('data')).toBe(0)
expect(command.stderr.listenerCount('data')).toBe(0)
expect(command.listenerCount('error')).toBe(0)
expect(command.listenerCount('close')).toBe(0)
})
})
@@ -137,6 +141,30 @@ describe('commandExecFileAsync Windows command shims', () => {
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
expect(command.kill).not.toHaveBeenCalled()
expect(command.stdout.listenerCount('data')).toBe(0)
expect(command.stderr.listenerCount('data')).toBe(0)
expect(command.listenerCount('error')).toBe(0)
expect(command.listenerCount('close')).toBe(0)
})
})
it('removes listeners after successful Windows .cmd shim executions', async () => {
await withPlatform('win32', async () => {
const command = createMockChildProcess(1234)
spawnMock.mockReturnValue(command)
const promise = commandExecFileAsync('C:\\tools\\pnpm.cmd', ['--version'], {
cwd: 'C:\\repo'
})
command.stdout.emit('data', Buffer.from('9.1.0\n'))
command.stderr.emit('data', Buffer.from('notice\n'))
command.emit('close', 0)
await expect(promise).resolves.toEqual({ stdout: '9.1.0\n', stderr: 'notice\n' })
expect(command.stdout.listenerCount('data')).toBe(0)
expect(command.stderr.listenerCount('data')).toBe(0)
expect(command.listenerCount('error')).toBe(0)
expect(command.listenerCount('close')).toBe(0)
})
})
})
+25 -11
View File
@@ -296,15 +296,23 @@ async function spawnCommandCapture(
killSpawnedCommandTree(child)
finish(createAbortError())
}
const cleanupListeners = (): void => {
if (timer) {
clearTimeout(timer)
timer = null
}
options.signal?.removeEventListener('abort', onAbort)
child.stdout?.off('data', onStdoutData)
child.stderr?.off('data', onStderrData)
child.off('error', onError)
child.off('close', onClose)
}
const finish = (error: Error | null): void => {
if (settled) {
return
}
settled = true
if (timer) {
clearTimeout(timer)
}
options.signal?.removeEventListener('abort', onAbort)
cleanupListeners()
if (error) {
reject(Object.assign(error, { stdout, stderr }))
return
@@ -318,7 +326,7 @@ async function spawnCommandCapture(
}, options.timeout)
: null
options.signal?.addEventListener('abort', onAbort, { once: true })
child.stdout?.on('data', (chunk: Buffer) => {
function onStdoutData(chunk: Buffer): void {
stdoutBytes += chunk.byteLength
if (options.maxBuffer && stdoutBytes > options.maxBuffer) {
killSpawnedCommandTree(child)
@@ -326,8 +334,8 @@ async function spawnCommandCapture(
return
}
stdout += chunk.toString(options.encoding ?? 'utf-8')
})
child.stderr?.on('data', (chunk: Buffer) => {
}
function onStderrData(chunk: Buffer): void {
stderrBytes += chunk.byteLength
if (options.maxBuffer && stderrBytes > options.maxBuffer) {
killSpawnedCommandTree(child)
@@ -335,15 +343,21 @@ async function spawnCommandCapture(
return
}
stderr += chunk.toString(options.encoding ?? 'utf-8')
})
child.on('error', finish)
child.on('close', (code) => {
}
function onError(error: Error): void {
finish(error)
}
function onClose(code: number | null): void {
if (code === 0) {
finish(null)
return
}
finish(new Error(`${command} exited with ${code}.`))
})
}
child.stdout?.on('data', onStdoutData)
child.stderr?.on('data', onStderrData)
child.on('error', onError)
child.on('close', onClose)
})
}