fix(e2b): close adapter lifecycle review gaps
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -111,18 +111,7 @@ class FakeTerminalSandbox {
|
||||
resolvedExecutable = '/usr/bin/node\n'
|
||||
requestedOutput = 'requested-shell$ '
|
||||
emitOutputMarker = true
|
||||
private createGate: Promise<void> | 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?.()
|
||||
}
|
||||
afterSessionLookup: (() => void) | undefined
|
||||
|
||||
readonly sandbox = {
|
||||
files: {
|
||||
@@ -163,7 +152,10 @@ class FakeTerminalSandbox {
|
||||
if (command.includes('command -v -- ')) {
|
||||
return { exitCode: 0, stdout: this.resolvedExecutable, stderr: '' }
|
||||
}
|
||||
if (command.startsWith('ps -o sid=')) return { exitCode: 0, stdout: this.sessionId, stderr: '' }
|
||||
if (command.startsWith('ps -o sid=')) {
|
||||
this.afterSessionLookup?.()
|
||||
return { exitCode: 0, stdout: this.sessionId, stderr: '' }
|
||||
}
|
||||
if (command.startsWith('ps -o tpgid=')) {
|
||||
if (this.foregroundFailure !== undefined) throw this.foregroundFailure
|
||||
return { exitCode: 0, stdout: this.foreground, stderr: '' }
|
||||
@@ -186,7 +178,6 @@ class FakeTerminalSandbox {
|
||||
pty: {
|
||||
create: async (options: Parameters<Sandbox['pty']['create']>[0]): Promise<CommandHandle> => {
|
||||
this.createOptions = options
|
||||
await this.createGate
|
||||
if (this.createError !== undefined) throw this.createError
|
||||
await options.onData(Buffer.from('buffered banner\n'))
|
||||
return this.handle.asHandle()
|
||||
@@ -691,7 +682,7 @@ describe('E2B subprocess terminal service', () => {
|
||||
|
||||
it('owns live terminals through service disposal', async () => {
|
||||
const { ctx, fiber, fake } = await service()
|
||||
const terminal = await ctx.subprocess.spawnTerminal(spec())
|
||||
const terminal = await ctx.subprocess.spawnTerminal(spec({ signal: new AbortController().signal }))
|
||||
await fiber.dispose()
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
@@ -699,19 +690,19 @@ describe('E2B subprocess terminal service', () => {
|
||||
|
||||
it('joins and rejects terminal setup that completes during service disposal', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.deferCreate()
|
||||
const { ctx, fiber } = await service(fake)
|
||||
let disposing: Promise<void> | undefined
|
||||
fake.afterSessionLookup = () => {
|
||||
fake.afterSessionLookup = undefined
|
||||
queueMicrotask(() => {
|
||||
queueMicrotask(() => { disposing = fiber.dispose() })
|
||||
})
|
||||
}
|
||||
const subprocess = ctx.subprocess
|
||||
const spawning = ctx.subprocess.spawnTerminal(spec())
|
||||
const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
|
||||
await vi.waitFor(() => { expect(fake.createOptions).toBeDefined() })
|
||||
|
||||
let disposed = false
|
||||
const subprocess = ctx.subprocess
|
||||
const disposing = fiber.dispose().then(() => { disposed = true })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(disposed).toBe(false)
|
||||
await vi.waitFor(() => { expect(disposing).toBeDefined() })
|
||||
await expect(subprocess.spawnTerminal(spec())).rejects.toThrow('service is disposing')
|
||||
fake.releaseCreate()
|
||||
|
||||
await rejected
|
||||
await disposing
|
||||
@@ -720,6 +711,37 @@ describe('E2B subprocess terminal service', () => {
|
||||
expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
|
||||
})
|
||||
|
||||
it('aborts and rolls back terminal setup that cannot publish readiness during disposal', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.ready = new FileNotFoundError('not ready')
|
||||
const { ctx, fiber } = await service(fake)
|
||||
const spawning = ctx.subprocess.spawnTerminal(spec())
|
||||
const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
|
||||
await vi.waitFor(() => { expect(fake.readyReads).toBeGreaterThan(0) })
|
||||
|
||||
await fiber.dispose()
|
||||
await rejected
|
||||
expect(fake.groups).toEqual([])
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
})
|
||||
|
||||
it('retains failed terminal setup cleanup for disposal retry', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.sendError = new Error('bootstrap failed')
|
||||
fake.clearOnTerm = false
|
||||
fake.clearOnKill = false
|
||||
const { ctx, fiber } = await service(fake)
|
||||
|
||||
await expect(ctx.subprocess.spawnTerminal(spec({ graceMs: 1 }))).rejects.toThrow('bootstrap failed')
|
||||
expect(fake.groups).toEqual([123])
|
||||
expect(fake.handle.disconnects).toBe(0)
|
||||
|
||||
fake.clearOnKill = true
|
||||
await fiber.dispose()
|
||||
expect(fake.groups).toEqual([])
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
})
|
||||
|
||||
it('releases naturally settled terminals and validates terminal requests', async () => {
|
||||
const { ctx, fiber, fake } = await service()
|
||||
for (const request of [
|
||||
|
||||
Reference in New Issue
Block a user