#5204·libuv

uv_spawn returns EBADF on macOS when stdio pipe descriptors exceed 10239

Author: arashsheydaCreated Jul 22, 2026Updated Jul 22, 2026

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

javascript
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 EBADF

The 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.