Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 13 additions & 21 deletions src/plugin/pty/session-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
})
}
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions test/session-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>()
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)
})
Loading