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:
Tianyi Cui
2026-08-02 15:38:00 +08:00
parent e6b3afbee9
commit f5866fc202
15 changed files with 339 additions and 139 deletions

View File

@@ -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/)
})

View File

@@ -793,6 +793,8 @@ describe('E2B subprocess terminal service', () => {
it('rejects invalid executable lookup inputs and results', async () => {
const { ctx, fake } = await service()
await expect(ctx.subprocess.resolveExecutable('')).rejects.toThrow('non-empty')
await expect(ctx.subprocess.resolveExecutable('./bin/server')).rejects.toThrow('is a relative path')
await expect(ctx.subprocess.resolveExecutable('node_modules/.bin/server')).rejects.toThrow('is a relative path')
await expect(ctx.subprocess.resolveExecutable('node', undefined, AbortSignal.abort(new Error('stop'))))
.rejects.toThrow('stop')
fake.resolvedExecutable = 'node\n'
@@ -801,6 +803,15 @@ describe('E2B subprocess terminal service', () => {
await expect(ctx.subprocess.resolveExecutable('node')).rejects.toThrow('did not resolve')
})
it('rejects a non-positive poll cadence at load', async () => {
const ctx = new Context()
ctx.provide('e2b', runtime(new FakeTerminalSandbox()))
await expect(ctx.plugin(E2BSubprocessService, { pollMs: 0 }))
.rejects.toThrow('pollMs must be a positive safe integer')
const explicit = await ctx.plugin(E2BSubprocessService, { pollMs: 5 })
await explicit.dispose()
})
it('owns live terminals through service disposal', async () => {
const { ctx, fiber, fake } = await service()
const terminal = await ctx.subprocess.spawnTerminal(spec({ signal: new AbortController().signal }))
@@ -873,9 +884,6 @@ describe('E2B subprocess terminal service', () => {
const { ctx, fiber, fake } = await service()
for (const request of [
spec({ argv: [] }),
spec({ rows: 0 }),
spec({ cols: 1.5 }),
spec({ graceMs: 0 }),
spec({ signal: AbortSignal.abort(new Error('cancelled')) }),
]) {
await expect(ctx.subprocess.spawnTerminal(request)).rejects.toThrow()