fix(e2b): close final lifecycle gaps
This commit is contained in:
@@ -107,6 +107,7 @@ class FakeSandbox {
|
||||
delaysKillCompletion = false
|
||||
sdkKillStops = true
|
||||
alive = true
|
||||
zombieOnly = false
|
||||
ambient = 'PATH=/ambient/bin\0KEEP=safe\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
|
||||
processGroupId = '4242\n'
|
||||
exitStatus = ''
|
||||
@@ -235,7 +236,7 @@ class FakeSandbox {
|
||||
if (this.envError !== undefined) throw this.envError
|
||||
return { exitCode: 0, stdout: this.ambient, stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -0 ')) {
|
||||
if (command.startsWith('set -o pipefail; ps -eo pgid=,stat=')) {
|
||||
this.beforeProbe?.()
|
||||
if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
|
||||
if (this.probeError !== undefined) {
|
||||
@@ -243,9 +244,9 @@ class FakeSandbox {
|
||||
this.probeError = undefined
|
||||
throw error
|
||||
}
|
||||
if (!this.alive) throw commandError(1)
|
||||
const stdout = this.alive && !this.zombieOnly ? 'live\n' : ''
|
||||
this.afterProbe?.()
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
return { exitCode: 0, stdout, stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -TERM ')) {
|
||||
await this.signalGate
|
||||
@@ -701,6 +702,20 @@ describe('E2BSubprocessHandle', () => {
|
||||
expect(fake.commandsSeen.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
|
||||
})
|
||||
|
||||
it('treats a zombie-only process group as quiescent', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
fake.zombieOnly = true
|
||||
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/zombie-quiescence')
|
||||
await flush()
|
||||
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
expect(fake.commandsSeen).toContain(
|
||||
'set -o pipefail; ps -eo pgid=,stat= | awk \'$1 == 4242 && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }\'',
|
||||
)
|
||||
fake.finish()
|
||||
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
})
|
||||
|
||||
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'))
|
||||
@@ -1187,6 +1202,23 @@ describe('E2BSubprocessHandle', () => {
|
||||
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
})
|
||||
|
||||
it('settles output backpressure when the consumer closes the pipe', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new E2BSubprocessHandle(runtime(fake), spec({
|
||||
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: { maxBytes: 4 } },
|
||||
}), '/runtime/backpressure-close')
|
||||
await flush()
|
||||
|
||||
const stdoutWrite = vi.spyOn(handle.stdout!, 'write').mockReturnValueOnce(false)
|
||||
const pending = fake.stdout('discarded')
|
||||
queueMicrotask(() => { handle.stdout!.destroy() })
|
||||
await pending
|
||||
stdoutWrite.mockRestore()
|
||||
|
||||
fake.finish()
|
||||
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
})
|
||||
|
||||
it('contains a pipe callback failure instead of rejecting command settlement', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new E2BSubprocessHandle(runtime(fake), spec({
|
||||
|
||||
@@ -95,6 +95,7 @@ class FakeTerminalSandbox {
|
||||
sessionId = '123\n'
|
||||
foreground = '456\n'
|
||||
groups = [123]
|
||||
zombieGroups: number[] = []
|
||||
createError: unknown
|
||||
writeError: unknown
|
||||
sendError: unknown
|
||||
@@ -160,9 +161,12 @@ class FakeTerminalSandbox {
|
||||
if (this.foregroundFailure !== undefined) throw this.foregroundFailure
|
||||
return { exitCode: 0, stdout: this.foreground, stderr: '' }
|
||||
}
|
||||
if (command.startsWith('ps -eo sid=')) {
|
||||
if (command.startsWith('set -o pipefail; ps -eo sid=')) {
|
||||
if (this.sessionGroupsFailure !== undefined) throw this.sessionGroupsFailure
|
||||
return { exitCode: 0, stdout: this.groups.map(group => `${group}\n`).join(''), stderr: '' }
|
||||
const groups = command.includes('stat=') && command.includes('$3 !~ /^[ZXx]/')
|
||||
? this.groups
|
||||
: [...this.groups, ...this.zombieGroups]
|
||||
return { exitCode: 0, stdout: groups.map(group => `${group}\n`).join(''), stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -TERM -- ')) {
|
||||
if (this.termFailure !== undefined) throw this.termFailure
|
||||
@@ -508,6 +512,20 @@ describe('E2B terminal lifecycle', () => {
|
||||
await expect(quiescence).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('treats a terminal session containing only zombies as quiescent', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
fake.zombieGroups = [123]
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/zombie-session')
|
||||
|
||||
fake.handle.succeed(0)
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
expect(fake.commands).toContain(
|
||||
"set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == 123 && $3 !~ /^[ZXx]/ { print $2 }'",
|
||||
)
|
||||
})
|
||||
|
||||
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