fix(e2b): close final lifecycle gaps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user