From c424c4b1b9df1a6d30651a52535a5bf9e2aa4baa Mon Sep 17 00:00:00 2001 From: dhaern Date: Mon, 5 Oct 2026 14:17:42 +0000 Subject: [PATCH] fix(pty): release the native PTY when the process exits The native PTY is only closed inside Terminal.kill(), so a process that exits on its own leaked file descriptors and threads (about +4 fds and +2 threads per PTY). Release it from the exit handler, guarded by exitAt because releasing can emit onExit again synchronously. Also simplify the timeout kill path and inline clearAllSessions. --- src/plugin/pty/session-lifecycle.ts | 34 +++++++++++------------------ test/session-lifecycle.test.ts | 21 ++++++++++++++++++ 2 files changed, 34 insertions(+), 21 deletions(-) create mode 100644 test/session-lifecycle.test.ts diff --git a/src/plugin/pty/session-lifecycle.ts b/src/plugin/pty/session-lifecycle.ts index 6927998b..8f30e62b 100644 --- a/src/plugin/pty/session-lifecycle.ts +++ b/src/plugin/pty/session-lifecycle.ts @@ -46,22 +46,8 @@ export class SessionLifecycleManager { const timeoutMs = session.timeoutSeconds * 1000 const timeoutHandle = setTimeout(() => { - this.sessionTimeouts.delete(session.id) - - const currentSession = this.sessions.get(session.id) - if (!currentSession || currentSession.status !== 'running') { - return - } - - // Persist the timeout reason before reusing the regular kill flow. - currentSession.timedOut = true - currentSession.status = 'killing' - - try { - currentSession.process?.kill() - } catch { - // Ignore kill errors - } + session.timedOut = true + this.kill(session.id) }, timeoutMs) this.sessionTimeouts.set(session.id, timeoutHandle) @@ -121,6 +107,11 @@ export class SessionLifecycleManager { }) session.process?.onExit(({ exitCode, signal }) => { + // Releasing the native PTY can emit onExit again synchronously. + if (session.exitAt) { + return + } + this.clearSessionTimeout(session.id) // Flush any remaining incomplete line in the buffer @@ -136,6 +127,11 @@ export class SessionLifecycleManager { // When the process stopped, so a reader can report how long it ran: the // buffer keeps the output but nothing else records the end. session.exitAt = new Date() + try { + session.process?.kill() + } catch { + // Ignore kill errors + } onExit(session, exitCode) }) } @@ -178,16 +174,12 @@ export class SessionLifecycleManager { return true } - private clearAllSessionsInternal(): void { + clearAllSessions(): void { for (const id of [...this.sessions.keys()]) { this.kill(id, true) } } - clearAllSessions(): void { - this.clearAllSessionsInternal() - } - cleanupBySession(parentSessionId: string): void { for (const [id, session] of this.sessions) { if (session.parentSessionId === parentSessionId) { diff --git a/test/session-lifecycle.test.ts b/test/session-lifecycle.test.ts new file mode 100644 index 00000000..f6c9c145 --- /dev/null +++ b/test/session-lifecycle.test.ts @@ -0,0 +1,21 @@ +import { expect, it, mock, spyOn } from 'bun:test' +import { SessionLifecycleManager } from '../src/plugin/pty/session-lifecycle.ts' + +it('releases the native PTY on natural exit without duplicating the exit callback', async () => { + const manager = new SessionLifecycleManager() + const { promise: exited, resolve } = Promise.withResolvers() + const onExit = mock(() => resolve()) + const info = manager.spawn( + { command: 'sh', args: ['-c', 'exit 3'], parentSessionId: 'test-session' }, + () => {}, + onExit + ) + const session = manager.getSession(info.id) + if (!session?.process) throw new Error('Expected a spawned PTY process') + const kill = spyOn(session.process, 'kill') + await exited + expect(kill).toHaveBeenCalledTimes(1) + expect(session.status).toBe('exited') + expect(session.exitCode).toBe(3) + expect(onExit).toHaveBeenCalledTimes(1) +})