When a child process spawned by x() creates a grandchild that inherits the piped stdout fd, the grandchild keeps the pipe open after the child exits. Both await x() and the async iterator hang indefinitely.
Reproduction:
import { x } from 'tinyexec'
// child.mjs:
// import { spawn } from 'node:child_process'
// spawn('node', ['-e', 'setTimeout(() => void 0, 30000)'], {
// stdio: ['ignore', 1, 'ignore'],
// })
// process.stdout.write('output\\n')
// process.exit(0)
const result = await x('node', ['child.mjs'])
// never reaches here
The grandchild inherits fd 1 (the piped stdout). When the child exits, the grandchild keeps the pipe open. readStream and combineStreams hang because the streams never end. Node's close event doesn't fire either, since it waits for all fds to be released.
This is what causes lint-staged to deadlock when eslint uses typescript-eslint's projectService (tsserver inherits the piped fds): lint-staged/lint-staged#1800.
Node v24.14.1, macOS (Apple Silicon). Only reproduces when stdout is piped (not a TTY).
When a child process spawned by
x()creates a grandchild that inherits the piped stdout fd, the grandchild keeps the pipe open after the child exits. Bothawait x()and the async iterator hang indefinitely.Reproduction:
The grandchild inherits fd 1 (the piped stdout). When the child exits, the grandchild keeps the pipe open.
readStreamandcombineStreamshang because the streams never end. Node'scloseevent doesn't fire either, since it waits for all fds to be released.This is what causes lint-staged to deadlock when eslint uses typescript-eslint's
projectService(tsserver inherits the piped fds): lint-staged/lint-staged#1800.Node v24.14.1, macOS (Apple Silicon). Only reproduces when stdout is piped (not a TTY).