Version: node-pty 1.1.0, macOS (arm64, also reproduced on macOS 26), Node 22 / Electron 42.
On darwin, pty_posix_spawn (src/unix/pty.cc) allocates a pty before spawning and never releases it on any error path — and its low-fd prologue leaks one fd even when everything succeeds.
Leak 1 — failed spawn leaks the master and the slave (2 pty devices per failure)
*master = posix_openpt(O_RDWR) (pty.cc:707) and slave = open(slave_pty_name, …) (pty.cc:725) have no matching parent-side close() anywhere: every early return (L709, 714, 722, 727, 733, 740) and the done: path (L777) leave both fds open. The posix_spawn_file_actions_addclose calls at L749-750 apply only to the child.
PtyFork then throws (pty.cc:372-374, "posix_spawnp failed.") without cleanup, and since the JS ctor never receives term.fd, the caller cannot close anything either.
Leak 2 — successful spawn leaks one ptmx fd (off-by-one)
The low-fd prologue (L694-701) opens ptys until one lands at fd >= STDERR_FILENO, then breaks. Cleanup is:
for (; count > 0; count--) close(low_fds[count]);
In the common case the loop breaks with count == 0, so the body never runs and low_fds[0] leaks on every spawn. It also skips index 0 whenever count > 0, and reads low_fds[3] out of bounds if the prologue loop completes without breaking.
Reproduction
Forcing a real posix_spawn failure needs more than a nonexistent binary (on macOS argv[0] is the spawn-helper, which exists — the failure then happens in the child). E2BIG via an oversized argv works:
const pty = require('node-pty')
const huge = 'x'.repeat(3 * 1024 * 1024)
for (let i = 0; i < 25; i++) {
try { pty.spawn('/bin/echo', [huge], { cwd: '/tmp' }) } catch (e) { /* posix_spawnp failed. */ }
}
// lsof -p <pid>: 50 /dev/ptmx + 25 /dev/ttysNNN still open
Measured on macOS: 25 failed spawns → +50 /dev/ptmx fds, +25 /dev/ttys* fds, system-wide device count 84 → 134 (2 devices per failure). 25 successful spawns, each fully exited and destroy()ed, still left 25 /dev/ptmx fds open (1 device each).
Impact
macOS caps pty devices system-wide at kern.tty.ptmx_max (default 511). Because failures leak two devices each, exhaustion is self-amplifying: at the ceiling every spawn fails, every failure consumes two more devices, and the process never recovers. An Electron app of ours accumulated 479 open masters in a 31-minute session against 28 real terminals, after which every spawn on the machine failed until the process exited.
Suggested fix
- In
pty_posix_spawn, close *master (and slave once opened) on every error path, setting *master = -1; close slave in the parent unconditionally after posix_spawn, and close *master too when *err != 0.
- Track how many
low_fds were actually opened and close all of them, e.g. size_t opened = count < 3 ? count + 1 : 3; for (size_t i = 0; i < opened; i++) if (low_fds[i] >= 0) close(low_fds[i]);
- Ideally include the
posix_spawn errno in the thrown message — "posix_spawnp failed." currently discards the one datum (EMFILE vs EAGAIN vs ENOENT) that would let applications diagnose this.
Version: node-pty 1.1.0, macOS (arm64, also reproduced on macOS 26), Node 22 / Electron 42.
On darwin,
pty_posix_spawn(src/unix/pty.cc) allocates a pty before spawning and never releases it on any error path — and its low-fd prologue leaks one fd even when everything succeeds.Leak 1 — failed spawn leaks the master and the slave (2 pty devices per failure)
*master = posix_openpt(O_RDWR)(pty.cc:707) andslave = open(slave_pty_name, …)(pty.cc:725) have no matching parent-sideclose()anywhere: every earlyreturn(L709, 714, 722, 727, 733, 740) and thedone:path (L777) leave both fds open. Theposix_spawn_file_actions_addclosecalls at L749-750 apply only to the child.PtyForkthen throws (pty.cc:372-374,"posix_spawnp failed.") without cleanup, and since the JS ctor never receivesterm.fd, the caller cannot close anything either.Leak 2 — successful spawn leaks one ptmx fd (off-by-one)
The low-fd prologue (L694-701) opens ptys until one lands at fd >=
STDERR_FILENO, thenbreaks. Cleanup is:In the common case the loop breaks with
count == 0, so the body never runs andlow_fds[0]leaks on every spawn. It also skips index 0 whenevercount > 0, and readslow_fds[3]out of bounds if the prologue loop completes without breaking.Reproduction
Forcing a real
posix_spawnfailure needs more than a nonexistent binary (on macOS argv[0] is the spawn-helper, which exists — the failure then happens in the child).E2BIGvia an oversized argv works:Measured on macOS: 25 failed spawns → +50
/dev/ptmxfds, +25/dev/ttys*fds, system-wide device count 84 → 134 (2 devices per failure). 25 successful spawns, each fully exited anddestroy()ed, still left 25/dev/ptmxfds open (1 device each).Impact
macOS caps pty devices system-wide at
kern.tty.ptmx_max(default 511). Because failures leak two devices each, exhaustion is self-amplifying: at the ceiling every spawn fails, every failure consumes two more devices, and the process never recovers. An Electron app of ours accumulated 479 open masters in a 31-minute session against 28 real terminals, after which every spawn on the machine failed until the process exited.Suggested fix
pty_posix_spawn, close*master(andslaveonce opened) on every error path, setting*master = -1; closeslavein the parent unconditionally afterposix_spawn, and close*mastertoo when*err != 0.low_fdswere actually opened and close all of them, e.g.size_t opened = count < 3 ? count + 1 : 3; for (size_t i = 0; i < opened; i++) if (low_fds[i] >= 0) close(low_fds[i]);posix_spawnerrno in the thrown message —"posix_spawnp failed."currently discards the one datum (EMFILE vs EAGAIN vs ENOENT) that would let applications diagnose this.