fix(e2b): address review round on cadence config, disposal, and SDK edge cases
- subprocess-e2b: the 20 ms remote poll cadence becomes a validated pollMs Config field (each tick is one control-plane request); the README documents the latency-versus-request-count trade. - subprocess-e2b: extract src/remote.ts owning asError, signalOpts, commandOpts, delay, waitTick, and one tolerant signalRemoteGroups shared by the pgid-keyed process ladder and sid-keyed terminal ladder, so the two teardown paths keep identical error tolerance. - subprocess-e2b: service disposal aggregates sibling cleanup failures into one AggregateError instead of discarding all but the first. - subprocess-e2b: waitForProcessGroupId refuses published group ids <= 1, so a same-UID rewrite of the pid file cannot aim termination at kill -- -1; README documents the same-UID control-state limitation. - subprocess-e2b: drain-grace expiry now releases an inherited-output E2B callback blocked on host backpressure before disconnecting, so the SDK settlement cannot stay pinned behind an unread host stream. - subprocess-e2b: spawn/spawnTerminal stop validating typed spec fields (trust-TypeScript rule; pty-local validates its config before specs exist); resolveExecutable rejects separator-containing relative paths per the seam contract; terminal setups tracked as a Set of records. - subprocess-e2b: PTY output push-without-backpressure is a documented contract (flowing consumer folds bytes; paused consumer buffers). - fs-e2b: streamText normalizes the pinned SDK's empty-file '' return into an empty stream instead of throwing on getReader(). - e2b overlays: comment the one-world cwd invariant across e2b.cwd, workspaceRoot, and bash-local's implicit default workdir.
This commit is contained in:
@@ -519,6 +519,38 @@ describe('E2BSubprocessHandle', () => {
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('releases an inherited-output callback blocked on host backpressure at drain expiry', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const written: string[] = []
|
||||
const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: Uint8Array) => {
|
||||
written.push(Buffer.from(chunk).toString())
|
||||
return false
|
||||
}) as typeof process.stdout.write)
|
||||
try {
|
||||
const handle = new E2BSubprocessHandle(runtime(fake), spec({
|
||||
graceMs: 5,
|
||||
stdio: { stdin: 'ignore', stdout: 'inherit', stderr: { maxBytes: 4 } },
|
||||
}), '/runtime/inherit-backpressure')
|
||||
await flush()
|
||||
let callbackSettled = false
|
||||
const blocked = fake.stdout('blocked bytes').then(() => { callbackSettled = true })
|
||||
await flush()
|
||||
expect(callbackSettled).toBe(false)
|
||||
fake.exitStatus = '0\n'
|
||||
|
||||
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
await blocked
|
||||
expect(callbackSettled).toBe(true)
|
||||
expect(written.join('')).toBe('blocked bytes')
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
|
||||
handle.terminate()
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
} finally {
|
||||
stdoutWrite.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
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({
|
||||
@@ -1311,6 +1343,17 @@ describe('E2BSubprocessHandle', () => {
|
||||
expect(invalidGroup.commandsSeen).toContain('kill -KILL -- -4242')
|
||||
await expect(invalid.waitForExit()).resolves.toBe(true)
|
||||
|
||||
// A rewritten pid file must not aim the kill at every process (`-- -1`).
|
||||
const unsafeGroup = new FakeSandbox()
|
||||
unsafeGroup.processGroupId = '1\n'
|
||||
unsafeGroup.delaysKill = true
|
||||
unsafeGroup.sdkKillStops = false
|
||||
unsafeGroup.afterProbe = () => { unsafeGroup.alive = false }
|
||||
const unsafe = new E2BSubprocessHandle(runtime(unsafeGroup), spec(), '/runtime/unsafe-group')
|
||||
await expect(unsafe.done).rejects.toThrow(/unsafe published process-group id 1/)
|
||||
expect(unsafeGroup.commandsSeen).not.toContain('kill -KILL -- -1')
|
||||
await expect(unsafe.waitForExit()).resolves.toBe(true)
|
||||
|
||||
const absentGroup = new FakeSandbox()
|
||||
absentGroup.processGroupId = ''
|
||||
const absent = new E2BSubprocessHandle(runtime(absentGroup), spec(), '/runtime/absent-group')
|
||||
@@ -1588,6 +1631,34 @@ describe('E2BSubprocessService', () => {
|
||||
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
|
||||
})
|
||||
|
||||
it('aggregates sibling cleanup failures instead of reporting only the first', async () => {
|
||||
const { ctx, fiber } = await service()
|
||||
const disposalErrors: unknown[] = []
|
||||
ctx.logger.error = ((error: unknown) => { disposalErrors.push(error) }) as typeof ctx.logger.error
|
||||
const first = {
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw new Error('first cleanup failed') }),
|
||||
done: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
} as unknown as E2BSubprocessHandle
|
||||
const second = {
|
||||
terminate: vi.fn(),
|
||||
waitForExit: vi.fn(async () => { throw new Error('second cleanup failed') }),
|
||||
done: Promise.resolve({ exitCode: 0, signal: null }),
|
||||
} as unknown as E2BSubprocessHandle
|
||||
const live = (ctx.subprocess as unknown as { live: Set<E2BSubprocessHandle> }).live
|
||||
live.add(first)
|
||||
live.add(second)
|
||||
|
||||
await fiber.dispose()
|
||||
const failure = disposalErrors[0]
|
||||
expect(failure).toBeInstanceOf(AggregateError)
|
||||
if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError')
|
||||
expect(failure.errors.map(error => (error as Error).message).sort()).toEqual([
|
||||
'first cleanup failed',
|
||||
'second cleanup failed',
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for every owned cleanup before reporting a disposal failure', async () => {
|
||||
const { ctx, fiber } = await service()
|
||||
const failed = {
|
||||
@@ -1667,7 +1738,6 @@ describe('E2BSubprocessService', () => {
|
||||
it('validates synchronous spawn preconditions', async () => {
|
||||
const { ctx } = await service()
|
||||
expect(() => ctx.subprocess.spawn(spec({ argv: [] }))).toThrow(/non-empty program/)
|
||||
expect(() => ctx.subprocess.spawn(spec({ graceMs: 0 }))).toThrow(/positive finite/)
|
||||
expect(() => ctx.subprocess.spawn(spec({ signal: AbortSignal.abort('stop') }))).toThrow(/aborted before spawn/)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user