fix(e2b): harden cancellation and teardown boundaries

This commit is contained in:
Tianyi Cui
2026-07-29 11:23:01 +08:00
parent cf4b721a8d
commit c122d984c4
17 changed files with 351 additions and 38 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md
README.md: ccafa9df480e3812d3d3b6d25b513e7b5d2afc9f
README.zh.md: 60eefd46a0a9a017f4db57e4dc5313d533eeba04
README.md: 6eb7d69dd6355de870db6bf05540f2629f464bc8
README.zh.md: b2e2616cf779bce659fdef58840aa51f095a73cf

View File

@@ -30,7 +30,7 @@ Set `sandboxId` to reconnect a running or paused sandbox instead of creating one
Construction starts one create/connect operation. Before resolving `getSandbox()`, the service creates `cwd` and the private `cwd/.dsh-e2b` adapter-state directory, verifies that the reserved path is a real directory rather than a symlink or another file type, then sets it to mode `0700`. `sandboxId` resolves to a branded `E2BSandboxId` after setup.
Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A `SandboxNotFoundError` means a kill-on-timeout sandbox is already quiescent; every other disposition failure rejects teardown. A newly created sandbox is killed when initial directory setup fails; if that rollback fails, disposal retries it before releasing ownership. A reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it.
Disposal first prevents new handle acquisition, then awaits setup and applies exactly one configured disposition. A `SandboxNotFoundError` is accepted when disposal requests `kill`, or when this service created a sandbox with `onTimeout: kill`; otherwise, a not-found error from a requested `pause` rejects teardown because retention was not proved. A newly created sandbox is killed when initial directory setup fails; if that rollback fails, disposal retries it before releasing ownership. A reconnected sandbox is not killed on setup failure because the service did not create it. Provider plugins must load after this owner and dispose before it.
`pause` and `leave` retain remote filesystem and adapter artifacts for a later `sandboxId` connection, but a later harness process receives only a new SDK handle. The subprocess service still fulfills its seam contract by terminating managed groups before owner disposal; neither disposition recovers prior process objects, output cursors, or in-memory adapter locks.

View File

@@ -30,7 +30,7 @@
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 仅在资源释放请求 `kill`,或本服务创建了配置为 `onTimeout: kill` 的沙箱时才可接受;否则,`pause` 请求返回的未找到错误会导致 teardown 拒绝,因为无法证明保留成功。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
`pause` 和 `leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。

View File

@@ -181,9 +181,13 @@ export class E2BSandboxService extends Service {
return
}
} catch (error: unknown) {
// A kill-on-timeout sandbox is already quiescent; every other disposal
// failure still reports that the configured final disposition is unknown.
if (!(error instanceof SandboxNotFoundError)) throw error
// Missing proves the requested disposition only when this owner asked
// for deletion or created the sandbox with timeout deletion. A
// reconnected sandbox's creation lifecycle is unknown.
if (this.config.onDispose === 'kill') return
if (this.created && this.config.onTimeout === 'kill') return
throw error
}
}, 'e2b sandbox teardown')
}

View File

@@ -160,15 +160,59 @@ describe('E2BSandboxService', () => {
expect(fixture.pause).toHaveBeenCalledOnce()
})
it('does not classify other disposal failures as an already-gone sandbox', async () => {
it('accepts a missing sandbox when disposal itself requests deletion', async () => {
const fixture = fakeSandbox()
fixture.kill.mockRejectedValue(new Error('disposition unknown'))
fixture.kill.mockRejectedValue(new SandboxNotFoundError('already deleted'))
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await ctx.e2b.getSandbox()
await fiber.dispose()
expect(fixture.kill).toHaveBeenCalledOnce()
expect(errors).toEqual([])
})
it('does not classify other disposal failures as an already-gone sandbox', async () => {
const fixture = fakeSandbox()
const failure = new Error('disposition unknown')
fixture.kill.mockRejectedValue(failure)
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await ctx.e2b.getSandbox()
await expect(fiber.dispose()).resolves.toBeUndefined()
expect(fixture.kill).toHaveBeenCalledOnce()
expect(errors).toContain(failure)
})
it.each([
['a created pause-on-timeout sandbox', false],
['a reconnected sandbox with unknown creation policy', true],
] as const)('reports missing during pause disposal for %s', async (_label, reconnect) => {
const fixture = fakeSandbox()
const failure = new SandboxNotFoundError('sandbox unexpectedly missing')
fixture.pause.mockRejectedValue(failure)
if (reconnect) sdk.connect.mockResolvedValue(fixture.sandbox)
else sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const errors: unknown[] = []
ctx.logger.error = ((error: unknown) => { errors.push(error) }) as typeof ctx.logger.error
const fiber = await ctx.plugin(E2BSandboxService, {
apiKey: 'test-key',
onTimeout: reconnect ? 'kill' : 'pause',
onDispose: 'pause',
...(reconnect ? { sandboxId: 'existing' } : {}),
})
await ctx.e2b.getSandbox()
await expect(fiber.dispose()).resolves.toBeUndefined()
expect(fixture.pause).toHaveBeenCalledOnce()
expect(errors).toContain(failure)
})
it('reconnects without applying creation lifecycle options and can leave state running', async () => {