镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

fix: close the PTY master fd when a session is reaped - #23

Open
brianKing88 wants to merge 1 commit into
cloudcli-ai:mainfrom
brianKing88:fix/pty-master-fd-leak
Open

brianKing88 wants to merge 1 commit into
cloudcli-ai:mainfrom
brianKing88:fix/pty-master-fd-leak

Conversation

@brianKing88

Copy link
Copy Markdown

Problem

After ~2.5 days of normal use, nothing on the host could open a terminal any more — not the plugin, not the user's own emulator:

# Ghostty
Your system cannot allocate any more pty devices.

# Terminal.app
[forkpty: Device not configured]

The plugin server was holding 508 /dev/ptmx handles with zero live children:

$ lsof /dev/ptmx | awk 'NR>1{print $1}' | sort | uniq -c | sort -rn
 508 node        # cloudcli-plugin-terminal/dist/server.js, up 2d10h
   2 Claude
   1 zed

$ ls /dev/ttys* | wc -l      # 527
$ sysctl -n kern.tty.ptmx_max # 511

Zero children, 508 master fds: the shells were long gone, the fds were not.

Cause

killSession() tears a session down with pty.kill(), which on POSIX is process.kill(pid, SIGHUP) and nothing else:

UnixTerminal.prototype.kill = function (signal) {
  try { process.kill(this.pid, signal || 'SIGHUP'); } catch (e) { /* swallow */ }
};

node-pty closes the master fd from its own exit handler, so the fd comes back only if the shell actually dies from that SIGHUP. A shell that traps the signal, or that sits in a foreground job outliving the session, keeps the fd for the lifetime of the plugin server. Every detached-and-reaped session strands one.

PTYs are a fixed machine-wide resource (kern.tty.ptmx_max on macOS, 511 by default; /proc/sys/kernel/pty/max on Linux), so this is not a slow memory creep — it eventually takes the whole machine's terminal capability down with it.

destroy() is the API that actually releases the fd: it closes the socket first and signals afterwards, which is safe on both a live and an already-exited shell.

Fix

Teardown goes through a new disposePty() that prefers destroy() and falls back to kill() on a node-pty that does not expose it. destroy() is declared optional on PtyProcess because node-pty's public typings omit it, although both UnixTerminal and WindowsTerminal implement it.

The onExit path calls it too — node-pty's close chain has only a timeout fallback there, and destroy() is a no-op once the socket is down, so the insurance is free.

Measurements

20 sessions, each a shell that traps SIGHUP (trap '' HUP; sleep 300), torn down the way killSession() does it. fds stranded per session:

node-pty kill() destroy()
1.1.0 (currently pinned) 3 2
1.2.0-beta.18 1 0

Repro, if you want to confirm it independently:

import { execSync } from 'node:child_process';
import pty from 'node-pty';

const mode = process.argv[2];                       // kill | destroy
const count = () => execSync(`lsof -p ${process.pid}`).toString()
  .split('\n').filter((l) => /\/dev\/(ptmx|ttys)/.test(l)).length;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const before = count();
for (let i = 0; i < 20; i++) {
  const p = pty.spawn('/bin/sh', [], { cols: 80, rows: 24, encoding: 'utf8' });
  p.onData(() => {}); p.onExit(() => {});
  p.write("trap '' HUP\nsleep 300\n");
  await sleep(120);
  try { mode === 'destroy' ? p.destroy() : p.kill(); } catch {}
}
await sleep(2000);
console.log(`${mode}: ${(count() - before) / 20} fd per session`);

Relationship to the open PRs

Verification

  • npm run typecheck — clean
  • npm test — 21/24, the same 3 failures as main with this patch stashed, so no regression. They are pre-existing and environmental: one asserts a cwd path that macOS resolves through the /var → /private/var symlink, two time out matching shell output.
  • Ran the patched plugin under CloudCLI for the session that produced this report: 0 PTY fds held, pool back to 10/511.

🤖 Generated with Claude Code

`killSession()` tears a session down with `pty.kill()`, which on POSIX is
`process.kill(pid, SIGHUP)` and nothing else. node-pty closes the master
fd from its own `exit` handler, so that fd is only released if the shell
actually dies from the SIGHUP. A shell that traps the signal, or that
sits in a foreground job outliving the session, leaves the fd open for
the lifetime of the plugin server.

PTYs are a fixed machine-wide resource -- `kern.tty.ptmx_max` on macOS
(511 by default), `/proc/sys/kernel/pty/max` on Linux. Reaped sessions
accumulate until the pool is gone, and then nothing on the host can open
a terminal any more. On the install where this was found, the plugin
server had been up for ~2.5 days and held 508 `/dev/ptmx` handles with
zero live children; Ghostty, Terminal.app and Cursor were all failing to
allocate a PTY.

Teardown now goes through `disposePty()`, which prefers `destroy()` --
it closes the fd first and signals afterwards, so it is safe on a live
and on an already-exited shell -- and falls back to `kill()` for a
node-pty that does not expose it. `destroy()` is declared optional on
`PtyProcess` since node-pty's public typings omit it, though both
UnixTerminal and WindowsTerminal implement it.

The `onExit` path calls it too. node-pty's close chain has only a
timeout fallback there, and `destroy()` is a no-op once the socket is
down, so the insurance is free.

Measured over 20 sessions whose shell traps SIGHUP, fds stranded per
session:

                     kill()   destroy()
    1.1.0               3         2
    1.2.0-beta.18       1         0

Reaching zero also needs the node-pty bump in cloudcli-ai#15; on the pinned 1.1.0
this change reduces the leak without eliminating it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant