refactor(e2b): group remote providers

This commit is contained in:
Tianyi Cui
2026-07-28 14:52:37 +08:00
parent 6667102890
commit e64d40837c
81 changed files with 171 additions and 249 deletions
+195
View File
@@ -0,0 +1,195 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty'
import { E2BPtyBackend, apply } from '@deepseek-ai/dsh-pty-e2b'
import { validateConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts'
import * as E2BPtyInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
function config() {
return {
backendType: 'shell', rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 1, idleSilenceMs: 2, timeoutMs: 5, disposeGraceMs: 1,
}
}
function owner(ctx: Context): Agent {
const id = SessionId('owner')
return {
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
followup: () => AgentMessageId('unused'), steer: () => AgentMessageId('unused'),
inject: () => AgentMessageId('unused'), send: () => AgentMessageId('unused'),
cancel() {}, whenIdle: () => Promise.resolve(),
}
}
function handle(pid = 123, kill = vi.fn().mockResolvedValue(true)): CommandHandle {
const result = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>()
return {
pid,
wait: () => result.promise,
kill,
disconnect: vi.fn().mockResolvedValue(undefined),
} as unknown as CommandHandle
}
describe('E2BPtyBackend and plugin', () => {
it('creates a remote PTY with isolated environment and initializes the session', async () => {
vi.useFakeTimers()
const ctx = new Context()
const sandbox = {} as Sandbox
ctx.provide('e2b', {
cwd: '/workspace',
getSandbox: async () => sandbox,
} as E2BSandboxService)
const created = handle()
let options: Parameters<Sandbox['pty']['create']>[0] | undefined
const backend = new E2BPtyBackend(ctx, config(), async (_sandbox, received) => {
options = received
void received.onData(Buffer.from('banner\n'))
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
return created
})
const pending = backend.spawn({
sessionId: PtySessionId('pty-1'), owner: owner(ctx), type: 'shell', cwd: 'project',
signal: new AbortController().signal,
})
await vi.advanceTimersByTimeAsync(2)
const session = await pending
expect(session.motd).toBe('dsh> ')
expect(options).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace/project', timeoutMs: 0 })
expect(options?.envs).toMatchObject({
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ',
DSH_SHELL: '1', DSH_SESSION_ID: 'owner', DSH_PTY_SESSION_ID: 'pty-1',
})
vi.useRealTimers()
})
it('uses the SDK PTY create method and the shared cwd by default', async () => {
vi.useFakeTimers()
const ctx = new Context()
const created = handle()
const create = vi.fn(async (received: Parameters<Sandbox['pty']['create']>[0]) => {
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
return created
})
const sandbox = { pty: { create } } as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
const backend = new E2BPtyBackend(ctx, config())
const pending = backend.spawn({ sessionId: PtySessionId('default'), owner: owner(ctx), type: 'shell' })
await vi.advanceTimersByTimeAsync(2)
await pending
expect(create).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/workspace' }))
vi.useRealTimers()
})
it('rejects aborts and invalid pids, killing a malformed SDK handle', async () => {
const ctx = new Context()
const sandbox = {} as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
const create = vi.fn().mockResolvedValue(handle(0))
const backend = new E2BPtyBackend(ctx, config(), create)
const aborted = AbortSignal.abort(new Error('stop'))
await expect(backend.spawn({ sessionId: PtySessionId('one'), owner: owner(ctx), type: 'shell', signal: aborted })).rejects.toThrow('stop')
expect(create).not.toHaveBeenCalled()
const malformedKill = vi.fn().mockResolvedValue(true)
const malformed = handle(0, malformedKill)
const invalid = new E2BPtyBackend(ctx, config(), async () => malformed)
await expect(invalid.spawn({ sessionId: PtySessionId('two'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
expect(malformedKill).toHaveBeenCalledOnce()
const killFailureKill = vi.fn().mockRejectedValue(new Error('already gone'))
const killFailure = handle(0, killFailureKill)
const raced = new E2BPtyBackend(ctx, config(), async () => killFailure)
await expect(raced.spawn({ sessionId: PtySessionId('three'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
})
it('cleans failed startup and aggregates a cleanup failure', async () => {
vi.useFakeTimers()
const ctx = new Context()
const sandbox = {
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) },
pty: { kill: vi.fn().mockRejectedValue(new Error('cleanup failed')) },
} as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
const failedHandle = handle()
const backend = new E2BPtyBackend(ctx, config(), async () => failedHandle)
const pending = backend.spawn({ sessionId: PtySessionId('failed'), owner: owner(ctx), type: 'shell' })
const rejected = expect(pending).rejects.toMatchObject({
name: 'PtyBackendCleanupError',
cleanupError: expect.objectContaining({ message: 'cleanup failed' }),
} satisfies Partial<PtyBackendCleanupError>)
await vi.advanceTimersByTimeAsync(6)
await vi.advanceTimersByTimeAsync(2)
await rejected
vi.useRealTimers()
})
it('preserves startup failure when cleanup succeeds', async () => {
vi.useFakeTimers()
const ctx = new Context()
const completion = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>()
const created = {
pid: 123,
wait: () => completion.promise,
disconnect: vi.fn().mockResolvedValue(undefined),
} as unknown as CommandHandle
const sandbox = {
commands: {
run: vi.fn(async (command: string) => {
if (command.startsWith('kill -TERM')) completion.resolve({ exitCode: 143, stdout: '', stderr: '' })
return { exitCode: 0, stdout: '', stderr: '' }
}),
},
pty: { kill: vi.fn().mockResolvedValue(true) },
} as unknown as Sandbox
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
const backend = new E2BPtyBackend(ctx, config(), async () => created)
const rejected = expect(backend.spawn({ sessionId: PtySessionId('failed-clean'), owner: owner(ctx), type: 'shell' }))
.rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(6)
await rejected
vi.useRealTimers()
})
it('validates configuration and registers the selected backend type', async () => {
const valid = config()
expect(() => { validateConfig(valid) }).not.toThrow()
for (const invalid of [
{ ...valid, backendType: '' },
{ ...valid, rows: 0 },
{ ...valid, rows: 1.5 },
{ ...valid, maxReadBytes: 129 },
]) {
expect(() => { validateConfig(invalid) }).toThrow()
}
const registerBackend = vi.fn()
apply({ pty: { registerBackend } } as unknown as Context, valid)
expect(registerBackend).toHaveBeenCalledWith(expect.objectContaining({ type: 'shell' }))
const ctx = new Context()
await ctx.plugin(PtyService)
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => ({}) } as never)
const fiber = await ctx.plugin({
inject: ['pty', 'e2b'],
apply: (pluginCtx: Context) => { apply(pluginCtx, valid) },
})
expect(ctx.pty.listBackends()).toEqual(['shell'])
await fiber.dispose()
})
it('registers the package-owned invariant companion', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
const fiber = await ctx.plugin(E2BPtyInvariant).await()
await fiber.dispose()
})
})
+398
View File
@@ -0,0 +1,398 @@
import { Buffer } from 'node:buffer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
CommandExitError,
type CommandHandle,
type CommandResult,
type Sandbox,
} from '@deepseek-ai/dsh-e2b'
import type { PtySendOperation, PtySessionStatus } from '@deepseek-ai/dsh-pty'
import { E2BPtySession } from '@deepseek-ai/dsh-pty-e2b'
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts'
function commandError(exitCode: number): CommandExitError {
return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
}
class FakePtyHandle {
pid = 123
readonly result = Promise.withResolvers<CommandResult>()
disconnects = 0
kills = 0
disconnectError: unknown
private settled = false
wait(): Promise<CommandResult> {
return this.result.promise
}
async disconnect(): Promise<void> {
this.disconnects += 1
if (this.disconnectError !== undefined) throw this.disconnectError
}
async kill(): Promise<boolean> {
this.kills += 1
return true
}
exit(exitCode = 0): void {
if (this.settled) return
this.settled = true
this.result.resolve({ exitCode, stdout: '', stderr: '' })
}
failExit(exitCode: number): void {
if (this.settled) return
this.settled = true
this.result.reject(commandError(exitCode))
}
crash(error: unknown): void {
if (this.settled) return
this.settled = true
this.result.reject(error)
}
asHandle(): CommandHandle {
return this as unknown as CommandHandle
}
}
class FakeSandbox {
readonly sent: Array<{ pid: number; data: Buffer }> = []
readonly commands: string[] = []
readonly killed: number[] = []
pgid = '456\n'
sendError: unknown
commandError: unknown
killError: unknown
onTerm: (() => void) | undefined
onKill: (() => void) | undefined
readonly sandbox = {
pty: {
sendInput: async (pid: number, data: Uint8Array): Promise<void> => {
this.sent.push({ pid, data: Buffer.from(data) })
if (this.sendError !== undefined) throw this.sendError
},
kill: async (pid: number): Promise<boolean> => {
this.killed.push(pid)
if (this.killError !== undefined) throw this.killError
this.onKill?.()
return true
},
},
commands: {
run: async (command: string): Promise<CommandResult> => {
this.commands.push(command)
if (this.commandError !== undefined) {
const error = this.commandError
this.commandError = undefined
throw error
}
if (command.startsWith('ps ')) return { exitCode: 0, stdout: this.pgid, stderr: '' }
if (command.startsWith('kill -TERM')) this.onTerm?.()
return { exitCode: 0, stdout: '', stderr: '' }
},
},
} as unknown as Sandbox
}
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
return {
backendType: 'shell', rows: 24, cols: 80,
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
pollIntervalMs: 10, idleSilenceMs: 40, timeoutMs: 100, disposeGraceMs: 20,
...overrides,
}
}
async function initialize(session: E2BPtySession): Promise<void> {
const pending = session.initialize()
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await pending
}
afterEach(() => { vi.useRealTimers() })
describe('E2BPtySession readiness, output, and signals', () => {
it('initializes, sends UTF-8 input, settles at a prompt, and reads bounded scrollback', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config({ maxReadBytes: 12 }))
expect(session.read({})).toMatchObject({ text: '', totalLines: 0 })
await initialize(session)
expect(session.motd).toBe('dsh> ')
const operation = session.startSend({ text: 'printf 你好', submit: true })
expect(fake.sent).toEqual([{ pid: 123, data: Buffer.from('printf 你好\r') }])
session.onData(Buffer.from('一\n二\n三\x1b]133;D;0\x07dsh> '))
const bounded = operation.readOutput()
expect(bounded.delta).toContain('三')
expect(bounded.truncated).toBe(true)
await vi.advanceTimersByTimeAsync(10)
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', sessionStatus: { kind: 'running' } })
expect(operation.cancel()).toBe(false)
expect(session.read({ count: 2 }).text).toContain('dsh>')
expect(session.read({ offset: 99 })).toMatchObject({ text: '', lineBegin: 99, lineEnd: 99 })
expect(() => session.read({ offset: -1 })).toThrow('non-negative safe integer')
expect(() => session.read({ offset: 1.5 })).toThrow('non-negative safe integer')
expect(() => session.read({ count: 0 })).toThrow('positive safe integer')
expect(() => session.read({ count: 1.5 })).toThrow('positive safe integer')
await expect(session.signal('SIGTERM')).resolves.toEqual({ delivered: true, targetPgid: 456 })
expect(fake.commands).toContain('kill -TERM -- -456')
expect(session.status()).toEqual({ kind: 'running' })
})
it('distinguishes inferred idle, timeout, session exit, and no-output startup timeout', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await initialize(session)
const inferred = session.startSend({ text: '', submit: false })
await vi.advanceTimersByTimeAsync(40)
expect((await inferred.done).waitReason).toBe('inferred_idle')
const timeout = session.startSend({ text: '', submit: false })
for (let index = 0; index < 3; index += 1) {
await vi.advanceTimersByTimeAsync(30)
session.onData(Buffer.from('.'))
}
await vi.advanceTimersByTimeAsync(10)
expect((await timeout.done).waitReason).toBe('timeout')
const exiting = session.startSend({ text: '', submit: false })
handle.failExit(143)
expect(await exiting.done).toMatchObject({
waitReason: 'session_exit',
sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' },
})
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
const startupHandle = new FakePtyHandle()
const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), config())
const timedOut = expect(startup.initialize()).rejects.toThrow('startup timeout')
await vi.advanceTimersByTimeAsync(100)
await timedOut
})
it('handles split prompt text, stale operations, and explicit cancellation', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const initializing = session.initialize()
session.onData(Buffer.from('\x1b]133;D;0\x07'))
await vi.advanceTimersByTimeAsync(20)
session.onData(Buffer.from('dsh> '))
await vi.advanceTimersByTimeAsync(10)
await initializing
const operation = session.startSend({ text: 'sleep', submit: true })
const internal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
interrupt(operation: PtySendOperation): void
settleActive(reason: 'timeout'): void
failActive(error: unknown): void
appendOutput(text: string): void
statusValue: PtySessionStatus
}
internal.pollReadiness({} as PtySendOperation)
internal.interrupt({} as PtySendOperation)
internal.appendOutput('')
fake.pgid = '789\n'
expect(operation.cancel()).toBe(true)
await vi.advanceTimersByTimeAsync(0)
expect(fake.commands).toContain('kill -INT -- -789')
session.onData(Buffer.from('\x1b]133;D;130\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await operation.done
internal.settleActive('timeout')
internal.failActive(new Error('ignored'))
const operationInternal = operation as unknown as {
append(text: string): void
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
fail(error: unknown): void
}
operationInternal.append('ignored')
operationInternal.settle('timeout', { kind: 'running' }, false)
operationInternal.fail(new Error('ignored'))
})
it('observes AbortSignal and contains send or foreground lookup failures', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await initialize(session)
const controller = new AbortController()
const aborting = session.startSend({ text: '', submit: false, signal: controller.signal })
expect(() => session.startSend({ text: '', submit: false })).toThrow('active send')
fake.pgid = 'not-a-pgid\n'
controller.abort()
await expect(aborting.done).rejects.toThrow('cannot resolve foreground process group')
const already = new AbortController()
already.abort()
expect(() => session.startSend({ text: '', submit: false, signal: already.signal })).toThrow('aborted before write')
fake.sendError = new Error('send failed')
const failed = session.startSend({ text: 'x', submit: false })
await expect(failed.done).rejects.toThrow('send failed')
fake.pgid = '123\n'
await expect(session.signal('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
fake.pgid = '0\n'
await expect(session.signal('SIGINT')).rejects.toThrow('cannot resolve')
const deferred = Promise.withResolvers<undefined>()
fake.sendError = undefined
const sendInput = vi.spyOn(fake.sandbox.pty, 'sendInput').mockReturnValueOnce(deferred.promise)
const late = session.startSend({ text: 'late', submit: false })
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
await vi.advanceTimersByTimeAsync(10)
await late.done
deferred.reject(new Error('late failure'))
await vi.advanceTimersByTimeAsync(0)
expect(sendInput).toHaveBeenCalled()
})
it('preserves startup abort reasons and classifies invalid UTF-8 transport failures', async () => {
const fake = new FakeSandbox()
const abortHandle = new FakePtyHandle()
const abortSession = new E2BPtySession(fake.sandbox, abortHandle.asHandle(), config())
const controller = new AbortController()
const reason = new Error('startup cancelled')
const initializing = abortSession.initialize(controller.signal)
const rejected = expect(initializing).rejects.toBe(reason)
controller.abort(reason)
await rejected
const invalidHandle = new FakePtyHandle()
const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), config())
const pending = invalid.startSend({ text: '', submit: false })
invalid.onData(Uint8Array.from([0xff]))
await expect(pending.done).rejects.toThrow('invalid UTF-8')
expect(invalid.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
const crashHandle = new FakePtyHandle()
const crashed = new E2BPtySession(fake.sandbox, crashHandle.asHandle(), config())
const active = crashed.startSend({ text: '', submit: false })
crashHandle.crash('transport gone')
await expect(active.done).rejects.toEqual(new Error('transport gone'))
const startupExitHandle = new FakePtyHandle()
const startupExit = new E2BPtySession(fake.sandbox, startupExitHandle.asHandle(), config())
const exitedDuringStartup = expect(startupExit.initialize()).rejects.toThrow('exited during startup')
startupExitHandle.exit(7)
await exitedDuringStartup
})
it('covers empty bounded reads and polling an exited active session', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const tinyHandle = new FakePtyHandle()
const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), config({ maxReadBytes: 1 }))
tiny.onData(Buffer.from('你'))
expect(tiny.read({ count: 1 })).toMatchObject({ text: '', lineEnd: 0 })
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const operation = session.startSend({ text: '', submit: false })
const internal = session as unknown as {
pollReadiness(operation: PtySendOperation): void
clearActive(): void
statusValue: PtySessionStatus
}
internal.statusValue = { kind: 'exited', exitCode: 7, signal: null }
internal.pollReadiness(operation)
expect((await operation.done).waitReason).toBe('session_exit')
internal.clearActive()
})
})
describe('E2BPtySession teardown', () => {
it('terminates the process group once, awaits exit, and disconnects', async () => {
vi.useFakeTimers()
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
fake.onTerm = () => { handle.failExit(143) }
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const first = session.close('done')
expect(session.close('again')).toBe(first)
await first
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGTERM' })
expect(handle.disconnects).toBe(1)
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
})
it('contains an already-gone TERM, escalates to KILL, and reports a survivor', async () => {
vi.useFakeTimers()
const gone = new FakeSandbox()
const goneHandle = new FakePtyHandle()
gone.commandError = commandError(1)
gone.onKill = () => { goneHandle.failExit(137) }
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), config())
const closingGone = goneSession.close('gone')
await vi.advanceTimersByTimeAsync(20)
await closingGone
expect(gone.killed).toEqual([123])
expect(goneSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGKILL' })
const survivor = new FakeSandbox()
const survivorHandle = new FakePtyHandle()
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), config())
const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving pid: 123')
await vi.advanceTimersByTimeAsync(40)
await failed
survivorHandle.exit()
await expect(survivorSession.close('retry')).resolves.toBeUndefined()
})
it('propagates cleanup transport failures and lets close retry', async () => {
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
fake.commandError = new Error('TERM transport failed')
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
await expect(session.close('failure')).rejects.toThrow('TERM transport failed')
handle.exit()
await expect(session.close('retry')).resolves.toBeUndefined()
const invalidTailHandle = new FakePtyHandle()
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), config())
invalidTail.onData(Uint8Array.from([0xe2]))
invalidTailHandle.exit()
await expect(invalidTail.close('invalid tail')).rejects.toThrow('invalid UTF-8')
const normalHandle = new FakePtyHandle()
normalHandle.disconnectError = new Error('disconnect raced')
const normal = new E2BPtySession(fake.sandbox, normalHandle.asHandle(), config())
normalHandle.exit(7)
await Promise.resolve()
expect(normal.status()).toEqual({ kind: 'exited', exitCode: 7, signal: null })
await expect(normal.close('already exited')).resolves.toBeUndefined()
})
it('kills a remotely live PTY after its host transport fails', async () => {
const fake = new FakeSandbox()
const handle = new FakePtyHandle()
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
const active = session.startSend({ text: '', submit: false })
session.onData(Uint8Array.from([0xff]))
await expect(active.done).rejects.toThrow('invalid UTF-8')
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
fake.onTerm = () => { handle.failExit(143) }
await expect(session.close('transport failed')).rejects.toThrow('invalid UTF-8')
expect(fake.commands).toContain('kill -TERM -- -123')
expect(handle.disconnects).toBe(1)
})
})