fix(pty): detach cancellation before close drain

This commit is contained in:
Tianyi Cui
2026-07-29 08:00:08 +08:00
parent 6e5d711099
commit a95e01d8b0
2 changed files with 26 additions and 1 deletions

View File

@@ -518,13 +518,16 @@ export class LocalPtySession implements PtyBackendSession {
if (!quiescent) {
throw new Error(`PTY cleanup failed (${reason}); terminal session did not reach quiescence`)
}
// Quiescence is the active send's terminal outcome. Detach its abort
// listener before snapshotting provider operations so no late interrupt
// can enter the owned set after the drain starts.
this.settleActive('session_exit')
await Promise.all(this.terminalOperations)
// Whole-session cleanup can fail before the top-level process exits. Wait
// for it first so that failure is reported instead of blocking forever on
// `done`; successful quiescence guarantees `done` can now settle status and
// drain the terminal output.
await this.completion
this.settleActive('session_exit')
this.terminal.output.off('data', this.onTerminalData)
this.terminal.output.off('end', this.onTerminalEnd)
this.terminal.output.off('error', this.onTerminalError)

View File

@@ -954,4 +954,26 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
expect((await operation.done).waitReason).toBe('session_exit')
})
it('detaches active cancellation before draining terminal operations', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config())
await initialize(session, terminal)
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
terminal.inspectForeground = async () => await inspection.promise
const signalForeground = vi.spyOn(terminal, 'signalForeground')
const controller = new AbortController()
const operation = session.startSend({ text: 'pending inspection', submit: true, signal: controller.signal })
const closing = session.close('pending cancellation')
await Promise.resolve()
await Promise.resolve()
controller.abort('late cancellation')
expect(signalForeground).not.toHaveBeenCalled()
inspection.resolve({ processGroupId: 456, inputWaiting: false })
await closing
expect((await operation.done).waitReason).toBe('session_exit')
})
})