Repository navigation
Description
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, 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 openMeasured 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(andslaveonce opened) on every error path, setting*master = -1; closeslavein the parent unconditionally afterposix_spawn, and close*mastertoo when*err != 0. - Track how many
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]); - Ideally include the
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.
Activity
fix(pty): patch node-pty's darwin fd leak locally (microsoft/node-pty…
ElBartox commentedon Sep 7, 2026
Independent confirmation of Leak 2, plus field data on how bad it gets in a long-running app.
Environment: node-pty 1.1.0, Electron 43, macOS 26.6.2 (arm64),
kern.tty.ptmx_max= 511.Isolated measurement
Spawning 5 shells and closing them, counting
/dev/ptmxfds held by the process (lsof -p <pid> | grep -w ptmx):kill()kill()+destroy()Exactly one fd leaked per successful spawn, which matches the
low_fds[0]off-by-one you identified. Worth noting for anyone arriving here looking for a workaround:destroy()is not one. It closes the socket wrappingterm.fd, but the orphaned descriptor is the other one — opened by the native prologue and never exposed to JS (master/slaveareundefinedon thespawn()path, andp.fdis already closed by then). There is no API-level way to reclaim it; only exiting the process does.Field impact
A desktop app left running for a few days accumulated 391 leaked ptmx fds in a single process. Together with other processes on the machine that reached 510 of 511.
The important part is the blast radius: once the global table is exhausted, no process on the machine can allocate a PTY — not just the app that leaked them. The failure also surfaces in a way that points at the wrong thing. The user-visible error is the generic
with the working directory in the message, so it reads as "this folder is broken". It is not: spawning failed identically for
/tmpand$HOME. Diagnosing it meant reproducing the exhaustion deliberately (holding 105/dev/ptmxfds open) to confirm the count, rather than anything about the spawn arguments.A cheap probe consumers can use to tell this apart from a real spawn failure —
open("/dev/ptmx")fails precisely when the table is full:Happy to test a patch against the same reproduction if that helps.
ohadpr commentedon Sep 29, 2026
Both leaks in this report are already fixed on
main— and there is a third one in the same per-spawn path that is worth adding to the list. The problem now is purely that none of it has shipped to the stable line.Already fixed on main
low_fdsoff-by-one) and the error-path master/slave leaks — fix: /dev/ptmx leak on macOS #882, merged 2026-01-28 (af053f22). It replacesfor (; count > 0; count--) close(low_fds[count])withfor (size_t i = 0; i <= count; i++) close(low_fds[i]), closesslavein the parent unconditionally afterposix_spawn, addsgoto donecleanup on every error path, and — addressing suggestion 3 above — replaces"posix_spawnp failed."withformat_error(), so the thrown message finally carries the errno.SetupExitCallbackopensint kq = HANDLE_EINTR(kqueue())(pty.cc:138in 1.1.0) and never closes it, so a kqueue is lost on every spawn as well, once the child is reaped. Fixed by fix: close kqueue fd in SetupExitCallback on macOS #931.All three are inside
#if defined(__APPLE__), so Linux (plainforkpty+waitpid) is unaffected — useful for triage, since the reports all come from macOS.Measured: 1.1.0 loses three descriptors per spawn, beta.15 loses none
8 spawn/exit cycles, each child fully exited and released, counting the process's own fds by kind:
/dev/ptmx(revoked)after teardown)KQUEUEConfirming ElBartox's point that
destroy()is not a workaround: it is flat at +8 with or without it. Field data from a terminal multiplexer daemon on macOS 26.6.2 (arm64), 1.1.0, after a day of ordinary use: 505/dev/ptmxhandles held againstkern.tty.ptmx_max= 511, with only 72 ptys actually in use and 362 descriptors in the kernel's(revoked)state. At that point nothing on the machine could allocate a pty — not the app, not a plain terminal, not a browser.Harness, no dependencies beyond node-pty:
The actual remaining ask
npm dist-tagsstill readslatest: 1.1.0,beta: 1.2.0-beta.15. 1.1.0 predates every one of these fixes, so the default install — eight months after #882 merged — still leaks three descriptors per spawn and still reports the exhaustion as a bareposix_spawnp failed. Every report in this thread is against 1.1.0 for that reason.Is there anything blocking a stable 1.2.0, or a 1.1.1 with just the fd fixes backported? Happy to help test. If a release is not close, it would be worth saying so in the README, because right now the only way to find out that the fix exists is to read
mainand diff it against what npm installed.Agent: node-pty 1.2.0-beta.15, stop leaking PTYs on macOS (0.14.2)
oskarkri commentedon Oct 5, 2026
One edge left in the #882 cleanup on
main/ 1.2.0-beta.15 (src/unix/pty.cc,pty_posix_spawn): if all threeposix_openpt()calls in the low-fd prologue fail — which is exactly the pty-exhausted /EMFILEstate this thread is about — the loop exits withcount == 3, andreads
low_fds[3], one past the array, and passes whatever is in that stack slot toclose(). Before #882 this was effectively unreachable, because the earlyreturnafter a failed masterposix_openpt()skipped the cleanup; withgoto doneit is now reached on that path.In the beta.15 darwin-arm64 prebuild I could not get it to close a live descriptor (50 spawn attempts at the fd ceiling with every fd 0–255 open, none closed), so it looks latent in that particular build — but it is UB, and the slot is shared with other locals in
PtyFork, so another build could behave differently.Possible fix: initialize
int low_fds[3] = {-1, -1, -1};,breakon the first-1in the prologue, and bound the cleanup, e.g.for (size_t i = 0; i < 3 && i <= count; i++) if (low_fds[i] >= 0) close(low_fds[i]);.