fix(e2b): harden remote process lifecycle
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: 00264b8f0b03e4af8512025322fe3e457e7b6b9b
|
||||
README.zh.md: 93fad661ded446e78e3addc0c8b2b8fdc39bd994
|
||||
README.md: 56606a22c36e65bcc53f2b8cce27f0739da1b8e2
|
||||
README.zh.md: 2e3213e431f15f1b22d2b8a429b27ab8a6a78671
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 契约,在所有者释放前终止受管进程组;这两种处置方式都不会恢复先前的进程对象、输出游标或内存中的适配器锁。
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user