diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index faea1ef84d..7116f44611 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -412,7 +412,7 @@ export class LocalPtySession implements PtyBackendSession { return } const foreground = await this.terminal.inspectForeground() - if (this.active !== operation || this.closing) return + if (this.active !== operation || this.closing || this.interrupting === operation) return const idleFor = Date.now() - this.lastOutputAt if (this.promptSeen && foreground !== undefined && this.shellPgid === undefined) { this.shellPgid = foreground.processGroupId @@ -439,7 +439,7 @@ export class LocalPtySession implements PtyBackendSession { this.settleActive('inferred_idle') } } catch (error: unknown) { - if (this.active === operation && !this.closing) this.failActive(error) + if (this.active === operation && !this.closing && this.interrupting !== operation) this.failActive(error) } finally { this.polling = false const active = this.active diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 8d11f8e6f4..27abfcf74e 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -406,6 +406,7 @@ describe('LocalPtySession readiness and output', () => { await Promise.resolve() await vi.advanceTimersByTimeAsync(10) expect(inspections).toBe(2) + await vi.advanceTimersByTimeAsync(50) expect(operation.cancel()).toBe(true) let settled = false void operation.done.then(() => { settled = true }) @@ -423,6 +424,54 @@ describe('LocalPtySession readiness and output', () => { expect((await operation.done).waitReason).toBe('session_exit') }) + it('does not let an in-flight readiness failure release a canceled send before signalling settles', async () => { + vi.useFakeTimers() + const terminal = new FakeTerminal() + const inspector = new FakeInspector() + const session = makeSession(terminal, inspector, config()) + await initialize(session, terminal) + + const readiness = Promise.withResolvers() + let inspections = 0 + terminal.inspectForeground = async () => { + inspections += 1 + if (inspections === 1) return { processGroupId: 456, inputWaiting: false } + if (inspections === 2) return await readiness.promise + return { processGroupId: 456, inputWaiting: true } + } + const signalling = Promise.withResolvers() + const signalled = Promise.withResolvers() + terminal.signalForeground = async (signal) => { + await signalling.promise + const foreground = await terminal.inspectForeground() + if (foreground === undefined) throw new Error('cannot resolve foreground') + inspector.signalGroup(foreground.processGroupId, signal) + signalled.resolve(foreground.processGroupId) + return foreground.processGroupId + } + + const operation = session.startSend({ text: 'first', submit: true }) + await Promise.resolve() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(10) + expect(inspections).toBe(2) + expect(operation.cancel()).toBe(true) + let settled = false + void operation.done.then(() => { settled = true }) + + readiness.reject(new Error('inspection failed during cancellation')) + await Promise.resolve() + await Promise.resolve() + expect(settled).toBe(false) + expect(() => session.startSend({ text: 'successor', submit: true })).toThrow(PtyError) + + signalling.resolve(undefined) + expect(await signalled.promise).toBe(456) + expect(inspector.groups).toContainEqual([456, 'SIGINT']) + await session.close('test complete') + expect((await operation.done).waitReason).toBe('session_exit') + }) + it('signals only after an in-flight provider write lands', async () => { vi.useFakeTimers() const terminal = new FakeTerminal()