From a0d1af93e4801c6b8b71929373e87ccd2a6660b8 Mon Sep 17 00:00:00 2001 From: HirokiKobayashi-R Date: Fri, 30 Jan 2026 04:36:19 +0900 Subject: [PATCH] fix(exec): clean up PTY resources to prevent FD leaks Add proper cleanup of node-pty resources when PTY process exits: - Dispose onData and onExit listeners - Call pty.kill() to release file descriptors This prevents gradual FD accumulation that leads to EBADF errors after extended gateway uptime. Closes #4102 --- src/agents/bash-tools.exec.ts | 20 ++++++++++++++++---- src/types/lydell-node-pty.d.ts | 6 ++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index b9de81872..e231a0cd2 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -79,11 +79,13 @@ const APPROVAL_SLUG_LENGTH = 8; type PtyExitEvent = { exitCode: number; signal?: number }; type PtyListener = (event: T) => void; +type PtyDisposable = { dispose: () => void }; type PtyHandle = { pid: number; write: (data: string | Buffer) => void; - onData: (listener: PtyListener) => void; - onExit: (listener: PtyListener) => void; + onData: (listener: PtyListener) => PtyDisposable; + onExit: (listener: PtyListener) => PtyDisposable; + kill: (signal?: string) => void; }; type PtySpawn = ( file: string, @@ -361,6 +363,8 @@ async function runExecProcess(opts: { let child: ChildProcessWithoutNullStreams | null = null; let pty: PtyHandle | null = null; let stdin: SessionStdin | undefined; + let ptyDataDisposable: PtyDisposable | null = null; + let ptyExitDisposable: PtyDisposable | null = null; if (opts.sandbox) { const { child: spawned } = await spawnWithFallback({ @@ -601,7 +605,7 @@ async function runExecProcess(opts: { if (pty) { const cursorResponse = buildCursorPositionResponse(); - pty.onData((data) => { + ptyDataDisposable = pty.onData((data) => { const raw = data.toString(); const { cleaned, requests } = stripDsrRequests(raw); if (requests > 0) { @@ -664,10 +668,18 @@ async function runExecProcess(opts: { }; if (pty) { - pty.onExit((event) => { + ptyExitDisposable = pty.onExit((event) => { const rawSignal = event.signal ?? null; const normalizedSignal = rawSignal === 0 ? null : rawSignal; handleExit(event.exitCode ?? null, normalizedSignal); + // Clean up PTY resources to prevent FD leaks + try { + ptyDataDisposable?.dispose(); + ptyExitDisposable?.dispose(); + pty.kill(); + } catch { + // Ignore cleanup errors + } }); } else if (child) { child.once("close", (code, exitSignal) => { diff --git a/src/types/lydell-node-pty.d.ts b/src/types/lydell-node-pty.d.ts index be7c40b76..35d913efe 100644 --- a/src/types/lydell-node-pty.d.ts +++ b/src/types/lydell-node-pty.d.ts @@ -1,11 +1,13 @@ declare module "@lydell/node-pty" { export type PtyExitEvent = { exitCode: number; signal?: number }; export type PtyListener = (event: T) => void; + export type PtyDisposable = { dispose: () => void }; export type PtyHandle = { pid: number; write: (data: string | Buffer) => void; - onData: (listener: PtyListener) => void; - onExit: (listener: PtyListener) => void; + onData: (listener: PtyListener) => PtyDisposable; + onExit: (listener: PtyListener) => PtyDisposable; + kill: (signal?: string) => void; }; export type PtySpawn = (