Files
deepseek-harness/packages/terminal/terminal-bash/tests/session.spec.ts
Tianyi Cui a2d0f7f411 refactor: apply repository naming contract
Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
2026-08-13 00:54:38 +08:00

1296 lines
53 KiB
TypeScript
Raw Blame History

import { afterEach, describe, expect, it, vi } from 'vitest'
import { PassThrough } from 'node:stream'
import { LocalPtySession } from '@deepseek-ai/dsh-terminal-bash/src/session.ts'
import type { ResolvedConfig } from '@deepseek-ai/dsh-terminal-bash/src/config.ts'
import type { TerminalSendOperation, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal'
import type {
SubprocessOutcome,
SubprocessTerminalHandle,
SubprocessTerminalSignal,
} from '@deepseek-ai/dsh-subprocess'
import { TerminalError } from '@deepseek-ai/dsh-terminal'
import type {
ProcessIdentity,
ProcessInspector,
} from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts'
class FakeInspector implements ProcessInspector {
pgid: number | undefined = 456
waiting = false
members: ProcessIdentity[] = []
alive = new Set<number>()
groups: Array<[number, TerminalSignal]> = []
processes: Array<[number, 'SIGTERM' | 'SIGKILL']> = []
throwGroup = false
throwProcess = false
removeOnSignal = true
foregroundPgid() { return this.pgid }
isStdinWaiting() { return this.waiting }
processTree() { return this.members }
processSession() { return [] }
isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) }
signalGroup(pgid: number, signal: TerminalSignal) {
if (this.throwGroup) throw new Error('group failed')
this.groups.push([pgid, signal])
}
signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') {
if (this.throwProcess) throw new Error('process raced')
this.processes.push([identity.pid, signal])
if (this.removeOnSignal) this.alive.delete(identity.pid)
}
}
class FakeTerminal implements SubprocessTerminalHandle {
pid = 123
readonly output = new PassThrough()
readonly writes: string[] = []
readonly kills: string[] = []
readonly outcome = Promise.withResolvers<SubprocessOutcome>()
readonly done = this.outcome.promise
throwWrite = false
throwKill = false
autoExitOnKill = true
terminateError: Error | undefined
private cleanup: Promise<void> | undefined
constructor(public inspector = new FakeInspector()) {}
emitData(data: string): void {
this.output.write(Buffer.from(data, 'utf8'))
}
emitBytes(data: Uint8Array): void {
this.output.write(data)
}
emitError(error: Error): void {
this.output.emit('error', error)
}
emitFailure(error: unknown): void {
this.output.end()
this.outcome.reject(error)
}
emitExit(exitCode = 0, signal?: number): void {
this.output.end()
this.outcome.resolve({
exitCode: signal === undefined || signal === 0 ? exitCode : null,
signal: signal === 9 ? 'SIGKILL' : signal === 15 ? 'SIGTERM' : null,
})
}
async write(data: string): Promise<void> {
if (this.throwWrite) throw new Error('write failed')
this.writes.push(data)
}
async inspectForeground() {
const processGroupId = this.inspector.foregroundPgid()
return processGroupId === undefined
? undefined
: { processGroupId, inputWaiting: this.inspector.isStdinWaiting() }
}
async signalForeground(signal: SubprocessTerminalSignal): Promise<number> {
const foreground = await this.inspectForeground()
if (foreground === undefined) throw new Error(`cannot resolve foreground process group for terminal ${this.pid}`)
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
}
this.inspector.signalGroup(foreground.processGroupId, signal)
return foreground.processGroupId
}
terminate(): Promise<void> {
if (this.cleanup !== undefined) return this.cleanup
const cleanup = this.terminateOnce()
this.cleanup = cleanup
void cleanup.catch(() => { this.cleanup = undefined })
return cleanup
}
private async terminateOnce(): Promise<void> {
if (this.terminateError !== undefined) throw this.terminateError
if (this.throwKill) throw new Error('kill failed')
this.kills.push('SIGTERM')
if (this.autoExitOnKill) this.emitExit(0, 15)
}
}
function makeSession(
terminal: FakeTerminal,
inspector: FakeInspector,
resolved: ResolvedConfig,
): LocalPtySession {
terminal.inspector = inspector
return new LocalPtySession(terminal, resolved)
}
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: [], rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, exactProbeAfterMs: 20, idleSilenceMs: 50, handoffGraceMs: 10, timeoutMs: 100,
disposeGraceMs: 20,
...overrides,
}
}
afterEach(() => { vi.useRealTimers() })
async function initialize(session: LocalPtySession, terminal: FakeTerminal): Promise<void> {
const pending = session.initialize()
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await pending
}
describe('LocalPtySession readiness and output', () => {
it('lets queued terminal output run before the first post-write readiness poll', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const inspect = terminal.inspectForeground.bind(terminal)
let inspections = 0
terminal.inspectForeground = async () => {
inspections += 1
return await inspect()
}
const operation = session.startSend({ text: 'true', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(inspections).toBe(1)
await vi.advanceTimersByTimeAsync(0)
expect(inspections).toBe(1)
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('discards prompt readiness observed during asynchronous pre-write inspection', 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: 'long-running-command', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
terminal.emitData('\x1b]133;D;0\x07dsh> ')
inspection.resolve({ processGroupId: 456, inputWaiting: true })
await vi.advanceTimersByTimeAsync(20)
expect(terminal.writes).toEqual(['long-running-command\r'])
expect(settled).toBe(false)
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('captures prompt MOTD, writes submit explicitly, and settles exact stdin waits', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
expect(session.motd).toBe('dsh> ')
inspector.waiting = true
const operation = session.startSend({ text: 'python3', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(terminal.writes).toEqual(['python3\r'])
inspector.pgid = 789
terminal.emitData('Python\r\n>>> ')
await vi.advanceTimersByTimeAsync(20)
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', viewport: 'Python\n>>> ', sessionStatus: { kind: 'running' } })
expect(operation.cancel()).toBe(false)
})
it('does not reuse a pre-write stdin wait as post-write readiness', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
inspector.waiting = true
const operation = session.startSend({ text: 'echo ready', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
await vi.advanceTimersByTimeAsync(20)
expect(settled).toBe(false)
inspector.waiting = false
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(false)
inspector.waiting = true
await vi.advanceTimersByTimeAsync(10)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('tracks a pre-write wait exit before exact probing begins', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config({
exactProbeAfterMs: 50,
idleSilenceMs: 100,
timeoutMs: 200,
}))
await initialize(session, terminal)
inspector.waiting = true
const operation = session.startSend({ text: 'fast command', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
inspector.waiting = false
await vi.advanceTimersByTimeAsync(10)
inspector.waiting = true
await vi.advanceTimersByTimeAsync(30)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(true)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('distinguishes inferred idle, timeout, exit signal, and operation reads', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
inspector.pgid = undefined
const inferred = session.startSend({ text: 'sleep', submit: false })
terminal.emitData('working')
expect(inferred.readOutput()).toEqual({ delta: 'working', truncated: false })
await vi.advanceTimersByTimeAsync(60)
expect((await inferred.done).waitReason).toBe('inferred_idle')
const timeout = session.startSend({ text: 'blocked', submit: false })
await vi.advanceTimersByTimeAsync(40)
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(40)
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(30)
expect((await timeout.done).waitReason).toBe('timeout')
const exiting = session.startSend({ text: 'exit', submit: true })
terminal.emitExit(7, 9)
expect(await exiting.done).toMatchObject({ waitReason: 'session_exit', sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGKILL' } })
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
})
it('cancels with foreground-group SIGINT, observes AbortSignal, and contains write failures', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const controller = new AbortController()
const operation = session.startSend({ text: 'sleep', submit: true, signal: controller.signal })
expect(() => session.startSend({ text: 'again', submit: true })).toThrow('active send')
controller.abort()
await Promise.resolve()
await Promise.resolve()
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
expect(terminal.writes).not.toContain('\x03')
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
const aborted = new AbortController()
aborted.abort()
expect(() => session.startSend({ text: '', submit: false, signal: aborted.signal })).toThrow('aborted before write')
terminal.throwWrite = true
const failed = session.startSend({ text: 'x', submit: false })
await expect(failed.done).rejects.toThrow('write failed')
const failedInternal = failed as unknown as { append(text: string): void; fail(error: unknown): void }
failedInternal.append('ignored')
failedInternal.fail(new Error('ignored'))
})
it('does not write a send canceled during asynchronous foreground inspection', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
terminal.inspectForeground = async () => await inspection.promise
const controller = new AbortController()
const operation = session.startSend({ text: 'must not execute', submit: true, signal: controller.signal })
controller.abort()
inspection.resolve({ processGroupId: 456, inputWaiting: false })
await Promise.resolve()
await Promise.resolve()
expect(terminal.writes).toEqual([])
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
})
it('retains a canceled send when the pre-write inspection rejects while its signal is in flight', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const failed = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
terminal.inspectForeground = async () => await failed.promise
const uncanceled = session.startSend({ text: 'plain failure', submit: true })
failed.reject(new Error('inspect failed before write'))
await expect(uncanceled.done).rejects.toThrow('inspect failed before write')
terminal.inspectForeground = FakeTerminal.prototype.inspectForeground.bind(terminal)
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
terminal.inspectForeground = async () => await inspection.promise
const signalGate = Promise.withResolvers<undefined>()
terminal.signalForeground = async (signal) => {
await signalGate.promise
inspector.signalGroup(456, signal)
return 456
}
const controller = new AbortController()
const operation = session.startSend({ text: 'must stay owned', submit: true, signal: controller.signal })
controller.abort()
inspection.reject(new Error('transient inspection failure'))
await Promise.resolve()
await Promise.resolve()
// The slot stays reserved while the cancellation's foreground signal is in flight.
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
terminal.inspectForeground = async () => ({ processGroupId: 456, inputWaiting: false })
signalGate.resolve(undefined)
await vi.advanceTimersByTimeAsync(0)
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
})
it('retains a canceled send until asynchronous foreground signalling settles', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const signalGate = Promise.withResolvers<undefined>()
terminal.signalForeground = async (signal) => {
await signalGate.promise
const foreground = await terminal.inspectForeground()
if (foreground === undefined) throw new Error('cannot resolve foreground')
inspector.signalGroup(foreground.processGroupId, signal)
return foreground.processGroupId
}
const operation = session.startSend({ text: 'first', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(operation.cancel()).toBe(true)
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(100)
expect((await operation.done).waitReason).toBe('timeout')
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow('active send')
signalGate.resolve(undefined)
await vi.advanceTimersByTimeAsync(0)
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
expect(inspector.groups).not.toContainEqual([789, 'SIGINT'])
})
it('does not let an in-flight readiness inspection 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<{ processGroupId: number; inputWaiting: boolean }>()
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<undefined>()
const signalled = Promise.withResolvers<number>()
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)
await vi.advanceTimersByTimeAsync(50)
expect(operation.cancel()).toBe(true)
let settled = false
void operation.done.then(() => { settled = true })
readiness.resolve({ processGroupId: 456, inputWaiting: true })
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
expect(() => session.startSend({ text: 'successor', submit: true })).toThrow(TerminalError)
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('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<never>()
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<undefined>()
const signalled = Promise.withResolvers<number>()
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(TerminalError)
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()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const writeGate = Promise.withResolvers<undefined>()
terminal.write = async () => { await writeGate.promise }
const operation = session.startSend({ text: 'must be interrupted', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(operation.cancel()).toBe(true)
await vi.advanceTimersByTimeAsync(20)
expect(inspector.groups).toEqual([])
writeGate.resolve(undefined)
await vi.advanceTimersByTimeAsync(0)
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
terminal.emitData('\x1b]133;D;130\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await operation.done
})
it('does not signal when a cancelled provider write rejects', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const writeGate = Promise.withResolvers<undefined>()
terminal.write = async () => { await writeGate.promise }
const operation = session.startSend({ text: 'rejected write', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(operation.cancel()).toBe(true)
const rejected = expect(operation.done).rejects.toThrow('write failed after cancellation')
writeGate.reject(new Error('write failed after cancellation'))
await rejected
expect(inspector.groups).toEqual([])
const next = session.startSend({ text: '', submit: false })
await vi.advanceTimersByTimeAsync(100)
expect((await next.done).waitReason).toBe('inferred_idle')
})
it('releases a timed-out cancellation after the provider write and signal settle', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const writeGate = Promise.withResolvers<undefined>()
terminal.write = async () => { await writeGate.promise }
const operation = session.startSend({ text: 'slow cancelled write', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(operation.cancel()).toBe(true)
await vi.advanceTimersByTimeAsync(100)
expect((await operation.done).waitReason).toBe('timeout')
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow(expect.objectContaining({
code: 'SEND_ACTIVE',
}))
writeGate.resolve(undefined)
await vi.advanceTimersByTimeAsync(0)
expect(inspector.groups).toContainEqual([456, 'SIGINT'])
const next = session.startSend({ text: '', submit: false })
await vi.advanceTimersByTimeAsync(100)
expect((await next.done).waitReason).toBe('inferred_idle')
})
it('retains the absolute timeout after cancellation while output stays active', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const operation = session.startSend({ text: 'ignore-sigint-and-write', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(operation.cancel()).toBe(true)
for (let elapsed = 20; elapsed <= 100; elapsed += 20) {
terminal.emitData('.')
await vi.advanceTimersByTimeAsync(20)
}
expect(await operation.done).toMatchObject({ waitReason: 'timeout' })
})
it('does not resume cancellation polling after the terminal exits during signalling', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const signalGate = Promise.withResolvers<undefined>()
terminal.signalForeground = async () => {
await signalGate.promise
return 456
}
const operation = session.startSend({ text: 'first', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(operation.cancel()).toBe(true)
terminal.emitExit(0)
await expect(operation.done).resolves.toMatchObject({ waitReason: 'session_exit' })
signalGate.resolve(undefined)
await vi.advanceTimersByTimeAsync(10)
expect(session.status()).toEqual({ kind: 'exited', exitCode: 0, signal: null })
})
it('retains send ownership after timeout until an asynchronous provider write settles', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const writeGate = Promise.withResolvers<undefined>()
terminal.write = async () => { await writeGate.promise }
const operation = session.startSend({ text: 'slow write', submit: true })
await Promise.resolve()
await Promise.resolve()
await vi.advanceTimersByTimeAsync(100)
expect((await operation.done).waitReason).toBe('timeout')
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
writeGate.resolve(undefined)
await Promise.resolve()
await Promise.resolve()
const rejectedWrite = Promise.withResolvers<undefined>()
terminal.write = async () => { await rejectedWrite.promise }
const rejected = session.startSend({ text: 'late rejection', submit: true })
await vi.advanceTimersByTimeAsync(100)
expect((await rejected.done).waitReason).toBe('timeout')
rejectedWrite.reject(new Error('write failed after timeout'))
await Promise.resolve()
await Promise.resolve()
const next = session.startSend({ text: '', submit: false })
await vi.advanceTimersByTimeAsync(100)
expect((await next.done).waitReason).toBe('inferred_idle')
})
it('retains send ownership when cancellation signalling fails during an asynchronous write', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const writeGate = Promise.withResolvers<undefined>()
terminal.write = async () => { await writeGate.promise }
let signalCalls = 0
terminal.signalForeground = async () => {
signalCalls += 1
throw new Error('interrupt failed')
}
const operation = session.startSend({ text: 'slow write', submit: true })
await Promise.resolve()
await Promise.resolve()
expect(operation.cancel()).toBe(true)
await vi.advanceTimersByTimeAsync(20)
expect(signalCalls).toBe(0)
expect(() => session.startSend({ text: 'must wait', submit: true })).toThrow('active send')
writeGate.resolve(undefined)
await expect(operation.done).rejects.toThrow('interrupt failed')
expect(signalCalls).toBe(1)
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
})
it('handles startup exit, unknown exit signals, cancel-write failure, and stale polls', async () => {
vi.useFakeTimers()
const startupTerminal = new FakeTerminal()
const startup = new LocalPtySession(startupTerminal, config())
const initializing = startup.initialize(new AbortController().signal)
startupTerminal.emitExit(1)
await expect(initializing).rejects.toThrow('exited during startup')
expect(startup.status()).toEqual({ kind: 'exited', exitCode: 1, signal: null })
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config())
await initialize(session, terminal)
const operation = session.startSend({ text: '', submit: false })
const operationInternal = operation as unknown as {
append(text: string): void
settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
}
operationInternal.append('')
const sessionInternal = session as unknown as {
pollReadiness(operation: TerminalSendOperation): void
interrupt(operation: TerminalSendOperation): void
schedulePoll(operation: TerminalSendOperation): void
polling: boolean
statusValue: TerminalSessionStatus
appendOutput(text: string): void
}
sessionInternal.appendOutput('')
sessionInternal.pollReadiness({} as TerminalSendOperation)
sessionInternal.schedulePoll({} as TerminalSendOperation)
sessionInternal.polling = true
sessionInternal.schedulePoll(operation)
sessionInternal.polling = false
sessionInternal.interrupt({} as TerminalSendOperation)
sessionInternal.statusValue = { kind: 'exited', exitCode: 2, signal: null }
sessionInternal.pollReadiness(operation)
await operation.done
operationInternal.settle('timeout', { kind: 'running' }, false)
const unknownTerminal = new FakeTerminal()
const unknown = new LocalPtySession(unknownTerminal, config())
unknownTerminal.emitExit(1, 999)
await vi.waitFor(() => {
expect(unknown.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
})
const cancelTerminal = new FakeTerminal()
const cancelInspector = new FakeInspector()
const cancel = makeSession(cancelTerminal, cancelInspector, config())
await initialize(cancel, cancelTerminal)
const cancellable = cancel.startSend({ text: '', submit: false })
cancelInspector.throwGroup = true
expect(cancellable.cancel()).toBe(true)
await expect(cancellable.done).rejects.toThrow('group failed')
expect(cancellable.cancel()).toBe(false)
const missingGroupTerminal = new FakeTerminal()
const missingGroupInspector = new FakeInspector()
const missingGroup = makeSession(missingGroupTerminal, missingGroupInspector, config())
await initialize(missingGroup, missingGroupTerminal)
missingGroupInspector.pgid = undefined
const unresolved = missingGroup.startSend({ text: '', submit: false })
expect(unresolved.cancel()).toBe(true)
await expect(unresolved.done).rejects.toThrow('cannot resolve foreground process group')
})
it('does not treat zero-output startup silence as readiness and fails on startup timeout', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config())
let settled = false
const initializing = session.initialize().then(() => { settled = true })
await vi.advanceTimersByTimeAsync(60)
expect(settled).toBe(false)
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
await initializing
const timeoutTerminal = new FakeTerminal()
const timeout = new LocalPtySession(timeoutTerminal, config())
const timedOut = expect(timeout.initialize()).rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(100)
await timedOut
})
it('preserves the caller abort reason when startup cannot resolve a foreground group', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
inspector.pgid = undefined
const session = makeSession(terminal, inspector, config())
const controller = new AbortController()
const reason = new Error('startup cancelled')
const initializing = session.initialize(controller.signal)
const rejected = expect(initializing).rejects.toBe(reason)
controller.abort(reason)
await rejected
})
it('waits for printable prompt text when the startup marker is split from PS1', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config())
let settled = false
const initializing = session.initialize().then(() => { settled = true })
terminal.emitData('\x1b]133;D;0\x07')
await vi.advanceTimersByTimeAsync(20)
expect(settled).toBe(false)
terminal.emitData('dsh> ')
await vi.advanceTimersByTimeAsync(10)
await initializing
expect(session.motd).toBe('dsh> ')
})
it('does not attribute a delayed prior prompt to the current send', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config({ idleSilenceMs: 100, timeoutMs: 200 }))
await initialize(session, terminal)
const operation = session.startSend({ text: "printf 'PID=%s\\n' \"$!\"", submit: true })
let settled = false
void operation.done.then(() => { settled = true })
await Promise.resolve()
await Promise.resolve()
terminal.emitData('\x1b]133;D;0\x07dsh> printf \'PID=%s\\n\' "$!"\r\n')
await vi.advanceTimersByTimeAsync(20)
expect(settled).toBe(false)
terminal.emitData('PID=123\r\n\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read' })
})
it('retains a prompt marker until the startup shell regains the foreground group', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
await Promise.resolve()
await Promise.resolve()
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(50)
expect(settled).toBe(false)
inspector.pgid = 456
await vi.advanceTimersByTimeAsync(10)
expect(settled).toBe(true)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('holds the idle fallback for the configured handoff grace, not one poll', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config({ handoffGraceMs: 40 }))
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
let settled = false
void operation.done.then(() => { settled = true })
await Promise.resolve()
await Promise.resolve()
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07dsh> ')
// One poll past the silence bound would already have settled inferred_idle.
await vi.advanceTimersByTimeAsync(70)
expect(settled).toBe(false)
inspector.pgid = 456
await vi.advanceTimersByTimeAsync(10)
expect((await operation.done).waitReason).toBe('stdin_read')
})
it('falls back to inferred idle when a foreground child emits an inherited prompt marker', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const operation = session.startSend({ text: 'bash -i', submit: true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07child> ')
await vi.advanceTimersByTimeAsync(100)
expect((await operation.done).waitReason).toBe('inferred_idle')
})
it('contains terminal transport failures and preserves the first failure', async () => {
const terminal = new FakeTerminal()
terminal.terminateError = new Error('cleanup after transport failure')
const session = new LocalPtySession(terminal, config())
const operation = session.startSend({ text: '', submit: false })
terminal.output.emit('data', 'plain text')
terminal.emitError(new Error('output transport failed'))
;(session as unknown as { onTransportFailure(error: unknown): void })
.onTransportFailure(new Error('later failure'))
await expect(operation.done).rejects.toThrow('output transport failed')
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
terminal.terminateError = undefined
await expect(session.close('transport')).rejects.toThrow('output transport failed')
const rejectedTerminal = new FakeTerminal()
const rejected = new LocalPtySession(rejectedTerminal, config())
const rejectedOperation = rejected.startSend({ text: '', submit: false })
rejectedTerminal.emitFailure('raw transport failure')
await expect(rejectedOperation.done).rejects.toThrow('raw transport failure')
})
it('replaces invalid UTF-8 terminal output', async () => {
const chunkTerminal = new FakeTerminal()
const chunkSession = new LocalPtySession(chunkTerminal, config())
const chunkOperation = chunkSession.startSend({ text: '', submit: false })
chunkTerminal.emitBytes(Uint8Array.from([0xff]))
expect(chunkOperation.readOutput()).toEqual({ delta: '<27>', truncated: false })
chunkTerminal.emitExit()
await chunkOperation.done
const endTerminal = new FakeTerminal()
const endSession = new LocalPtySession(endTerminal, config())
const endOperation = endSession.startSend({ text: '', submit: false })
endTerminal.emitBytes(Uint8Array.from([0xe2]))
endTerminal.emitExit()
expect((await endOperation.done).viewport).toBe('<27>')
})
it('contains readiness inspection failure and a stale inspection result', async () => {
vi.useFakeTimers()
const failedTerminal = new FakeTerminal()
const failedSession = new LocalPtySession(failedTerminal, config())
const failedOperation = failedSession.startSend({ text: '', submit: false })
await Promise.resolve()
await Promise.resolve()
failedTerminal.inspectForeground = async () => { throw new Error('inspect failed') }
const failedInternal = failedSession as unknown as {
pollReadiness(operation: TerminalSendOperation): Promise<void>
}
await failedInternal.pollReadiness(failedOperation)
await expect(failedOperation.done).rejects.toThrow('inspect failed')
const staleTerminal = new FakeTerminal()
const staleSession = new LocalPtySession(staleTerminal, config())
const staleOperation = staleSession.startSend({ text: '', submit: false })
await Promise.resolve()
await Promise.resolve()
const gate = Promise.withResolvers<ReturnType<FakeTerminal['inspectForeground']> extends Promise<infer T> ? T : never>()
staleTerminal.inspectForeground = async () => await gate.promise
const staleInternal = staleSession as unknown as {
active: TerminalSendOperation | undefined
pollReadiness(operation: TerminalSendOperation): Promise<void>
}
const polling = staleInternal.pollReadiness(staleOperation)
staleInternal.active = undefined
gate.resolve({ processGroupId: 456, inputWaiting: false })
await polling
;(staleOperation as unknown as {
settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
}).settle('timeout', { kind: 'running' }, false)
})
it('reschedules readiness for a new send after a stale remote inspection releases the poll slot', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const old = session.startSend({ text: '', submit: false })
await Promise.resolve()
await Promise.resolve()
const inspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
let block = true
terminal.inspectForeground = async () => block
? await inspection.promise
: { processGroupId: 456, inputWaiting: false }
const internals = session as unknown as {
pollReadiness(operation: TerminalSendOperation): Promise<void>
settleActive(reason: 'timeout'): void
}
const stalePoll = internals.pollReadiness(old)
internals.settleActive('timeout')
await old.done
block = false
const current = session.startSend({ text: '', submit: false })
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await Promise.resolve()
await Promise.resolve()
inspection.resolve({ processGroupId: 456, inputWaiting: false })
await stalePoll
await vi.advanceTimersByTimeAsync(10)
expect((await current.done).waitReason).toBe('stdin_read')
})
it('does not poll a successor before its own pre-write inspection and write complete', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
await initialize(session, terminal)
const old = session.startSend({ text: '', submit: false })
await Promise.resolve()
await Promise.resolve()
const staleInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
const successorInspection = Promise.withResolvers<{ processGroupId: number; inputWaiting: boolean }>()
let inspectCalls = 0
terminal.inspectForeground = async () => {
inspectCalls += 1
return inspectCalls === 1 ? await staleInspection.promise : await successorInspection.promise
}
const internals = session as unknown as {
pollReadiness(operation: TerminalSendOperation): Promise<void>
settleActive(reason: 'timeout'): void
}
const stalePoll = internals.pollReadiness(old)
internals.settleActive('timeout')
await old.done
const current = session.startSend({ text: 'successor', submit: true })
await Promise.resolve()
await Promise.resolve()
staleInspection.resolve({ processGroupId: 456, inputWaiting: false })
await stalePoll
await vi.advanceTimersByTimeAsync(10)
expect(inspectCalls).toBe(2)
expect(terminal.writes).toEqual([])
successorInspection.resolve({ processGroupId: 456, inputWaiting: false })
await Promise.resolve()
await Promise.resolve()
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect(terminal.writes).toEqual(['successor\r'])
expect((await current.done).waitReason).toBe('stdin_read')
})
it('contains stale timer, write, inspection, and interrupt continuations', async () => {
vi.useFakeTimers()
const settle = (operation: TerminalSendOperation): void => {
;(operation as unknown as {
settle(reason: 'timeout', status: TerminalSessionStatus, inherited: boolean): void
}).settle('timeout', { kind: 'running' }, false)
}
const deadlineTerminal = new FakeTerminal()
const deadlineSession = new LocalPtySession(deadlineTerminal, config())
const deadlineOperation = deadlineSession.startSend({ text: '', submit: false })
;(deadlineSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
await vi.advanceTimersByTimeAsync(100)
settle(deadlineOperation)
const writeTerminal = new FakeTerminal()
const writeGate = Promise.withResolvers<undefined>()
writeTerminal.write = async () => { await writeGate.promise }
const writeSession = new LocalPtySession(writeTerminal, config())
const writeOperation = writeSession.startSend({ text: 'x', submit: false })
await Promise.resolve()
await Promise.resolve()
;(writeSession as unknown as { closing: boolean }).closing = true
writeGate.resolve(undefined)
await Promise.resolve()
await Promise.resolve()
settle(writeOperation)
const beginTerminal = new FakeTerminal()
const beginGate = Promise.withResolvers<never>()
beginTerminal.inspectForeground = async () => await beginGate.promise
const beginSession = new LocalPtySession(beginTerminal, config())
const beginOperation = beginSession.startSend({ text: '', submit: false })
;(beginSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
beginGate.reject(new Error('stale begin failure'))
await Promise.resolve()
await Promise.resolve()
settle(beginOperation)
const scheduledTerminal = new FakeTerminal()
const scheduledSession = new LocalPtySession(scheduledTerminal, config())
const scheduledOperation = scheduledSession.startSend({ text: '', submit: false })
await Promise.resolve()
await Promise.resolve()
const scheduledInternal = scheduledSession as unknown as {
schedulePoll(operation: TerminalSendOperation, delayMs?: number): void
settleActive(reason: 'timeout'): void
}
scheduledInternal.schedulePoll(scheduledOperation, 5)
scheduledInternal.settleActive('timeout')
await scheduledOperation.done
const pollTerminal = new FakeTerminal()
const pollSession = new LocalPtySession(pollTerminal, config())
const pollOperation = pollSession.startSend({ text: '', submit: false })
await Promise.resolve()
await Promise.resolve()
const pollGate = Promise.withResolvers<never>()
pollTerminal.inspectForeground = async () => await pollGate.promise
const pollInternal = pollSession as unknown as {
active: TerminalSendOperation | undefined
pollReadiness(operation: TerminalSendOperation): Promise<void>
}
const stalePoll = pollInternal.pollReadiness(pollOperation)
pollInternal.active = undefined
pollGate.reject(new Error('stale poll failure'))
await stalePoll
settle(pollOperation)
const interruptTerminal = new FakeTerminal()
const interruptGate = Promise.withResolvers<never>()
interruptTerminal.signalForeground = async () => await interruptGate.promise
const interruptSession = new LocalPtySession(interruptTerminal, config())
const interruptOperation = interruptSession.startSend({ text: '', submit: false })
expect(interruptOperation.cancel()).toBe(true)
;(interruptSession as unknown as { active: TerminalSendOperation | undefined }).active = undefined
interruptGate.reject(new Error('stale interrupt failure'))
await Promise.resolve()
await Promise.resolve()
settle(interruptOperation)
})
})
describe('LocalPtySession bounds, signals, and teardown', () => {
it('validates pagination and enforces line/UTF-8 bounds', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(
terminal,
config({ scrollbackLines: 3, scrollbackMaxBytes: 12, maxReadBytes: 6 }),
)
expect(session.read({})).toMatchObject({ text: '' })
await initialize(session, terminal)
const operation = session.startSend({ text: '', submit: false })
terminal.emitData('一\n二\n三\n四')
await vi.advanceTimersByTimeAsync(60)
expect((await operation.done).truncated).toBe(true)
const page = session.read({ offset: 0, count: 3 })
expect(Buffer.byteLength(page.text)).toBeLessThanOrEqual(6)
expect(page.truncated).toBe(true)
expect(session.read({ offset: 999 })).toMatchObject({ text: '', lineBegin: 999, lineEnd: 999 })
expect(() => session.read({ offset: -1 })).toThrow('offset')
expect(() => session.read({ count: 0 })).toThrow('count')
const tinyTerminal = new FakeTerminal()
const tiny = new LocalPtySession(tinyTerminal, config({ maxReadBytes: 1 }))
await initialize(tiny, tinyTerminal)
const tinyOperation = tiny.startSend({ text: '', submit: false })
tinyTerminal.emitData('一')
await vi.advanceTimersByTimeAsync(60)
await tinyOperation.done
expect(tiny.read({ offset: 0, count: 1 }).text).toBe('')
})
it('signals verified groups and refuses unresolved or shell-targeted hard kills', async () => {
const terminal = new FakeTerminal()
const inspector = new FakeInspector()
const session = makeSession(terminal, inspector, config())
expect(await session.signal('SIGINT')).toEqual({ delivered: true, targetPgid: 456 })
inspector.pgid = terminal.pid
await expect(session.signal('SIGKILL')).rejects.toThrow('terminate the terminal session')
inspector.pgid = undefined
await expect(session.signal('SIGTERM')).rejects.toThrow('cannot resolve')
})
it('closes idempotently and rejects new signals', async () => {
const terminal = new FakeTerminal()
terminal.throwKill = true
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 1 }))
const closing = session.close('test')
expect(session.close('other')).toBe(closing)
await expect(closing).rejects.toThrow('PTY cleanup failed (test)')
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
await expect(session.signal('SIGTERM')).rejects.toThrow('closing')
})
it('reports cleanup failure without waiting for top-level exit and permits retry', async () => {
const terminal = new FakeTerminal()
terminal.autoExitOnKill = false
terminal.terminateError = new Error('terminal cleanup failed; surviving pids: 456')
const session = new LocalPtySession(terminal, config())
await expect(session.close('survivor')).rejects.toMatchObject({
message: 'PTY cleanup failed (survivor)',
cause: terminal.terminateError,
})
expect(terminal.kills).toEqual([])
terminal.terminateError = undefined
terminal.autoExitOnKill = true
await expect(session.close('retry')).resolves.toBeUndefined()
expect(terminal.kills).toEqual(['SIGTERM'])
})
it('settles an active send as session_exit when closed mid-operation', async () => {
vi.useFakeTimers()
const terminal = new FakeTerminal()
const session = new LocalPtySession(terminal, config({ disposeGraceMs: 50 }))
await initialize(session, terminal)
const operation = session.startSend({ text: 'run', submit: true })
// The shell returns to its prompt while the send is active; a running
// readiness poll would otherwise mis-settle this as stdin_read once close
// begins, so teardown must stop polling before its grace period.
terminal.emitData('\x1b]133;D;0\x07dsh> ')
terminal.autoExitOnKill = false
const closing = session.close('mid-send')
await vi.advanceTimersByTimeAsync(20)
terminal.emitExit(0, 15)
expect((await operation.done).waitReason).toBe('session_exit')
await closing
})
it('settles a closing send when provider termination cancels inspection', 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 writeOperation = session.startSend({ text: 'pending write', submit: true })
await Promise.resolve()
await Promise.resolve()
await session.close('pending write')
expect((await writeOperation.done).waitReason).toBe('session_exit')
// The rejection lands after close released the send; it must stay contained.
write.reject(new Error('write rejected during close'))
await Promise.resolve()
await Promise.resolve()
})
it('settles a closing send when provider termination cancels a pending inspection', 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 terminate = terminal.terminate.bind(terminal)
terminal.terminate = async () => {
inspection.reject(new Error('terminal terminated'))
await terminate()
}
const operation = session.startSend({ text: 'pending inspection', submit: true })
await Promise.resolve()
await session.close('pending inspection')
expect((await operation.done).waitReason).toBe('session_exit')
expect(terminal.writes).toEqual([])
})
it('does not let an in-flight readiness inspection outrun close', 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 }>()
const originalInspect = terminal.inspectForeground.bind(terminal)
let inspections = 0
terminal.inspectForeground = async () => {
inspections += 1
return inspections === 1 ? await originalInspect() : await inspection.promise
}
const operation = session.startSend({ text: 'pending readiness', submit: true })
await Promise.resolve()
await Promise.resolve()
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(10)
expect(inspections).toBe(2)
const termination = Promise.withResolvers<undefined>()
terminal.terminate = async () => {
await termination.promise
terminal.emitExit(0, 15)
}
const closing = session.close('in-flight readiness')
let settled = false
void operation.done.then(() => { settled = true })
inspection.resolve({ processGroupId: 456, inputWaiting: true })
await Promise.resolve()
await Promise.resolve()
expect(settled).toBe(false)
termination.resolve(undefined)
await closing
expect((await operation.done).waitReason).toBe('session_exit')
})
})