refactor(e2b): drop speculative cleanup retry state

This commit is contained in:
Tianyi Cui
2026-07-30 06:02:10 +08:00
parent de77310c6f
commit b1b2d63785
15 changed files with 48 additions and 111 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: 6045b313a91edafc25361eba3f3c4dacf56b4eb0
README.zh.md: dc157d9b5167488530c7d63b59aed793ab6aa4c7
README.md: 7ade7c3d6522d8fa6d54d7011766238451b17c9a
README.zh.md: b683f33d2211106cf422e780b2b37904b0c640ad

View File

@@ -26,7 +26,7 @@ Shared lifecycle owner for one E2B sandbox. The filesystem and subprocess adapte
Construction starts one sandbox creation. 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`. Each adapter-internal E2B command shell receives a fresh randomized root-level `HOME`, so the SDK's fixed login shell does not resolve profile files from the mutable user home before the control command.
Disposal first prevents new handle acquisition, then awaits setup and deletes the sandbox. A `SandboxNotFoundError` means expiry or another owner already deleted it and is accepted as quiescence. Initial directory setup failure also deletes the newly created sandbox; if that rollback fails, disposal retries it before releasing ownership. Provider plugins must load after this owner and dispose before it.
Disposal first prevents new handle acquisition, then awaits setup and deletes the sandbox. A `SandboxNotFoundError` means expiry or another owner already deleted it and is accepted as quiescence. Initial directory setup failure makes one deletion attempt; the configured E2B timeout bounds a second failure. Provider plugins must load after this owner and dispose before it.
## Model Experience

View File

@@ -26,7 +26,7 @@
构造阶段会启动一次沙箱创建。服务在 `getSandbox()` 结算前创建 `cwd` 和私有的 `cwd/.dsh-e2b` 适配器状态目录,验证该预留路径是真实目录而非符号链接或其他文件类型,再把该目录的 mode 设为 `0700`。每个适配器内部的 E2B 命令 shell 都会获得一个位于根目录下、全新随机生成的 `HOME`,因此 SDK 固定使用的登录 shell 不会在控制命令之前解析可变用户主目录中的配置文件。
资源释放会先阻止继续获取新句柄,再等待初始化完成,然后删除沙箱。`SandboxNotFoundError` 表示沙箱已因超时或被另一个所有者删除,因此可视为完全停稳。初始目录设置失败时也会删除新建沙箱;如果该回滚失败,资源释放会在解除所有权前重试。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
资源释放会先阻止继续获取新句柄,再等待初始化完成,然后删除沙箱。`SandboxNotFoundError` 表示沙箱已因超时或被另一个所有者删除,因此可视为完全停稳。初始目录设置失败时会尝试删除一次;若该尝试也失败,则由已配置的 E2B 超时约束沙箱的存活时间。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
## 模型体验

View File

@@ -85,7 +85,6 @@ 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) {
@@ -108,21 +107,18 @@ export class E2BSandboxService extends Service {
ctx.effect(() => async () => {
this.disposed = true
let sandbox = this.failedSetupSandbox
if (sandbox === undefined) {
try {
sandbox = await this.ready
} catch {
sandbox = this.failedSetupSandbox
}
let sandbox: Sandbox
try {
sandbox = await this.ready
} catch (_sandboxSetupFailure) {
// open() either acquired no sandbox or already made the POC's one rollback attempt.
return
}
if (sandbox === undefined) return
try {
await sandbox.kill()
} catch (error: unknown) {
if (!(error instanceof SandboxNotFoundError)) throw error
}
this.failedSetupSandbox = undefined
}, 'e2b sandbox teardown')
}
@@ -174,10 +170,9 @@ export class E2BSandboxService extends Service {
} catch (error: unknown) {
try {
await sandbox.kill()
} catch (_cleanupFailure) {
// Preserve the setup failure as the public error while retaining the
// created handle for the service disposer to retry this rollback.
this.failedSetupSandbox = sandbox
} catch (_sandboxSetupRollbackFailure) {
// TODO(e2b-setup-rollback): Add retry state only if a real double failure
// outlives E2B's configured sandbox timeout.
}
throw error
}

View File

@@ -180,7 +180,7 @@ describe('E2BSandboxService', () => {
await fiber.dispose()
})
it('preserves the setup failure even when cleanup also fails', async () => {
it('preserves the setup failure after its one rollback attempt fails', async () => {
const fixture = fakeSandbox()
fixture.run.mockRejectedValueOnce(new Error('chmod failed'))
fixture.kill.mockRejectedValueOnce(new Error('cleanup failed'))
@@ -191,23 +191,7 @@ describe('E2BSandboxService', () => {
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)
expect(fixture.kill).toHaveBeenCalledOnce()
})
it.each([