Skip to content

PtyFork leaks slave file descriptor in parent process #13

@cturner

Description

@cturner

Summary

PtyFork in src/unix/pty.cc calls openpty() which returns both master and slave file descriptors, then uses posix_spawn to pass the slave to the child process. However, the parent process never closes its copy of the slave fd after the spawn succeeds.

This causes one file descriptor to leak per PTY spawn.

Impact

On long-running processes (terminal multiplexers, IDE backends, agent frameworks), this eventually exhausts the system's PTY pool. On macOS, the default limit is 512 PTYs (kern.tty.ptmx_max). At typical usage rates, the pool is exhausted in 12-19 days, after which SSH, new terminal sessions, and any PTY-dependent operation fails.

Root cause

In src/unix/pty.cc PtyFork:

int master, slave;
int ret = pty_openpty(&master, &slave, nullptr, term, &winp);
// ...
auto error = posix_spawn(&pid, helper_path, &acts, &attrs, argv, env);
close(comms_pipe[1]);
// ❌ close(slave) is missing here

The slave fd has already been dup2'd to stdin/stdout/stderr of the child via posix_spawn_file_actions_adddup2. The parent's copy is no longer needed and should be closed.

Reproduction

const pty = require("@lydell/node-pty");
const { execSync } = require("child_process");
const pid = process.pid;

const p = pty.spawn("/bin/sh", ["-c", "exit 0"], { cols: 80, rows: 24 });
p.onExit(() => {
  setTimeout(() => {
    p.destroy();
    setTimeout(() => {
      // This will show 1 leaked /dev/ptmx fd (the slave)
      const fds = execSync(`lsof -p ${pid} 2>/dev/null | grep ptmx || true`).toString().trim();
      console.log("Leaked ptmx FDs:", fds || "(none)");
      process.exit(0);
    }, 500);
  }, 500);
});

After spawn + exit + destroy, one /dev/ptmx fd remains open. On macOS, both master and slave sides of a PTY pair opened via openpty() appear as /dev/ptmx in lsof. The master is properly closed by destroy(), but the slave is never closed.

Fix

Add close(slave) after posix_spawn succeeds, alongside the existing close(comms_pipe[1]):

close(comms_pipe[1]);
close(slave);

PR: #forthcoming

Environment

  • macOS 15.3 (arm64)
  • Node.js v22.22.0
  • @lydell/node-pty-darwin-arm64 (latest as of 2026-03-15)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions