diff --git a/src/plugin/pty/session-lifecycle.ts b/src/plugin/pty/session-lifecycle.ts index 6927998..8f30e62 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 0000000..f6c9c14 --- /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) +})