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)
Summary
PtyForkinsrc/unix/pty.cccallsopenpty()which returns both master and slave file descriptors, then usesposix_spawnto 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.ccPtyFork: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
After spawn + exit + destroy, one
/dev/ptmxfd remains open. On macOS, both master and slave sides of a PTY pair opened viaopenpty()appear as/dev/ptmxin lsof. The master is properly closed bydestroy(), but the slave is never closed.Fix
Add
close(slave)afterposix_spawnsucceeds, alongside the existingclose(comms_pipe[1]):PR: #forthcoming
Environment