uv_spawn returns EBADF on macOS when stdio pipe descriptors exceed 10239
Version
libuv 1.52.1 through Node.js 24.16.0. Also reproducible with libuv 1.51.0 through Node.js 22.21.0.
Platform
macOS 26.5.2 (Darwin 25.5.0, arm64).
Description
On macOS, uv_spawn() returns EBADF once the parent has enough open file descriptors for the newly created stdio pipe descriptors to exceed 10239, even when the process limit is much higher.
This surfaced in a Nuxt application that live type-checks a large TypeScript project. The type-checker holds approximately 10,700 files open, after which Miniflare cannot spawn workerd with four piped stdio descriptors. Cleaning Miniflare persistence does not help because the descriptors belong to the TypeScript watcher.
getconf OPEN_MAX and ulimit -n both report 1048575. kern.maxfilesperproc is 138240.
Reproduction
const fs = require('node:fs')
const { spawnSync } = require('node:child_process')
const descriptors = Array.from({ length: 10221 }, () => fs.openSync(__filename, 'r'))
const result = spawnSync(process.execPath, ['--version'], { encoding: 'utf8' })
console.log(result.error)
descriptors.forEach((descriptor) => fs.closeSync(descriptor))With 10220 explicitly opened descriptors, spawning succeeds. With 10221, it consistently fails:
Error: spawnSync /path/to/node EBADFThe exact explicit-open threshold depends on the process's baseline descriptors; in this process the created pipe descriptor crosses 10239 at 10221 opens.
Expected behavior
uv_spawn() should succeed while descriptors and process resources remain available, or fall back to the fork/exec path if Apple's posix_spawn file actions reject a valid high-numbered source descriptor.
Additional context
The likely failure site is posix_spawn_file_actions_adddup2() or posix_spawn_file_actions_addclose() in uv__spawn_set_posix_spawn_file_actions(). macOS permits the parent to hold descriptors above 10239 under the raised limit, but its spawn file-action API appears to reject those descriptors with EBADF.
A practical application-level workaround is to avoid the live TypeScript watcher during development and run type checking separately. That reduced the Nuxt process from approximately 10,739 open descriptors to approximately 1,466 and allowed workerd to spawn.
Source: libuv/libuv