fix(e2b): harden remote process lifecycle

This commit is contained in:
Tianyi Cui
2026-07-29 05:31:22 +08:00
parent 8877f5d582
commit fd78d9bc58
18 changed files with 586 additions and 153 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: 00264b8f0b03e4af8512025322fe3e457e7b6b9b
README.zh.md: 93fad661ded446e78e3addc0c8b2b8fdc39bd994
README.md: 56606a22c36e65bcc53f2b8cce27f0739da1b8e2
README.zh.md: 2e3213e431f15f1b22d2b8a429b27ab8a6a78671

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, then sets that directory 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 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; 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`。
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
资源释放会先阻止继续获取新句柄,再等待初始化完成,并且只应用一种已配置的处置方式。`SandboxNotFoundError` 表示因超时终止的沙箱已经完全停稳;其他处置失败都会使 teardown 拒绝。新建沙箱的初始目录设置失败时,服务会终止该沙箱;重新连接的沙箱设置失败时不会被终止,因为它不是由本服务创建的。提供方插件必须在该所有者之后加载,并在其之前 dispose(资源释放)。
`pause` 和 `leave` 会保留远程文件系统及适配器产物,供稍后的 `sandboxId` 连接使用,但后续 harness 进程只会获得新的 SDK 句柄。进程管理服务仍会履行其 seam 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。

View File

@@ -7,7 +7,7 @@
import { posix } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { Sandbox } from 'e2b'
import { Sandbox, SandboxNotFoundError } from 'e2b'
import type { Branded } from '@deepseek-ai/dsh-brand'
export {
@@ -159,16 +159,22 @@ export class E2BSandboxService extends Service {
// there is no remote resource for teardown to own.
return
}
switch (this.config.onDispose) {
case 'kill':
await sandbox.kill()
return
case 'pause': {
await sandbox.pause()
return
try {
switch (this.config.onDispose) {
case 'kill':
await sandbox.kill()
return
case 'pause': {
await sandbox.pause()
return
}
case 'leave':
return
}
case 'leave':
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
}
}, 'e2b sandbox teardown')
}

View File

@@ -43,6 +43,22 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
const node = await ctx.subprocess.resolveExecutable('node')
const relativeNodePath = posix.relative(ctx.subprocess.cwd, posix.dirname(node)) || '.'
await expect(ctx.subprocess.resolveExecutable('node', { PATH: relativeNodePath })).resolves.toBe(node)
const environmentProbe = ctx.subprocess.spawn({
argv: ['/bin/bash', '-c', [
'dsh_leak=0',
'for dsh_pid in "$PPID" $(ps -o pid= --ppid "$PPID"); do',
' [[ "$dsh_pid" == "$$" ]] && continue',
' if tr "\\0" "\\n" < "/proc/$dsh_pid/environ" 2>/dev/null | grep -Fqx "NPM_TOKEN=sentinel-secret"; then dsh_leak=1; fi',
'done',
'printf "DIRECT=<%s> LEAK=<%s>\\n" "${NPM_TOKEN-}" "$dsh_leak"',
].join('\n')],
cwd: '/home/user',
stdio: { stdin: 'ignore', stdout: { maxBytes: 1_024 }, stderr: { maxBytes: 1_024 } },
graceMs: 500,
env: {},
})
await expect(environmentProbe.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(environmentProbe.collected.stdout?.readFrom(0).text).toBe('DIRECT=<> LEAK=<0>\n')
const ownerId = SessionId('e2b-pty-env-owner')
const owner: Agent = {
id: ownerId,
@@ -106,6 +122,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
bashRead: 'written-by-fs\n',
fsRead: 'written-by-bash\n',
explicitEnvironment: true,
splitUtf8Output: '你好',
publicationRollback: true,
spill: {
liveBytes: 6,

View File

@@ -3,6 +3,7 @@ import { Context } from 'cordis'
import type { Sandbox as SandboxType } from 'e2b'
import E2BSandboxService, {
E2BSandboxId,
SandboxNotFoundError,
quoteE2BShellArg,
} from '@deepseek-ai/dsh-e2b'
import * as E2BInvariant from '../src/invariant.ts'
@@ -140,6 +141,32 @@ describe('E2BSandboxService', () => {
expect(fixture.pause).toHaveBeenCalledOnce()
})
it('treats a timeout-killed sandbox as already quiescent during disposal', async () => {
const fixture = fakeSandbox()
fixture.pause.mockRejectedValue(new SandboxNotFoundError('sandbox expired'))
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, {
apiKey: 'test-key',
onTimeout: 'kill',
onDispose: 'pause',
})
await ctx.e2b.getSandbox()
await expect(fiber.dispose()).resolves.toBeUndefined()
expect(fixture.pause).toHaveBeenCalledOnce()
})
it('does not classify other disposal failures as an already-gone sandbox', async () => {
const fixture = fakeSandbox()
fixture.kill.mockRejectedValue(new Error('disposition unknown'))
sdk.create.mockResolvedValue(fixture.sandbox)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
await ctx.e2b.getSandbox()
await expect(fiber.dispose()).resolves.toBeUndefined()
expect(fixture.kill).toHaveBeenCalledOnce()
})
it('reconnects without applying creation lifecycle options and can leave state running', async () => {
const fixture = fakeSandbox('existing')
sdk.connect.mockResolvedValue(fixture.sandbox)