fix(daemon): release terminal attach cancellation listeners (#23191)

* fix(daemon): release terminal attach cancellation listeners

* fix(daemon): preserve attach wait settlement ordering

* fix(i18n): register existing diff note fallback strings

---------

Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
This commit is contained in:
OrcaWin
2026-09-26 13:55:22 -07:00
committed by GitHub
co-authored by m4air
parent 0d7659d5f8
commit f6631ddeee
4 changed files with 56 additions and 17 deletions
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest'
import { waitForTerminalAttachOperation } from './terminal-attach-cancellation'
describe('terminal attach cancellation', () => {
it('removes cancellation listeners when the operation settles first', async () => {
const controller = new AbortController()
const removeListener = vi.spyOn(controller.signal, 'removeEventListener')
await expect(
waitForTerminalAttachOperation(Promise.resolve('ready'), controller.signal, 'session-1')
).resolves.toBe('ready')
expect(removeListener).toHaveBeenCalledTimes(1)
})
it('preserves operation-first ordering when settlement and abort share a turn', async () => {
const controller = new AbortController()
const operation = Promise.withResolvers<string>()
const waiting = waitForTerminalAttachOperation(
operation.promise,
controller.signal,
'session-3'
)
operation.resolve('ready')
controller.abort()
await expect(waiting).resolves.toBe('ready')
})
it('rejects promptly on cancellation while the operation remains pending', async () => {
const controller = new AbortController()
const operation = new Promise<void>(() => {})
const waiting = waitForTerminalAttachOperation(operation, controller.signal, 'session-2')
controller.abort()
await expect(waiting).rejects.toMatchObject({ name: 'TerminalAttachCanceledError' })
})
})
+11 -11
View File
@@ -1,17 +1,17 @@
import { TerminalAttachCanceledError } from './daemon-errors'
import { PromiseSettlementWaiters } from '../../shared/promise-settlement-waiters'
/** Never resolves; only rejects, so it can bound a wait without settling it. */
export function rejectOnAbort(signal: AbortSignal | undefined, sessionId: string): Promise<never> {
export function waitForTerminalAttachOperation<T>(
operation: Promise<T>,
signal: AbortSignal | undefined,
sessionId: string
): Promise<T> {
if (!signal) {
return new Promise<never>(() => {})
return operation
}
return new Promise<never>((_resolve, reject) => {
if (signal.aborted) {
reject(new TerminalAttachCanceledError(sessionId))
return
}
signal.addEventListener('abort', () => reject(new TerminalAttachCanceledError(sessionId)), {
once: true
})
return new PromiseSettlementWaiters(operation).wait({
signal,
abortInMicrotask: true,
createAbortError: () => new TerminalAttachCanceledError(sessionId)
})
}
@@ -12,7 +12,7 @@ import type { TerminalHostTombstones } from './terminal-host-tombstones'
import type { TerminalSessionTeardown } from './terminal-session-teardown'
import { resolveDaemonSessionScrollbackRows } from './daemon-session-scrollback-window'
import { TerminalAttachCanceledError } from './daemon-errors'
import { rejectOnAbort } from './terminal-attach-cancellation'
import { waitForTerminalAttachOperation } from './terminal-attach-cancellation'
import { SessionNotFoundError } from './types'
import { resolveWslSessionContext } from './wsl-session-context'
@@ -47,10 +47,11 @@ export async function createOrAttachTerminalSession(
// reaches this a beat after the attach that retired it, and refusing surfaced the raw
// SessionNotFoundError to the user. Windows makes it the common case, where the plain-shell
// sweep holds the claim across an OS identity probe and taskkill (#18046).
await Promise.race([
await waitForTerminalAttachOperation(
deps.sessionTeardown.settle(opts.sessionId),
rejectOnAbort(opts.cancelSignal, opts.sessionId)
])
opts.cancelSignal,
opts.sessionId
)
deps.assertCreateAllowed()
existing = deps.sessions.get(opts.sessionId)
// Unkillable child, or a fresh teardown claimed it while we waited: still nobody's to recreate.
+2 -2
View File
@@ -21,7 +21,7 @@ import { TerminalHostTombstones } from './terminal-host-tombstones'
import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
import { createOrAttachTerminalSession } from './terminal-host-session-create'
import { TerminalAttachCanceledError } from './daemon-errors'
import { rejectOnAbort } from './terminal-attach-cancellation'
import { waitForTerminalAttachOperation } from './terminal-attach-cancellation'
import { randomUUID } from 'node:crypto'
import { pruneRetiredPtyIncarnations } from '../../shared/retired-pty-incarnations'
import {
@@ -84,7 +84,7 @@ export class TerminalHost {
// Why: the create ahead of us can be stuck on an unreachable share for
// minutes. Waiting unconditionally is what let one dead path strand every
// later create and attach for the session, so a canceled caller leaves.
await Promise.race([inFlight, rejectOnAbort(opts.cancelSignal, opts.sessionId)])
await waitForTerminalAttachOperation(inFlight, opts.cancelSignal, opts.sessionId)
this.assertCreateOrAttachAllowed(opts)
}
this.assertCreateOrAttachAllowed(opts)