Skip to content

macOS PTY master leak persists in 0.62.0 after completed shell commands (token-free repro) #29592

Description

@tmad4000

What happened?

Gemini CLI 0.62.0 on macOS retains one distinct /dev/ptmx master per completed ordinary shell command in a minimal token-free reproduction. This looks like a recurrence of #15945 after #25079. It exhausts the host PTY limit and prevents new terminal/login sessions (openpty: Device not configured).

On the affected host kern.tty.ptmx_max=999. Four long-lived Gemini processes held 85, 185, 222, and 79 distinct PTY device numbers respectively, with no direct child processes at the snapshot. These are distinct masters, not duplicate references to a few terminals. No MCP server or model call is needed for the reproduction below.

Minimal reproduction

Environment: Gemini CLI 0.62.0 (npm bundled distribution), Node v26.7.0, macOS 26.5.2 arm64. The bundled optional @lydell/node-pty dependency is 1.1.0. Set GEMINI_BUNDLE below to the installed @google/gemini-cli/bundle directory, save as repro.mjs, and run with node repro.mjs.

import path from 'node:path';
import {pathToFileURL} from 'node:url';
import {spawnSync} from 'node:child_process';
const bundle = process.env.GEMINI_BUNDLE;
// The actual 0.62.0 CLI path is gemini.js -> gemini-J6X2T2ZI.js -> this chunk.
// Update this filename from the CLI entry-point imports for a different build.
const entry = 'chunk-MLY4WQFO.js';
const {ShellExecutionService} = await import(pathToFileURL(path.join(bundle, entry)));
const count = () => spawnSync('lsof', ['-nP', '-a', '-p', String(process.pid), '/dev/ptmx'], {encoding:'utf8'}).stdout.split('\n').filter(x => x.includes('/dev/ptmx')).length;
for (const usePty of [true, false]) {
  const before = count();
  const methods = [];
  for (let i=0; i<6; i++) {
    const handle = await ShellExecutionService.execute(
      'printf pty-probe', '/tmp', () => {},
      new AbortController().signal, usePty,
      {env:{PATH:process.env.PATH, HOME:process.env.HOME},
       sanitizationConfig:{allowedEnvironmentVariables:[], blockedEnvironmentVariables:[]}}
    );
    const result = await handle.result;
    methods.push([result.executionMethod, result.exitCode, result.output]);
  }
  await new Promise(resolve => setTimeout(resolve, 5000));
  console.log({usePty, before, after:count(), methods});
}
process.exit(0);

Observed:

  • usePty=true: before=0, after=6; all six methods lydell-node-pty, exit 0, output pty-probe.
  • usePty=false: before=6, after=6; all six methods child_process, exit 0, output pty-probe.
  • Counts remain unchanged after a five-second grace period following each command batch, ruling out short delayed-close scheduling. Normal probe exit releases its own retained handles.

Expected: completed commands should release their native PTY master while the agent process remains alive.

Mitigation verified

Launching with GEMINI_PTY_INFO=child_process makes the installed getPty() return null. Six commands routed through the real shell service then use child_process and retain zero masters, including when the service receives shouldUseNodePty=true. This avoids interactive shell PTYs but preserves the outer agent TUI. It does not recover already-retained handles in running agents.

The reproduction identifies the native shell path as the causal boundary; I have not yet isolated the precise native descriptor ownership defect. It does not depend on background commands, aborted commands, or MCP subprocess cleanup.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/coreIssues related to User Interface, OS Support, Core Functionalityeffort/large3+ days: platform-specific, architecture, memory leaksstatus/bot-triaged

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions