refactor(e2b): group remote providers
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user