From a95e01d8b02bc4882d840441b59d680b34d9ab39 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 29 Jul 2026 08:00:08 +0800 Subject: [PATCH] fix(pty): detach cancellation before close drain --- packages/pty/pty-local/src/session.ts | 5 ++++- packages/pty/pty-local/tests/session.spec.ts | 22 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 0fb9f4ab0b..267c7c17fc 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -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) diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index c5fc354106..cce384bf6b 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -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') + }) + })