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.
What happened?
Gemini CLI 0.62.0 on macOS retains one distinct
/dev/ptmxmaster 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-ptydependency is 1.1.0. SetGEMINI_BUNDLEbelow to the installed@google/gemini-cli/bundledirectory, save asrepro.mjs, and run withnode repro.mjs.Observed:
usePty=true:before=0, after=6; all six methodslydell-node-pty, exit 0, outputpty-probe.usePty=false:before=6, after=6; all six methodschild_process, exit 0, outputpty-probe.Expected: completed commands should release their native PTY master while the agent process remains alive.
Mitigation verified
Launching with
GEMINI_PTY_INFO=child_processmakes the installedgetPty()return null. Six commands routed through the real shell service then usechild_processand retain zero masters, including when the service receivesshouldUseNodePty=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.