Skip to content

[darwin] pty_posix_spawn leaks the pty master+slave when posix_spawn fails, and one ptmx fd on every successful spawn #950

Description

@eneskirca

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

  1. 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.
  2. 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]);
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions