fix(e2b): close final lifecycle gaps

This commit is contained in:
Tianyi Cui
2026-07-29 09:54:14 +08:00
parent 219999cd2f
commit d0648446c9
12 changed files with 111 additions and 27 deletions
+2 -2
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: 01c7ade2f5f31e34091ad0ae910287e659f15549
README.zh.md: fb95a12cbbbd431ccb3283a14bb63b324f9bdea6
README.md: ccafa9df480e3812d3d3b6d25b513e7b5d2afc9f
README.zh.md: 60eefd46a0a9a017f4db57e4dc5313d533eeba04
+1 -1
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; 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` 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.
`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.
+1 -1
View File
@@ -30,7 +30,7 @@
构造阶段会启动一次 create/connect 操作。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。初始化完成后,`sandboxId` 会结算为品牌类型 `E2BSandboxId`。
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;如果该回滚失败,资源释放会在解除所有权前重试。重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
`pause` 和 `leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。
+14 -3
View File
@@ -120,6 +120,7 @@ export class E2BSandboxService extends Service {
private readonly config: ResolvedConfig
private readonly ready: Promise<Sandbox>
private failedSetupSandbox: Sandbox | undefined
private disposed = false
constructor(ctx: Context, config: Config) {
@@ -155,8 +156,16 @@ export class E2BSandboxService extends Service {
try {
sandbox = await this.ready
} catch {
// Connection creation already failed and is exposed by getSandbox();
// there is no remote resource for teardown to own.
const failedSetupSandbox = this.failedSetupSandbox
if (failedSetupSandbox === undefined) return
sandbox = failedSetupSandbox
try {
await sandbox.kill()
this.failedSetupSandbox = undefined
} catch (error: unknown) {
if (!(error instanceof SandboxNotFoundError)) throw error
this.failedSetupSandbox = undefined
}
return
}
try {
@@ -243,7 +252,9 @@ export class E2BSandboxService extends Service {
try {
await sandbox.kill()
} catch (_cleanupFailure) {
// The setup failure remains authoritative; E2B will still apply the configured lifetime.
// Preserve the setup failure as the public error while retaining the
// created handle for the service disposer to retry this rollback.
this.failedSetupSandbox = sandbox
}
}
throw error
+21 -1
View File
@@ -210,8 +210,28 @@ describe('E2BSandboxService', () => {
fixture.kill.mockRejectedValueOnce(new Error('cleanup failed'))
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await expect(ctx.e2b.getSandbox()).rejects.toThrow('chmod failed')
expect(fixture.kill).toHaveBeenCalledOnce()
await fiber.dispose()
expect(fixture.kill).toHaveBeenCalledTimes(2)
})
it.each([
['retries a still-failing rollback', new Error('retry failed')],
['accepts a setup sandbox that expired before retry', new SandboxNotFoundError('sandbox expired')],
])('%s during disposal', async (_label, retryError) => {
const fixture = fakeSandbox()
fixture.run.mockRejectedValueOnce(new Error('chmod failed'))
fixture.kill.mockRejectedValueOnce(new Error('cleanup failed')).mockRejectedValueOnce(retryError)
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await expect(ctx.e2b.getSandbox()).rejects.toThrow('chmod failed')
await fiber.dispose()
expect(fixture.kill).toHaveBeenCalledTimes(2)
})
it('does not kill a reconnected sandbox when setup fails', async () => {