fix(e2b): harden cancellation and teardown boundaries
This commit is contained in:
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CommandExitError,
|
||||
FileNotFoundError,
|
||||
SandboxNotFoundError,
|
||||
type CommandHandle,
|
||||
type CommandResult,
|
||||
type Sandbox,
|
||||
@@ -113,6 +114,18 @@ class FakeTerminalSandbox {
|
||||
requestedOutput = 'requested-shell$ '
|
||||
emitOutputMarker = true
|
||||
afterSessionLookup: (() => void) | undefined
|
||||
private createGate: Promise<undefined> | undefined
|
||||
private releaseCreateGate: (() => void) | undefined
|
||||
|
||||
deferCreate(): void {
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
this.createGate = gate.promise
|
||||
this.releaseCreateGate = () => { gate.resolve(undefined) }
|
||||
}
|
||||
|
||||
releaseCreate(): void {
|
||||
this.releaseCreateGate?.()
|
||||
}
|
||||
|
||||
readonly sandbox = {
|
||||
files: {
|
||||
@@ -183,6 +196,8 @@ class FakeTerminalSandbox {
|
||||
create: async (options: Parameters<Sandbox['pty']['create']>[0]): Promise<CommandHandle> => {
|
||||
this.createOptions = options
|
||||
if (this.createError !== undefined) throw this.createError
|
||||
await this.createGate
|
||||
options.signal?.throwIfAborted()
|
||||
await options.onData(Buffer.from('buffered banner\n'))
|
||||
return this.handle.asHandle()
|
||||
},
|
||||
@@ -258,7 +273,7 @@ describe('E2B terminal allocation', () => {
|
||||
const runner = fake.writes.get('/runtime/terminal-one/runner.bash') ?? ''
|
||||
expect(runner).toContain('if (( ${#dsh_argv[@]} == 0 )); then')
|
||||
expect(runner).toContain('printf \'%s\' "$dsh_output_marker"')
|
||||
expect(runner).toContain('exec env -i "${dsh_env[@]}" "${dsh_argv[@]}"')
|
||||
expect(runner).toContain('exec env -i -- "${dsh_env[@]}" "${dsh_argv[@]}"')
|
||||
expect(runner).not.toContain('\u007f')
|
||||
terminal.output.destroy()
|
||||
await fake.createOptions?.onData(Buffer.from('late bootstrap callback'))
|
||||
@@ -296,6 +311,25 @@ describe('E2B terminal allocation', () => {
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('publishes the PTY handle before honoring allocation cancellation', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.deferCreate()
|
||||
const controller = new AbortController()
|
||||
const spawning = spawnE2BTerminal(
|
||||
runtime(fake),
|
||||
spec({ signal: controller.signal }),
|
||||
'/runtime/allocation-cancel',
|
||||
)
|
||||
await vi.waitFor(() => { expect(fake.createOptions).toBeDefined() })
|
||||
|
||||
controller.abort(new Error('allocation cancelled'))
|
||||
fake.releaseCreate()
|
||||
await expect(spawning).rejects.toThrow('allocation cancelled')
|
||||
expect(fake.createOptions?.signal).toBeUndefined()
|
||||
expect(fake.groups).toEqual([])
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects malformed environment and argv values before PTY allocation', async () => {
|
||||
const invalidName = new FakeTerminalSandbox()
|
||||
await expect(spawnE2BTerminal(runtime(invalidName), spec({ env: { 'BAD=NAME': 'x' } }), '/runtime/name'))
|
||||
@@ -410,6 +444,44 @@ describe('E2B terminal allocation', () => {
|
||||
cleanupFailed.removeError = new Error('remove transport failed')
|
||||
await expect(spawnE2BTerminal(runtime(cleanupFailed), spec(), '/runtime/cleanup-failed'))
|
||||
.rejects.toThrow('invalid terminal pid 0')
|
||||
|
||||
const expiredDuringRollback = new FakeTerminalSandbox()
|
||||
expiredDuringRollback.sendError = new Error('bootstrap failed before timeout')
|
||||
expiredDuringRollback.groups = []
|
||||
expiredDuringRollback.settleOnPtyKill = false
|
||||
expiredDuringRollback.ptyKillError = new SandboxNotFoundError('sandbox expired')
|
||||
expiredDuringRollback.removeError = new SandboxNotFoundError('sandbox expired')
|
||||
await expect(spawnE2BTerminal(runtime(expiredDuringRollback), spec(), '/runtime/expired-rollback'))
|
||||
.rejects.toThrow('bootstrap failed before timeout')
|
||||
expect(expiredDuringRollback.ptyKills).toBe(1)
|
||||
|
||||
const expiredBeforeSdkRollback = new FakeTerminalSandbox()
|
||||
expiredBeforeSdkRollback.handle.waitError = new Error('wait failed after timeout')
|
||||
expiredBeforeSdkRollback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
|
||||
expiredBeforeSdkRollback.handle.settleOnSdkKill = false
|
||||
await expect(spawnE2BTerminal(runtime(expiredBeforeSdkRollback), spec(), '/runtime/expired-sdk-rollback'))
|
||||
.rejects.toThrow('wait failed after timeout')
|
||||
|
||||
const expiredDuringSdkFallback = new FakeTerminalSandbox()
|
||||
expiredDuringSdkFallback.sendError = new Error('bootstrap failed before SDK fallback')
|
||||
expiredDuringSdkFallback.groups = []
|
||||
expiredDuringSdkFallback.settleOnPtyKill = false
|
||||
expiredDuringSdkFallback.handle.sdkKillError = new SandboxNotFoundError('sandbox expired')
|
||||
expiredDuringSdkFallback.handle.settleOnSdkKill = false
|
||||
await expect(spawnE2BTerminal(runtime(expiredDuringSdkFallback), spec(), '/runtime/expired-sdk-fallback'))
|
||||
.rejects.toThrow('bootstrap failed before SDK fallback')
|
||||
|
||||
const missingDuringDisconnect = new FakeTerminalSandbox()
|
||||
missingDuringDisconnect.sendError = new Error('bootstrap failed before disconnect')
|
||||
missingDuringDisconnect.handle.disconnectError = new SandboxNotFoundError('sandbox expired')
|
||||
await expect(spawnE2BTerminal(runtime(missingDuringDisconnect), spec(), '/runtime/missing-disconnect'))
|
||||
.rejects.toThrow('bootstrap failed before disconnect')
|
||||
|
||||
const failedDisconnect = new FakeTerminalSandbox()
|
||||
failedDisconnect.sendError = new Error('bootstrap failed with disconnect failure')
|
||||
failedDisconnect.handle.disconnectError = new Error('disconnect transport failed')
|
||||
await expect(spawnE2BTerminal(runtime(failedDisconnect), spec(), '/runtime/failed-disconnect'))
|
||||
.rejects.toThrow('bootstrap failed with disconnect failure')
|
||||
})
|
||||
|
||||
it('propagates setup cancellation and provider failures', async () => {
|
||||
@@ -526,6 +598,53 @@ describe('E2B terminal lifecycle', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('treats a timeout-killed sandbox as quiescent during terminal cleanup', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/expired-sandbox')
|
||||
fake.sessionGroupsFailure = new SandboxNotFoundError('sandbox expired')
|
||||
fake.handle.succeed(0)
|
||||
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('treats sandbox disappearance during PTY kill as quiescent', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
fake.settleOnPtyKill = false
|
||||
fake.ptyKillError = new SandboxNotFoundError('sandbox expired')
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill')
|
||||
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
expect(fake.ptyKills).toBe(1)
|
||||
})
|
||||
|
||||
it('propagates a non-missing PTY kill failure', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
fake.settleOnPtyKill = false
|
||||
fake.ptyKillError = new Error('PTY kill transport failed')
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill')
|
||||
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).rejects.toThrow('PTY kill transport failed')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['accepts sandbox loss', new SandboxNotFoundError('sandbox expired'), true],
|
||||
['propagates another failure', new Error('disconnect failed'), false],
|
||||
] as const)('%s while disconnecting a settled terminal', async (_label, failure, accepted) => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), `/runtime/disconnect-${accepted}`)
|
||||
fake.handle.disconnectError = failure
|
||||
fake.groups = []
|
||||
fake.handle.succeed(0)
|
||||
|
||||
if (accepted) await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
else await expect(terminal.waitForExit()).rejects.toThrow('disconnect failed')
|
||||
})
|
||||
|
||||
it('rejects killing the terminal shell and propagates live foreground failures', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.foreground = '123\n'
|
||||
|
||||
Reference in New Issue
Block a user