fix(e2b): harden remote process lifecycle

This commit is contained in:
Tianyi Cui
2026-07-29 05:31:22 +08:00
parent 8877f5d582
commit fd78d9bc58
18 changed files with 586 additions and 153 deletions

View File

@@ -10,7 +10,7 @@ import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import type { SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import * as E2BSubprocessInvariant from '../src/invariant.ts'
import { E2BOutputReader } from '../src/output.ts'
import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from '../src/output.ts'
import { E2BSubprocessHandle } from '../src/process.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { describe, expect, it, vi } from 'vitest'
@@ -91,6 +91,7 @@ class FakeSandbox {
trapsTerm = false
delaysKill = false
alive = true
ambient = 'PATH=/ambient/bin\0KEEP=safe\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
processGroupId = '4242\n'
readonly processGroupReads: string[] = []
beforeProbe: (() => void) | undefined
@@ -110,15 +111,35 @@ class FakeSandbox {
finish(exitCode = 0): void {
this.alive = false
if (exitCode === 0) this.handle.succeed(0)
else this.handle.fail(exitCode)
void this.completeOutput().then(
() => {
if (exitCode === 0) this.handle.succeed(0)
else this.handle.fail(exitCode)
},
(error: unknown) => { this.handle.crash(error) },
)
}
async completeOutput(): Promise<void> {
await Promise.all([
this.stdoutWire(`${E2B_OUTPUT_COMPLETE_FRAME}\n`),
this.stderrWire(`${E2B_OUTPUT_COMPLETE_FRAME}\n`),
])
}
async stdout(data: string): Promise<void> {
await this.startOptions?.onStdout?.(data)
await this.stdoutWire(data.length === 0 ? '' : `${Buffer.from(data).toString('base64')}\n`)
}
async stderr(data: string): Promise<void> {
await this.stderrWire(data.length === 0 ? '' : `${Buffer.from(data).toString('base64')}\n`)
}
async stdoutWire(data: string): Promise<void> {
await this.startOptions?.onStdout?.(data)
}
async stderrWire(data: string): Promise<void> {
await this.startOptions?.onStderr?.(data)
}
@@ -147,6 +168,7 @@ class FakeSandbox {
commands: {
run: async (command: string, options?: StartOptions | { signal?: AbortSignal }): Promise<CommandHandle | CommandResult> => {
this.commandsSeen.push(command)
if (command === 'env -0') return { exitCode: 0, stdout: this.ambient, stderr: '' }
if (command.startsWith('kill -0 ')) {
this.beforeProbe?.()
if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
@@ -221,11 +243,35 @@ async function flush(): Promise<void> {
}
describe('E2BOutputReader', () => {
it('decodes base64 across arbitrary callback boundaries and rejects malformed framing', () => {
const decoder = new E2BBase64Decoder()
expect(decoder.push('')).toEqual(Buffer.alloc(0))
expect(decoder.push('5')).toEqual(Buffer.alloc(0))
expect(decoder.push('L2')).toEqual(Buffer.alloc(0))
expect(decoder.push('g\n').toString()).toBe('你')
expect(decoder.push('YQ==\nYg==\n').toString()).toBe('ab')
expect(decoder.push(`${Buffer.from([0, 255]).toString('base64')}\n`)).toEqual(Buffer.from([0, 255]))
expect(decoder.push(`${E2B_OUTPUT_COMPLETE_FRAME}\n`)).toEqual(Buffer.alloc(0))
decoder.finish()
expect(() => new E2BBase64Decoder().push('%\n')).toThrow('invalid base64')
expect(() => new E2BBase64Decoder().push('AB==\n')).toThrow('invalid base64')
expect(() => decoder.push(`${E2B_OUTPUT_COMPLETE_FRAME}\n`)).toThrow('duplicate output transport completion')
expect(() => decoder.push('YQ==\n')).toThrow('continued after completion')
const truncated = new E2BBase64Decoder()
truncated.push('YQ')
expect(() => { truncated.finish() }).toThrow('truncated base64')
expect(() => { new E2BBase64Decoder().finish() }).toThrow('incomplete output transport')
const interrupted = new E2BBase64Decoder()
interrupted.push('YQ')
expect(() => { interrupted.finish(false) }).not.toThrow()
})
it('keeps a byte-exact tail with independent whole-stream cursors', () => {
const reader = new E2BOutputReader(4, 10, '/remote/spill')
reader.push('')
reader.push('ab')
reader.push('cdef')
reader.push(Buffer.alloc(0))
reader.push(Buffer.from('ab'))
reader.push(Buffer.from('cdef'))
expect(reader.size).toBe(6)
expect(reader.readFrom(0)).toEqual({ text: 'cdef', nextOffset: 6, lossy: true, spillPath: '/remote/spill' })
expect(reader.readFrom(2)).toEqual({ text: 'cdef', nextOffset: 6, lossy: false })
@@ -235,11 +281,11 @@ describe('E2BOutputReader', () => {
it('drops whole head chunks and withholds absent or over-cap spills', () => {
const withoutSpill = new E2BOutputReader(2, undefined, '/unused')
withoutSpill.push('ab')
withoutSpill.push('cd')
withoutSpill.push(Buffer.from('ab'))
withoutSpill.push(Buffer.from('cd'))
expect(withoutSpill.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
const overCap = new E2BOutputReader(2, 3, '/too-small')
overCap.push('abcd')
overCap.push(Buffer.from('abcd'))
expect(overCap.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
expect(() => overCap.readFrom(-1)).toThrow(/non-negative safe integer/)
expect(() => overCap.readFrom(1.5)).toThrow(/non-negative safe integer/)
@@ -265,16 +311,22 @@ describe('E2BSubprocessHandle', () => {
expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
expect(fake.handle.closes).toBe(1)
expect(fake.startOptions?.envs).toBeUndefined()
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('exec setsid --wait -- bash -c')
const command = fake.commandsSeen.find(value => value.includes('exec "$dsh_e2b_env_bin" -i'))!
expect(command).toContain('"$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c')
expect(command).not.toContain('DEEPSEEK_API_KEY')
expect(command).not.toContain('DSH_MODE')
expect(command).not.toContain('FOO-BAR')
expect(command).not.toContain('explicit-secret')
expect(command).not.toContain('hyphen-value')
expect(command).not.toContain('${!dsh_e2b_name}')
expect(command).toContain('env -0')
expect(fake.commandsSeen).toContain('env -0')
expect(command).toContain('mapfile -d')
expect(command).toContain('dsh_e2b_node="$(command -v node)"')
expect(command).toContain('"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e')
expect(command).toContain('exec "$dsh_e2b_env_bin" -i "${dsh_e2b_env[@]}"')
expect(command).toContain('>&2 2>/dev/null')
expect(command).not.toContain('2>/dev/null >&2')
expect(command).toContain('base64')
expect(fake.writtenFiles[0]).toEqual([
'/workspace/.dsh-e2b/processes/one/pid',
'/workspace/.dsh-e2b/processes/one/exit-code',
@@ -282,7 +334,7 @@ describe('E2BSubprocessHandle', () => {
'/workspace/.dsh-e2b/processes/one/stderr.log',
])
expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe(
'PATH=/bin\0FOO-BAR=hyphen-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
'PATH=/bin\0KEEP=safe\0FOO-BAR=hyphen-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
)
let piped = ''
@@ -297,6 +349,47 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.waitForExit()).resolves.toBe(true)
})
it('preserves UTF-8 bytes when the ASCII transport is split across callbacks', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/split-utf8')
await flush()
const chunks: Buffer[] = []
handle.stdout!.on('data', (chunk: Buffer) => { chunks.push(chunk) })
for (const character of `${Buffer.from('A你好B').toString('base64')}\n`) {
await fake.stdoutWire(character)
}
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(Buffer.concat(chunks).toString('utf8')).toBe('A你好B')
})
it('rejects malformed output transport without confusing it with a consumer sink failure', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/malformed-output')
await flush()
await fake.stdoutWire('%\n')
fake.finish()
await expect(handle.done).rejects.toThrow('invalid base64 output transport')
const stderrFake = new FakeSandbox()
const stderrHandle = new E2BSubprocessHandle(runtime(stderrFake), spec(), '/runtime/malformed-stderr')
await flush()
await stderrFake.stderrWire('%\n')
stderrFake.finish()
await expect(stderrHandle.done).rejects.toThrow('invalid base64 output transport')
})
it('rejects a naturally completed command whose encoder omits its completion frame', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/incomplete-output')
await flush()
fake.alive = false
fake.handle.succeed(0)
await expect(handle.done).rejects.toThrow('incomplete output transport')
})
it('surfaces deferred piped-stdin write and close failures as stream errors', async () => {
const writeFake = new FakeSandbox()
writeFake.deferStart()
@@ -362,10 +455,10 @@ describe('E2BSubprocessHandle', () => {
await handle.done
expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
expect(fake.removed).toContain('/runtime/oversize/stdout.log')
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('head -c 3')
const command = fake.commandsSeen.find(value => value.includes('dsh_e2b_tee='))!
expect(command).toContain('"$dsh_e2b_head" -c 3')
expect(command).toContain('/runtime/oversize/stdout.log')
expect(command).toContain('tee --output-error=warn-nopipe')
expect(command).toContain('"$dsh_e2b_tee" --output-error=warn-nopipe')
expect(command).not.toContain('tee -a')
})
@@ -436,6 +529,7 @@ describe('E2BSubprocessHandle', () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/surviving-group')
await flush()
await fake.completeOutput()
fake.handle.succeed(0)
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(fake.alive).toBe(true)
@@ -640,7 +734,13 @@ describe('E2BSubprocessHandle', () => {
expect(failures[0].message).toContain('invalid process-group id')
expect(failures[1].message).toBe('rollback signal failed')
expect(fake.handle.kills).toBe(1)
fake.finish()
const bounded = new AbortController()
const waiting = handle.waitForExit(bounded.signal)
bounded.abort()
await expect(waiting).resolves.toBe(false)
handle.terminate()
await expect(handle.waitForExit()).resolves.toBe(true)
expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
})
it('waits for delayed process-group publication', async () => {

View File

@@ -30,10 +30,13 @@ class FakeTerminalCommandHandle {
sdkKills = 0
disconnectError: unknown
sdkKillError: unknown
waitError: unknown
settleOnSdkKill = true
private readonly result = Promise.withResolvers<CommandResult>()
private settled = false
wait(): Promise<CommandResult> {
if (this.waitError !== undefined) throw this.waitError
return this.result.promise
}
@@ -46,10 +49,10 @@ class FakeTerminalCommandHandle {
this.sdkKills += 1
if (this.sdkKillError !== undefined) {
const error = this.sdkKillError
this.fail(137)
if (this.settleOnSdkKill) this.fail(137)
throw error
}
this.fail(137)
if (this.settleOnSdkKill) this.fail(137)
return true
}
@@ -94,8 +97,10 @@ class FakeTerminalSandbox {
createError: unknown
sendError: unknown
commandFailure: unknown
sessionGroupsFailure: unknown
foregroundFailure: unknown
termFailure: unknown
ptyKillError: unknown
removeError: unknown
clearOnTerm = true
clearOnKill = true
@@ -146,6 +151,7 @@ class FakeTerminalSandbox {
return { exitCode: 0, stdout: this.foreground, stderr: '' }
}
if (command.startsWith('ps -eo sid=')) {
if (this.sessionGroupsFailure !== undefined) throw this.sessionGroupsFailure
return { exitCode: 0, stdout: this.groups.map(group => `${group}\n`).join(''), stderr: '' }
}
if (command.startsWith('kill -TERM -- ')) {
@@ -173,6 +179,7 @@ class FakeTerminalSandbox {
},
kill: async (pid: number): Promise<boolean> => {
this.ptyKills += 1
if (this.ptyKillError !== undefined) throw this.ptyKillError
if (this.settleOnPtyKill) this.handle.fail(137)
return pid === this.handle.pid
},
@@ -282,7 +289,8 @@ describe('E2B terminal allocation', () => {
failedInput.sendError = new Error('bootstrap failed')
await expect(spawnE2BTerminal(runtime(failedInput), spec(), '/runtime/input'))
.rejects.toThrow('bootstrap failed')
expect(failedInput.handle.sdkKills).toBe(1)
expect(failedInput.commands).toContain('kill -TERM -- -123')
expect(failedInput.groups).toEqual([])
const exited = new FakeTerminalSandbox()
exited.ready = new FileNotFoundError('not ready')
@@ -292,13 +300,62 @@ describe('E2B terminal allocation', () => {
const invalidSession = new FakeTerminalSandbox()
invalidSession.sessionId = 'not-a-session\n'
invalidSession.clearOnTerm = false
await expect(spawnE2BTerminal(runtime(invalidSession), spec(), '/runtime/session'))
.rejects.toThrow('cannot resolve process session')
expect(invalidSession.handle.sdkKills).toBe(1)
expect(invalidSession.commands).toContain('kill -TERM -- -123')
expect(invalidSession.commands).toContain('kill -KILL -- -123')
expect(invalidSession.groups).toEqual([])
expect(invalidSession.ptyKills).toBe(1)
const lateData = invalidSession.createOptions?.onData
if (lateData === undefined) throw new Error('missing captured terminal callback')
expect(lateData(Buffer.from('late bytes'))).toBeUndefined()
const termFailed = new FakeTerminalSandbox()
termFailed.sendError = new Error('bootstrap failed')
termFailed.termFailure = new Error('TERM transport failed')
await expect(spawnE2BTerminal(runtime(termFailed), spec(), '/runtime/term-failed'))
.rejects.toThrow('bootstrap failed')
expect(termFailed.commands).toContain('kill -KILL -- -123')
expect(termFailed.ptyKills).toBe(1)
const uninspectable = new FakeTerminalSandbox()
uninspectable.sendError = new Error('bootstrap failed')
uninspectable.sessionGroupsFailure = 'session enumeration failed'
uninspectable.ptyKillError = new Error('PTY kill failed')
let uninspectableFailure: unknown
try {
await spawnE2BTerminal(runtime(uninspectable), spec(), '/runtime/uninspectable')
} catch (error: unknown) {
uninspectableFailure = error
}
expect(uninspectableFailure).toBeInstanceOf(AggregateError)
expect(uninspectable.ptyKills).toBe(1)
expect(uninspectable.handle.sdkKills).toBe(1)
const survivingGroups = new FakeTerminalSandbox()
survivingGroups.sendError = new Error('bootstrap failed')
survivingGroups.clearOnTerm = false
survivingGroups.clearOnKill = false
await expect(spawnE2BTerminal(runtime(survivingGroups), spec({ graceMs: 1 }), '/runtime/surviving-groups'))
.rejects.toThrow('bootstrap failed')
const survivingPid = new FakeTerminalSandbox()
survivingPid.sendError = new Error('bootstrap failed')
survivingPid.groups = []
survivingPid.settleOnPtyKill = false
survivingPid.handle.settleOnSdkKill = false
await expect(spawnE2BTerminal(runtime(survivingPid), spec({ graceMs: 1 }), '/runtime/surviving-pid'))
.rejects.toThrow('bootstrap failed')
const waitFailed = new FakeTerminalSandbox()
waitFailed.handle.waitError = new Error('wait failed')
waitFailed.handle.settleOnSdkKill = false
waitFailed.handle.sdkKillError = new Error('kill failed')
await expect(spawnE2BTerminal(runtime(waitFailed), spec(), '/runtime/wait-failed'))
.rejects.toThrow('wait failed')
expect(waitFailed.handle.sdkKills).toBe(1)
const cleanupFailed = new FakeTerminalSandbox()
cleanupFailed.handle.pid = 0
cleanupFailed.handle.sdkKillError = new Error('kill transport failed')
@@ -362,7 +419,7 @@ describe('E2B terminal lifecycle', () => {
it.each([
[7, { exitCode: 7, signal: null }],
[143, { exitCode: null, signal: 'SIGTERM' }],
[143, { exitCode: 143, signal: null }],
[255, { exitCode: 255, signal: null }],
] as const)('classifies an unrequested command exit %i', async (exitCode, expected) => {
const fake = new FakeTerminalSandbox()