fix(e2b): close adapter lifecycle review gaps

This commit is contained in:
Tianyi Cui
2026-07-29 09:19:46 +08:00
parent f5306f6e89
commit 219999cd2f
14 changed files with 375 additions and 103 deletions

View File

@@ -97,6 +97,7 @@ class FakeSandbox {
startOptions: StartOptions | undefined
backgroundError: unknown
envError: unknown
statusError: unknown
nextRemoveError: unknown
probeError: unknown
signalError: unknown
@@ -117,6 +118,8 @@ class FakeSandbox {
private openStart: (() => void) | undefined
private processGroupReadGate: Promise<void> | undefined
private openProcessGroupRead: (() => void) | undefined
private signalGate: Promise<void> | undefined
private openSignal: (() => void) | undefined
constructor() {
this.handle = new FakeCommandHandle(() => {
@@ -147,6 +150,16 @@ class FakeSandbox {
this.openProcessGroupRead?.()
}
deferSignals(): void {
const gate = Promise.withResolvers<undefined>()
this.signalGate = gate.promise
this.openSignal = () => { gate.resolve(undefined) }
}
releaseSignals(): void {
this.openSignal?.()
}
finish(exitCode = 0): void {
this.alive = false
void this.completeOutput().then(
@@ -198,6 +211,11 @@ class FakeSandbox {
await this.processGroupReadGate
return this.processGroupReads.shift() ?? this.processGroupId
}
if (this.statusError !== undefined) {
const error = this.statusError
this.statusError = undefined
throw error
}
this.afterStatusRead?.()
return this.exitStatus
},
@@ -230,6 +248,7 @@ class FakeSandbox {
return { exitCode: 0, stdout: '', stderr: '' }
}
if (command.startsWith('kill -TERM ')) {
await this.signalGate
const error = this.signalErrors.shift() ?? this.signalError
if (error !== undefined) {
if (this.signalErrors.length === 0) this.signalError = undefined
@@ -242,6 +261,7 @@ class FakeSandbox {
return { exitCode: 0, stdout: '', stderr: '' }
}
if (command.startsWith('kill -KILL ')) {
await this.signalGate
const error = this.signalErrors.shift() ?? this.signalError
if (error !== undefined) {
if (this.signalErrors.length === 0) this.signalError = undefined
@@ -399,6 +419,16 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.waitForExit()).resolves.toBe(true)
})
it('rejects malformed environment entries before command start', async () => {
for (const env of [{ 'BAD=NAME': 'x' }, { BAD: 'x\0INJECTED=1' }]) {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({ env }), '/runtime/invalid-environment')
await expect(handle.done).rejects.toThrow('environment entries')
expect(fake.startOptions).toBeUndefined()
expect(fake.removed).toContain('/runtime/invalid-environment')
}
})
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({
@@ -460,6 +490,29 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.waitForExit()).resolves.toBe(true)
})
it('waits for lossless raw-pipe output after the direct status is published', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({
graceMs: 1,
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
}), '/runtime/pipe-drain')
let output = ''
handle.stdout!.on('data', (chunk) => { output += String(chunk) })
await flush()
fake.exitStatus = '0\n'
let settled = false
void handle.done.then(() => { settled = true })
await new Promise(resolve => setTimeout(resolve, 50))
expect(settled).toBe(false)
expect(fake.handle.disconnects).toBe(0)
await fake.stdout('complete protocol frame')
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(output).toBe('complete protocol frame')
})
it('accepts clean encoder completion inside the output-drain grace', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 100 }), '/runtime/drain-complete')
@@ -474,7 +527,7 @@ describe('E2BSubprocessHandle', () => {
expect(fake.handle.disconnects).toBe(0)
})
it('preserves a requested signal when output draining expires', async () => {
it('preserves a published exit code when requested termination outlives output draining', async () => {
const fake = new FakeSandbox()
fake.trapsTerm = true
fake.delaysKill = true
@@ -485,9 +538,9 @@ describe('E2BSubprocessHandle', () => {
handle.terminate()
await vi.waitFor(() => { expect(fake.commandsSeen).toContain('kill -KILL -- -4242') })
fake.exitStatus = '143\n'
fake.exitStatus = '0\n'
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(fake.handle.disconnects).toBe(1)
fake.alive = false
await expect(handle.waitForExit()).resolves.toBe(true)
@@ -503,6 +556,31 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.waitForExit()).resolves.toBe(true)
})
it('rolls back a published process group before rejecting a monitoring failure', async () => {
const fake = new FakeSandbox()
fake.statusError = new Error('status transport failed')
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/status-failure')
await expect(handle.done).rejects.toThrow('status transport failed')
expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
expect(fake.alive).toBe(false)
await expect(handle.waitForExit()).resolves.toBe(true)
const failed = new FakeSandbox()
failed.statusError = new Error('status transport failed')
failed.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
failed.handle.killError = new Error('SDK kill failed')
const retained = new E2BSubprocessHandle(runtime(failed), spec({ graceMs: 1 }), '/runtime/status-cleanup-failure')
await expect(retained.done).rejects.toThrow(
'command monitoring failed and process-group rollback did not reach quiescence',
)
expect(failed.alive).toBe(true)
failed.handle.killError = undefined
retained.terminate()
await expect(retained.waitForExit()).resolves.toBe(true)
})
it('surfaces deferred piped-stdin write and close failures as stream errors', async () => {
const writeFake = new FakeSandbox()
writeFake.deferStart()
@@ -607,6 +685,46 @@ describe('E2BSubprocessHandle', () => {
expect(fake.commandsSeen.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
})
it('makes termination a permanent no-op after natural quiescence is observed', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/natural-quiescence')
await flush()
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
await expect(handle.waitForExit()).resolves.toBe(true)
const signals = fake.commandsSeen.filter(command => command.startsWith('kill -')).length
fake.alive = true
handle.terminate()
await flush()
expect(fake.alive).toBe(true)
expect(fake.commandsSeen.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
})
it('keeps proven quiescence after a concurrent termination transport fails', async () => {
const fake = new FakeSandbox()
fake.signalErrors.push(new Error('TERM transport failed'), new Error('KILL transport failed'))
fake.handle.killError = new Error('SDK kill failed')
fake.deferSignals()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/quiescent-race')
await flush()
handle.terminate()
await vi.waitFor(() => { expect(fake.commandsSeen).toContain('kill -TERM -- -4242') })
fake.alive = false
await expect(handle.waitForExit()).resolves.toBe(true)
fake.probeError = new Error('post-quiescence probe failed')
fake.releaseSignals()
await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
fake.finish()
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
const signals = fake.commandsSeen.filter(command => command.startsWith('kill -')).length
handle.terminate()
await expect(handle.waitForExit()).resolves.toBe(true)
expect(fake.commandsSeen.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
})
it('escalates a TERM-trapping process group to KILL and uses the SDK kill as fallback', async () => {
const fake = new FakeSandbox()
fake.trapsTerm = true
@@ -620,6 +738,28 @@ describe('E2BSubprocessHandle', () => {
expect(fake.handle.kills).toBe(1)
})
it('does not treat successful termination transport as observed quiescence', async () => {
const fake = new FakeSandbox()
fake.trapsTerm = true
fake.delaysKill = true
fake.delaysKillCompletion = true
fake.sdkKillStops = false
const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/termination-fence')
await flush()
handle.terminate()
await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
let quiescent = false
const waiting = handle.waitForExit().then((value) => { quiescent = value })
await new Promise(resolve => setTimeout(resolve, 10))
expect(quiescent).toBe(false)
fake.alive = false
fake.finish()
await waiting
expect(quiescent).toBe(true)
})
it('honors termination requested before asynchronous startup finishes', async () => {
const fake = new FakeSandbox()
fake.deferStart()