diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index d780719c54..0fb9f4ab0b 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -165,6 +165,7 @@ export class LocalPtySession implements PtyBackendSession { private activeTimer: NodeJS.Timeout | undefined private activeDeadlineTimer: NodeJS.Timeout | undefined private activeAbort: (() => void) | undefined + private readonly terminalOperations = new Set>() private writing: LocalSendOperation | undefined private pollingReady: LocalSendOperation | undefined private polling = false @@ -237,10 +238,18 @@ export class LocalPtySession implements PtyBackendSession { this.activeDeadlineTimer = setTimeout(() => { if (this.active === operation) this.settleActive('timeout', this.writing === operation) }, this.config.timeoutMs) - void this.beginSend(operation, request) + this.ownTerminalOperation(this.beginSend(operation, request)) return operation } + /** Retain one contained provider operation until its asynchronous work finishes. */ + private ownTerminalOperation(operation: Promise): void { + const tracked = operation.finally(() => { this.terminalOperations.delete(tracked) }) + this.terminalOperations.add(tracked) + // beginSend(), pollReadiness(), and interrupt() contain their own boundary errors. + void tracked + } + private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise { try { const foreground = await this.terminal.inspectForeground() @@ -398,7 +407,7 @@ export class LocalPtySession implements PtyBackendSession { if (this.activeTimer !== undefined) clearTimeout(this.activeTimer) this.activeTimer = setTimeout(() => { this.activeTimer = undefined - void this.pollReadiness(operation) + this.ownTerminalOperation(this.pollReadiness(operation)) }, delayMs) } @@ -467,6 +476,7 @@ export class LocalPtySession implements PtyBackendSession { this.activeTimer = undefined if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer) this.activeDeadlineTimer = undefined + this.pollingReady = undefined } private clearActive(): void { @@ -493,9 +503,9 @@ export class LocalPtySession implements PtyBackendSession { private interrupt(operation: LocalSendOperation): void { if (this.active !== operation) return - void this.terminal.signalForeground('SIGINT').catch((error: unknown) => { + this.ownTerminalOperation(this.terminal.signalForeground('SIGINT').then(() => {}, (error: unknown) => { if (this.active === operation) this.failActive(error, this.writing === operation) - }) + })) } private async closeOnce(reason: string): Promise { @@ -508,6 +518,7 @@ export class LocalPtySession implements PtyBackendSession { if (!quiescent) { throw new Error(`PTY cleanup failed (${reason}); terminal session did not reach quiescence`) } + 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 diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 40c60c570d..c5fc354106 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -907,4 +907,51 @@ describe('LocalPtySession bounds, signals, and teardown', () => { await closing }) + it('does not finish close while a pre-write terminal operation is pending', 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 operation = session.startSend({ text: 'must not run', submit: true }) + const closing = session.close('pending inspection') + let closed = false + void closing.then(() => { closed = true }) + await Promise.resolve() + await Promise.resolve() + expect(closed).toBe(false) + + inspection.resolve({ processGroupId: 456, inputWaiting: false }) + await closing + expect(closed).toBe(true) + expect(terminal.writes).toEqual([]) + expect((await operation.done).waitReason).toBe('session_exit') + }) + + it('does not finish close while a terminal write is pending', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const session = new LocalPtySession(terminal, config()) + await initialize(session, terminal) + + const write = Promise.withResolvers() + terminal.write = async () => { await write.promise } + const operation = session.startSend({ text: 'pending write', submit: true }) + await Promise.resolve() + await Promise.resolve() + const closing = session.close('pending write') + let closed = false + void closing.then(() => { closed = true }) + await Promise.resolve() + await Promise.resolve() + expect(closed).toBe(false) + + write.resolve(undefined) + await closing + expect(closed).toBe(true) + expect((await operation.done).waitReason).toBe('session_exit') + }) + })