fix(pty): join asynchronous terminal operations
This commit is contained in:
@@ -165,6 +165,7 @@ export class LocalPtySession implements PtyBackendSession {
|
|||||||
private activeTimer: NodeJS.Timeout | undefined
|
private activeTimer: NodeJS.Timeout | undefined
|
||||||
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
private activeDeadlineTimer: NodeJS.Timeout | undefined
|
||||||
private activeAbort: (() => void) | undefined
|
private activeAbort: (() => void) | undefined
|
||||||
|
private readonly terminalOperations = new Set<Promise<void>>()
|
||||||
private writing: LocalSendOperation | undefined
|
private writing: LocalSendOperation | undefined
|
||||||
private pollingReady: LocalSendOperation | undefined
|
private pollingReady: LocalSendOperation | undefined
|
||||||
private polling = false
|
private polling = false
|
||||||
@@ -237,10 +238,18 @@ export class LocalPtySession implements PtyBackendSession {
|
|||||||
this.activeDeadlineTimer = setTimeout(() => {
|
this.activeDeadlineTimer = setTimeout(() => {
|
||||||
if (this.active === operation) this.settleActive('timeout', this.writing === operation)
|
if (this.active === operation) this.settleActive('timeout', this.writing === operation)
|
||||||
}, this.config.timeoutMs)
|
}, this.config.timeoutMs)
|
||||||
void this.beginSend(operation, request)
|
this.ownTerminalOperation(this.beginSend(operation, request))
|
||||||
return operation
|
return operation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Retain one contained provider operation until its asynchronous work finishes. */
|
||||||
|
private ownTerminalOperation(operation: Promise<void>): 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<void> {
|
private async beginSend(operation: LocalSendOperation, request: PtySendRequest): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const foreground = await this.terminal.inspectForeground()
|
const foreground = await this.terminal.inspectForeground()
|
||||||
@@ -398,7 +407,7 @@ export class LocalPtySession implements PtyBackendSession {
|
|||||||
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
if (this.activeTimer !== undefined) clearTimeout(this.activeTimer)
|
||||||
this.activeTimer = setTimeout(() => {
|
this.activeTimer = setTimeout(() => {
|
||||||
this.activeTimer = undefined
|
this.activeTimer = undefined
|
||||||
void this.pollReadiness(operation)
|
this.ownTerminalOperation(this.pollReadiness(operation))
|
||||||
}, delayMs)
|
}, delayMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +476,7 @@ export class LocalPtySession implements PtyBackendSession {
|
|||||||
this.activeTimer = undefined
|
this.activeTimer = undefined
|
||||||
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
|
if (this.activeDeadlineTimer !== undefined) clearTimeout(this.activeDeadlineTimer)
|
||||||
this.activeDeadlineTimer = undefined
|
this.activeDeadlineTimer = undefined
|
||||||
|
this.pollingReady = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearActive(): void {
|
private clearActive(): void {
|
||||||
@@ -493,9 +503,9 @@ export class LocalPtySession implements PtyBackendSession {
|
|||||||
|
|
||||||
private interrupt(operation: LocalSendOperation): void {
|
private interrupt(operation: LocalSendOperation): void {
|
||||||
if (this.active !== operation) return
|
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)
|
if (this.active === operation) this.failActive(error, this.writing === operation)
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
private async closeOnce(reason: string): Promise<void> {
|
private async closeOnce(reason: string): Promise<void> {
|
||||||
@@ -508,6 +518,7 @@ export class LocalPtySession implements PtyBackendSession {
|
|||||||
if (!quiescent) {
|
if (!quiescent) {
|
||||||
throw new Error(`PTY cleanup failed (${reason}); terminal session did not reach quiescence`)
|
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
|
// 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
|
// for it first so that failure is reported instead of blocking forever on
|
||||||
// `done`; successful quiescence guarantees `done` can now settle status and
|
// `done`; successful quiescence guarantees `done` can now settle status and
|
||||||
|
|||||||
@@ -907,4 +907,51 @@ describe('LocalPtySession bounds, signals, and teardown', () => {
|
|||||||
await closing
|
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<undefined>()
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user