fix(e2b): harden cancellation and teardown boundaries
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: ccafa9df480e3812d3d3b6d25b513e7b5d2afc9f
|
||||
README.zh.md: 60eefd46a0a9a017f4db57e4dc5313d533eeba04
|
||||
README.md: 6eb7d69dd6355de870db6bf05540f2629f464bc8
|
||||
README.zh.md: b2e2616cf779bce659fdef58840aa51f095a73cf
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user