fix(e2b): close remote lifecycle gaps

This commit is contained in:
Tianyi Cui
2026-07-28 16:26:20 +08:00
parent e64d40837c
commit 3dea36f1ce
22 changed files with 568 additions and 181 deletions

View File

@@ -14,7 +14,7 @@ import type {
} from '@deepseek-ai/dsh-code-runtime'
import {
E2BFrameDecoder,
encodeE2BFrame,
encodeBoundedE2BFrame,
quoteE2BShellArg,
resolveE2BExecutable,
} from '@deepseek-ai/dsh-e2b'
@@ -25,6 +25,7 @@ import {
} from '@deepseek-ai/dsh-code-runtime-worker'
import type { WorkerJsonWire } from '@deepseek-ai/dsh-code-runtime-worker'
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { CODE_RUNNER_SOURCE } from './runner-source.ts'
@@ -46,6 +47,7 @@ export interface Config {
}
type ResolvedConfig = Required<Config>
type PreparedRuntime = { node: string; runner: string }
interface LiveRun {
settle(failure: CodeRunFailure): void
@@ -130,7 +132,7 @@ export class E2BCodeRuntime extends CodeRuntime {
readonly isolation = 'container'
private readonly config: ResolvedConfig
private readonly ready: Promise<{ node: string; runner: string }>
private readonly ready: Promise<PreparedRuntime>
private readonly live = new Set<LiveRun>()
private readonly subprocess: E2BSubprocessService
private disposed = false
@@ -176,25 +178,61 @@ export class E2BCodeRuntime extends CodeRuntime {
} catch (error: unknown) {
return this.failure({ kind: 'exception', message: messageOf(error) })
}
let runtime: Awaited<typeof this.ready>
let runtime: PreparedRuntime | undefined
try {
runtime = await this.ready
runtime = await this.awaitPreparation(request.signal)
} catch (error: unknown) {
// Disposal can race the awaited setup despite the synchronous precheck.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.disposed) return this.failure({ kind: 'abort', message: 'runtime disposed' })
return this.failure({ kind: 'worker-exit', message: `E2B runtime setup failed: ${messageOf(error)}` })
}
if (runtime === undefined) {
return this.failure({ kind: 'abort', message: String(request.signal?.reason) })
}
// Disposal can race the awaited remote setup after the pre-await check.
/* v8 ignore start -- requires disposal between promise resolution and its awaiting continuation. */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.disposed) return this.failure({ kind: 'abort', message: 'runtime disposed' })
/* v8 ignore stop */
return await this.execute(request, code, bindings, runtime)
}
/* jscpd:ignore-end */
private async prepare(): Promise<{ node: string; runner: string }> {
private awaitPreparation(signal: AbortSignal | undefined): Promise<PreparedRuntime | undefined> {
if (signal === undefined) return this.ready
return new Promise<PreparedRuntime | undefined>((resolve, reject) => {
const onAbort = (): void => { cleanup(); resolve(undefined) }
const cleanup = (): void => { signal.removeEventListener('abort', onAbort) }
signal.addEventListener('abort', onAbort, { once: true })
if (signal.aborted) {
onAbort()
return
}
void this.ready.then(
(runtime) => { cleanup(); resolve(runtime) },
(error: unknown) => {
cleanup()
reject(error instanceof Error ? error : new Error(String(error)))
},
)
})
}
private assertPreparationActive(): void {
if (this.disposed) throw new Error('code-runtime-e2b: runtime disposed during setup')
}
private async prepare(): Promise<PreparedRuntime> {
const sandbox = await this.ctx.e2b.getSandbox()
this.assertPreparationActive()
const runner = posix.join(this.ctx.e2b.runtimeRoot, 'code-runtime-runner.mjs')
await sandbox.files.write([{ path: runner, data: CODE_RUNNER_SOURCE }])
this.assertPreparationActive()
await sandbox.commands.run(`chmod 600 -- ${quoteE2BShellArg(runner)}`)
this.assertPreparationActive()
const node = await resolveE2BExecutable(sandbox, 'node')
this.assertPreparationActive()
return { node, runner }
}
@@ -237,16 +275,25 @@ export class E2BCodeRuntime extends CodeRuntime {
request: CodeRunRequest,
code: string,
bindings: Map<string, CodeBindingNamespace>,
runtime: { node: string; runner: string },
runtime: PreparedRuntime,
): Promise<CodeRunResult> {
const handle = this.subprocess.spawn({
argv: [runtime.node, runtime.runner],
cwd: this.ctx.e2b.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: this.config.maxOutputBytes } },
graceMs: this.config.killGraceMs,
...request.signal === undefined ? {} : { signal: request.signal },
env: {},
})
let handle: SubprocessHandle
try {
handle = this.subprocess.spawn({
argv: [runtime.node, runtime.runner],
cwd: this.ctx.e2b.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: this.config.maxOutputBytes } },
graceMs: this.config.killGraceMs,
...request.signal === undefined ? {} : { signal: request.signal },
env: {},
})
} catch (error: unknown) {
if (this.disposed) return this.failure({ kind: 'abort', message: 'runtime disposed' })
if (request.signal?.aborted === true) {
return this.failure({ kind: 'abort', message: String(request.signal.reason) })
}
return this.failure({ kind: 'worker-exit', message: `E2B runtime spawn failed: ${messageOf(error)}` })
}
if (handle.stdin === undefined || handle.stdout === undefined) {
handle.terminate()
await Promise.allSettled([handle.done])
@@ -279,7 +326,6 @@ export class E2BCodeRuntime extends CodeRuntime {
settled = true
clearTimeout(wallTimer.current)
request.signal?.removeEventListener('abort', onAbort)
this.live.delete(live)
void new Promise<void>((resume) => { setImmediate(resume) }).then(async () => {
handle.terminate()
await handle.done.catch(() => {})
@@ -298,6 +344,7 @@ export class E2BCodeRuntime extends CodeRuntime {
result = output.failure(logs, { kind: 'worker-exit', message: `E2B runtime cleanup failed: ${messageOf(cleanupError)}` })
}
const final = typeof result === 'function' ? result() : result
this.live.delete(live)
finishResolve()
resolve(final)
})
@@ -305,7 +352,14 @@ export class E2BCodeRuntime extends CodeRuntime {
const sendReply = (message: unknown): void => {
if (settled) return
stdin.write(encodeE2BFrame(message), (error?: Error | null) => {
let frame: string
try {
frame = encodeBoundedE2BFrame(message, this.config.maxFrameBytes)
} catch (error: unknown) {
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime bridge failed: ${messageOf(error)}` }))
return
}
stdin.write(frame, (error?: Error | null) => {
if (error !== undefined && error !== null) {
finish(() => output.failure(logs, { kind: 'worker-exit', message: `E2B runtime bridge write failed: ${error.message}` }))
}
@@ -433,7 +487,10 @@ export class E2BCodeRuntime extends CodeRuntime {
this.disposed = true
const runs = [...this.live]
for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
await Promise.all(runs.map(run => run.finished))
await Promise.all([
this.ready.then(() => {}, () => {}),
...runs.map(run => run.finished),
])
}
/* jscpd:ignore-end */
}

View File

@@ -34,13 +34,22 @@ class FakeHandle implements SubprocessHandle {
waitCalls = 0
private readonly decoder = new E2BFrameDecoder(10_000_000)
private readonly waitError: Error | undefined
private readonly waitResult: Promise<boolean> | undefined
private settled = false
constructor(
private readonly onMessage: (message: unknown, handle: FakeHandle) => void = () => {},
options: { stdin?: boolean; stdout?: boolean; stderr?: string; writeError?: Error; waitError?: Error } = {},
options: {
stdin?: boolean
stdout?: boolean
stderr?: string
writeError?: Error
waitError?: Error
waitResult?: Promise<boolean>
} = {},
) {
this.waitError = options.waitError
this.waitResult = options.waitResult
this.stdin = options.stdin === false
? undefined
: options.writeError === undefined
@@ -89,6 +98,7 @@ class FakeHandle implements SubprocessHandle {
async waitForExit(): Promise<boolean> {
this.waitCalls += 1
if (this.waitError !== undefined) throw this.waitError
if (this.waitResult !== undefined) return await this.waitResult
return true
}
}
@@ -286,6 +296,30 @@ describe('E2BCodeRuntime', () => {
await fixture.fiber.dispose()
})
it('enforces the outbound frame bound on boot and binding replies', async () => {
const oversizedBoot = new FakeHandle()
const oversizedReply = new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') {
current.emit({ type: 'call', id: 1, global: 'bridge', name: 'large', args: encodeWorkerJson(null) })
}
})
const fixture = await setup([oversizedBoot, oversizedReply], { maxOutputBytes: 128, maxFrameBytes: 512 })
const bootResult = await fixture.runtime.run(request(`return ${JSON.stringify('x'.repeat(1_000))}`))
expect(bootResult.error).toMatchObject({ kind: 'worker-exit' })
expect(bootResult.error?.message).toContain('frame exceeded its byte limit')
expect(oversizedBoot.writes).toHaveLength(0)
const replyResult = await fixture.runtime.run({
program: 'return await bridge.large(null)',
bindings: [{ global: 'bridge', functions: { large: async () => 'x'.repeat(1_000) } }],
})
expect(replyResult.error).toMatchObject({ kind: 'worker-exit' })
expect(replyResult.error?.message).toContain('frame exceeded its byte limit')
expect(oversizedReply.writes).toHaveLength(1)
await fixture.fiber.dispose()
})
it('contains stdin errors, process exits, spawn failures, and missing pipes', async () => {
const writeError = new FakeHandle(() => {}, { writeError: new Error('write callback broke') })
const stdinError = new FakeHandle((message, current) => {
@@ -445,12 +479,116 @@ describe('E2BCodeRuntime', () => {
const gate = Promise.withResolvers<Sandbox>()
const fixture = await setup([], {}, {}, () => gate.promise)
const running = fixture.runtime.run(request())
await (fixture.runtime as unknown as { teardown(): Promise<void> }).teardown()
const disposing = fixture.fiber.dispose()
let disposed = false
void disposing.then(() => { disposed = true })
await new Promise(resolve => setImmediate(resolve))
const disposedBeforeSetup = disposed
gate.resolve(fixture.sandbox)
await disposing
expect(disposedBeforeSetup).toBe(false)
expect((await running).error).toEqual({ kind: 'abort', message: 'runtime disposed' })
expect(fixture.write).not.toHaveBeenCalled()
})
it('observes abort while runtime preparation is pending', async () => {
const gate = Promise.withResolvers<Sandbox>()
const fixture = await setup([], {}, {}, () => gate.promise)
const controller = new AbortController()
const running = fixture.runtime.run({ ...request(), signal: controller.signal })
controller.abort('stop during setup')
const early = await Promise.race([
running.then(result => ({ kind: 'result' as const, result })),
new Promise<{ kind: 'pending' }>((resolve) => { setImmediate(() => { resolve({ kind: 'pending' }) }) }),
])
expect(fixture.spawn).not.toHaveBeenCalled()
gate.resolve(fixture.sandbox)
expect(early).toMatchObject({ kind: 'result', result: { error: { kind: 'abort', message: 'stop during setup' } } })
await running
await fixture.fiber.dispose()
})
it('classifies an abort that races synchronous subprocess spawn', async () => {
const fixture = await setup()
const controller = new AbortController()
fixture.spawn.mockImplementationOnce(() => {
controller.abort('stop at spawn')
throw new Error('aborted before spawn')
})
expect((await fixture.runtime.run({ ...request(), signal: controller.signal })).error)
.toEqual({ kind: 'abort', message: 'stop at spawn' })
fixture.spawn.mockImplementationOnce(() => { throw new Error('synchronous spawn failure') })
expect((await fixture.runtime.run(request())).error).toEqual({
kind: 'worker-exit',
message: 'E2B runtime spawn failed: synchronous spawn failure',
})
await fixture.fiber.dispose()
const disposingFixture = await setup()
disposingFixture.spawn.mockImplementationOnce(() => {
void (disposingFixture.runtime as unknown as { teardown(): Promise<void> }).teardown()
throw new Error('spawn raced disposal')
})
expect((await disposingFixture.runtime.run(request())).error)
.toEqual({ kind: 'abort', message: 'runtime disposed' })
await disposingFixture.fiber.dispose()
})
it('closes both abort races around runtime readiness and live-run publication', async () => {
let preparationAborted = false
const preparationSignal = {
get aborted() { return preparationAborted },
reason: 'preparation race',
addEventListener() { preparationAborted = true },
removeEventListener() {},
} as unknown as AbortSignal
const liveHandle = new FakeHandle()
const fixture = await setup([liveHandle])
expect((await fixture.runtime.run({ ...request(), signal: preparationSignal })).error)
.toEqual({ kind: 'abort', message: 'preparation race' })
expect(fixture.spawn).not.toHaveBeenCalled()
let liveAborted = false
let registrations = 0
const liveSignal = {
get aborted() { return liveAborted },
reason: 'live publication race',
addEventListener() {
registrations += 1
if (registrations === 2) liveAborted = true
},
removeEventListener() {},
} as unknown as AbortSignal
expect((await fixture.runtime.run({ ...request(), signal: liveSignal })).error)
.toEqual({ kind: 'abort', message: 'live publication race' })
await fixture.fiber.dispose()
})
it('retains a live run until remote cleanup reaches quiescence', async () => {
const cleanup = Promise.withResolvers<boolean>()
const handle = new FakeHandle((message, current) => {
if ((message as { type?: string }).type === 'boot') current.emit({ type: 'done' })
}, { waitResult: cleanup.promise })
const fixture = await setup([handle])
const running = fixture.runtime.run(request())
await vi.waitFor(() => { expect(handle.waitCalls).toBe(1) })
const disposing = fixture.fiber.dispose()
let disposed = false
void disposing.then(() => { disposed = true })
await new Promise(resolve => setImmediate(resolve))
const disposedBeforeCleanup = disposed
cleanup.resolve(true)
await expect(running).resolves.toEqual({ logs: [] })
await expect(disposing).resolves.toBeUndefined()
expect(disposedBeforeCleanup).toBe(false)
})
it('registers the package-owned invariant companion', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })

View File

@@ -10,9 +10,30 @@ const BASE64_LINE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3
* @returns Base64-encoded UTF-8 JSON followed by one newline.
*/
export function encodeE2BFrame(value: unknown): string {
return encodeFrame(value)
}
/**
* Encode one JSON-compatible value while enforcing the decoded frame bound.
* @param value - Value accepted by `JSON.stringify`.
* @param maxFrameBytes - Maximum UTF-8 JSON bytes in the encoded frame.
* @returns Base64-encoded UTF-8 JSON followed by one newline.
*/
export function encodeBoundedE2BFrame(value: unknown, maxFrameBytes: number): string {
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
throw new Error('E2B frame maxFrameBytes must be a positive safe integer')
}
return encodeFrame(value, maxFrameBytes)
}
function encodeFrame(value: unknown, maxFrameBytes?: number): string {
const json: unknown = JSON.stringify(value)
if (typeof json !== 'string') throw new Error('E2B frame value is not JSON-serializable')
return `${Buffer.from(json).toString('base64')}\n`
const bytes = Buffer.from(json)
if (maxFrameBytes !== undefined && bytes.length > maxFrameBytes) {
throw new Error('E2B frame exceeded its byte limit')
}
return `${bytes.toString('base64')}\n`
}
/** Incremental decoder for newline-delimited base64 JSON frames. */

View File

@@ -10,7 +10,7 @@ import z from 'schemastery'
import { Sandbox } from 'e2b'
import type { Branded } from '@deepseek-ai/dsh-brand'
export { E2BFrameDecoder, encodeE2BFrame } from './frame.ts'
export { E2BFrameDecoder, encodeBoundedE2BFrame, encodeE2BFrame } from './frame.ts'
export {
CommandExitError,
@@ -202,7 +202,11 @@ export class E2BSandboxService extends Service {
*/
async getSandbox(): Promise<Sandbox> {
if (this.disposed) throw new Error('E2B sandbox service is disposing')
return await this.ready
const sandbox = await this.ready
// Disposal can race the awaited sandbox readiness despite the synchronous precheck.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (this.disposed) throw new Error('E2B sandbox service is disposing')
return sandbox
}
private validate(): void {

View File

@@ -24,7 +24,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
},
processTimeoutMs: 120_000,
inspect: async (cwd) => {
for (const name of ['from-fs.txt', 'from-bash.txt', 'multibyte.ts', 'fixture-lsp.mjs']) {
for (const name of ['from-fs.txt', 'from-bash.txt', 'multibyte # file.ts', 'fixture-lsp.mjs']) {
await expect(access(join(cwd, name))).rejects.toMatchObject({ code: 'ENOENT' })
}
},
@@ -35,6 +35,12 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
expect(output).toMatchObject({
bashRead: 'written-by-fs\n',
fsRead: 'written-by-bash\n',
explicitEnvironment: true,
spill: {
liveBytes: 6,
outcome: { exitCode: null, signal: 'SIGTERM' },
read: { text: '6789', nextOffset: 10, lossy: true },
},
hover: {
kind: 'hover',
hover: { contents: '**remote hover** 你好 café' },
@@ -51,6 +57,8 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
hostileOutput: { error: { kind: 'output-limit' } },
timedOut: { error: { kind: 'timeout' } },
aborted: { error: { kind: 'abort', message: 'live abort' } },
oversizedBoot: { error: { kind: 'worker-exit' } },
oversizedReply: { error: { kind: 'worker-exit' } },
lingeringCodeRunners: 0,
})
expect((output.terminal as { motd: string }).motd.length).toBeGreaterThan(0)

View File

@@ -4,6 +4,7 @@ import type { Sandbox as SandboxType } from 'e2b'
import E2BSandboxService, {
E2BFrameDecoder,
E2BSandboxId,
encodeBoundedE2BFrame,
encodeE2BFrame,
quoteE2BShellArg,
resolveE2BExecutable,
@@ -91,6 +92,22 @@ describe('E2BSandboxService', () => {
await expect(service.getSandbox()).rejects.toThrow(/disposing/)
})
it('rejects handle acquisition when disposal starts during setup', async () => {
const fixture = fakeSandbox()
const opening = Promise.withResolvers<SandboxType>()
sdk.create.mockReturnValue(opening.promise)
const ctx = new Context()
const fiber = await ctx.plugin(E2BSandboxService, { apiKey: 'test-key' })
const acquisition = ctx.e2b.getSandbox()
const disposing = fiber.dispose()
opening.resolve(fixture.sandbox)
await expect(acquisition).rejects.toThrow(/disposing/)
await expect(disposing).resolves.toBeUndefined()
expect(fixture.kill).toHaveBeenCalledOnce()
})
it('creates from a template, honors timeout and pause policies, and reads the key from the environment', async () => {
vi.stubEnv('E2B_API_KEY', 'environment-key')
const fixture = fakeSandbox('template-sandbox')
@@ -240,6 +257,14 @@ describe('E2B helpers and invariant companion', () => {
expect(() => encodeE2BFrame(undefined)).toThrow('not JSON-serializable')
})
it('bounds outbound frames by decoded UTF-8 bytes', () => {
const exact = encodeBoundedE2BFrame({ text: '你' }, 14)
expect(new E2BFrameDecoder(14).push(exact)).toEqual([{ text: '你' }])
expect(() => encodeBoundedE2BFrame({ text: '你' }, 13)).toThrow('byte limit')
expect(() => encodeBoundedE2BFrame(null, 0)).toThrow('positive safe integer')
expect(() => encodeBoundedE2BFrame(null, 1.5)).toThrow('positive safe integer')
})
it('rejects malformed, oversized, and truncated frame streams', () => {
expect(() => new E2BFrameDecoder(0)).toThrow('positive safe integer')
expect(() => new E2BFrameDecoder(1.5)).toThrow('positive safe integer')

View File

@@ -199,6 +199,7 @@ export class E2BFileSystem extends FileSystem {
const reader = stream.getReader()
const decoder = new TextDecoder('utf-8', { fatal: true })
let sampledBytes = 0
let completed = false
try {
while (true) {
assertNotAborted(signal, 'read')
@@ -222,9 +223,17 @@ export class E2BFileSystem extends FileSystem {
} catch (error: unknown) {
throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
}
completed = true
} catch (error: unknown) {
throw mapError(error, 'read', displayPath, signal)
} finally {
if (!completed) {
try {
await reader.cancel()
} catch (_streamCancellationFailure) {
// The primary read outcome owns the result; cancellation is best-effort after early stop.
}
}
reader.releaseLock()
}
},

View File

@@ -12,7 +12,7 @@ import { FsVersion } from '@deepseek-ai/dsh-fs'
import E2BFileSystem from '@deepseek-ai/dsh-fs-e2b'
import * as E2BFsInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
interface RemoteNode {
type: FileType
@@ -38,6 +38,8 @@ class FakeRemote {
readonly removals: string[] = []
readonly commands: string[] = []
streamChunks: Uint8Array[] | undefined
streamKeepOpen = false
readonly streamCancel = vi.fn()
nextCommandError: unknown
nextInfoError: unknown
nextListError: unknown
@@ -148,10 +150,11 @@ class FakeRemote {
if (options.format === 'bytes') return data.slice()
const chunks = this.streamChunks ?? [data.slice()]
return new ReadableStream<Uint8Array>({
start(controller) {
start: (controller) => {
for (const chunk of chunks) controller.enqueue(chunk)
controller.close()
if (!this.streamKeepOpen) controller.close()
},
cancel: () => { this.streamCancel() },
})
},
list: async (path: string, options?: { depth?: number; signal?: AbortSignal }): Promise<EntryInfo[]> => {
@@ -303,6 +306,22 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
expect(initiallyBuffered).toBe('€')
})
it('cancels a remote stream when its consumer stops early', async () => {
const remote = new FakeRemote()
remote.file('/workspace/text.txt', 'ab')
remote.streamChunks = [bytes('a'), bytes('b')]
remote.streamKeepOpen = true
const { fs } = await setup(remote)
const stream = await fs.streamText(await fs.resolve('text.txt'))
for await (const chunk of stream) {
expect(chunk).toBe('a')
break
}
expect(remote.streamCancel).toHaveBeenCalledOnce()
})
it('matches local binary sampling while edits still reject any NUL byte', async () => {
const remote = new FakeRemote()
remote.file('/workspace/late-nul.txt', `${'a'.repeat(8192)}\0tail`)

View File

@@ -200,6 +200,16 @@ export async function readE2BSource(
return { canonicalPath, text }
}
/**
* Encode one absolute remote Linux path as a host-independent file URI.
* @param path - Canonical POSIX path inside E2B.
* @returns The equivalent percent-encoded file URI.
*/
export function e2bFileUri(path: string): string {
if (!posix.isAbsolute(path)) throw new Error(`lsp-e2b: expected an absolute remote path, received ${JSON.stringify(path)}`)
return `file://${path.split('/').map(segment => encodeURIComponent(segment)).join('/')}`
}
/* jscpd:ignore-start -- Provider identity mirrors the seam while remote source and process ownership stay local. */
/** One pooled remote provider with an isolated server per canonical workspace. */
export class E2BLspProvider implements LspProvider {
@@ -298,6 +308,7 @@ export class E2BLspProvider implements LspProvider {
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
killGraceMs: this.config.killGraceMs,
clientProcessId: null,
pathToFileUri: e2bFileUri,
}, (spec: SubprocessSpawnSpec) => {
const originalArgv = Buffer.from(JSON.stringify(spec.argv)).toString('base64')
const inner = this.subprocess.spawn({

View File

@@ -69,6 +69,7 @@ import {
E2BLspProvider,
apply,
canonicalizeE2BWorkspace,
e2bFileUri,
readE2BSource,
} from '@deepseek-ai/dsh-lsp-e2b'
import type { LspE2BServerConfig } from '@deepseek-ai/dsh-lsp-e2b'
@@ -234,6 +235,10 @@ describe('E2BLspProvider pooling and lifecycle', () => {
await expect(current.query(query())).resolves.toEqual({ kind: 'hover', hover: { contents: 'ok' } })
expect(mockedLsp.FakeLspInstance.instances).toHaveLength(1)
expect(mockedLsp.FakeLspInstance.instances[0]?.spec).toMatchObject({ clientProcessId: null, cwd: '/workspace' })
expect(e2bFileUri('/workspace/a b#c.ts')).toBe('file:///workspace/a%20b%23c.ts')
expect(() => e2bFileUri('relative.ts')).toThrow('absolute remote path')
const pathToFileUri = mockedLsp.FakeLspInstance.instances[0]?.spec.pathToFileUri as (path: string) => string
expect(pathToFileUri('/workspace/a b#c.ts')).toBe('file:///workspace/a%20b%23c.ts')
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: ['/usr/bin/node', '/workspace/.dsh-e2b/lsp-proxy.mjs', expect.any(String)],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 128 } },

View File

@@ -71,9 +71,10 @@ export class E2BPtyBackend implements PtyBackend {
}
const session = new E2BPtySession(sandbox, handle, this.config)
created.session = session
for (const data of pending) session.onData(data)
try {
await session.initialize(spec.signal)
const initializing = session.initialize(spec.signal)
for (const data of pending) session.onData(data)
await initializing
return session
} catch (error: unknown) {
try {

View File

@@ -52,7 +52,7 @@ describe('E2BPtyBackend and plugin', () => {
const backend = new E2BPtyBackend(ctx, config(), async (_sandbox, received) => {
options = received
void received.onData(Buffer.from('banner\n'))
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
return created
})
const pending = backend.spawn({
@@ -62,7 +62,7 @@ describe('E2BPtyBackend and plugin', () => {
await vi.advanceTimersByTimeAsync(2)
const session = await pending
expect(session.motd).toBe('dsh> ')
expect(session.motd).toBe('banner\ndsh> ')
expect(options).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace/project', timeoutMs: 0 })
expect(options?.envs).toMatchObject({
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ',

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/subprocess-e2b/README.md
README.md: ce61de1100791be6e5c4db74c73ff43566281ed9
README.zh.md: a4c619f0c002cc1d36310c5a9b4a3f7ae655ad1f
README.md: 3b3bfa88e7e6483decfcdec11355942ae4ff7403
README.zh.md: 3ff9a51c60636dea5789f9dd11b04aa902b91d0c

View File

@@ -11,7 +11,7 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
- **Environment boundary** — the wrapper starts from the sandbox command environment, removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names, then restores every `spec.env` entry as an explicit caller opt-in. Host ambient variables never enter the sandbox implicitly.
- **Stdio projection** — pipe mode forwards E2B callbacks into host Node streams; inherit mode forwards them to the harness process streams; collect mode retains a bounded host tail with offset reads. Optional complete spill files are written remotely and advertised only while within their cap. Batch and streaming stdin use the SDK handle.
The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `tr`, `env`, `chmod`, `tee`, and `kill`. A custom template must retain compatible commands.
The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `tr`, `env`, `chmod`, `tee`, `head`, and `kill`. A custom template must retain compatible commands.
## Model Experience

View File

@@ -11,7 +11,7 @@
- **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*``*SECRET*``*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。
- **stdio 投影**pipe 模式把 E2B 回调转发到宿主 Node 流inherit 模式把回调转发到 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。可选的完整 spill 文件写在远程,并且只有未超过其上限时才会对外公布。批量 stdin 和流式 stdin 都使用 SDK 句柄。
基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash``setsid``ps``tr``env``chmod``tee``kill`。自定义模板必须保留兼容的命令。
基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash``setsid``ps``tr``env``chmod``tee``head``kill`。自定义模板必须保留兼容的命令。
## 模型体验

View File

@@ -55,37 +55,38 @@ class DeferredStdin extends Writable {
interface RemotePaths {
pid: string
status: string
environment: string
stdout: string
stderr: string
}
function explicitEnvironmentNames(env: Readonly<Record<string, string>> | undefined): string {
return Object.keys(env ?? {})
.map(quoteE2BShellArg)
.join(' ')
function explicitEnvironment(env: Readonly<Record<string, string>> | undefined): string {
return Object.entries(env ?? {})
.map(([name, value]) => `${name}=${value}\0`)
.join('')
}
function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
const stdoutRedirect = hasSpill(spec.stdio.stdout)
? `> >(tee -a -- ${quoteE2BShellArg(paths.stdout)})`
? `> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}))`
: ''
const stderrRedirect = hasSpill(spec.stdio.stderr)
? `2> >(tee -a -- ${quoteE2BShellArg(paths.stderr)} >&2)`
? `2> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) >&2)`
: ''
const environmentNames = explicitEnvironmentNames(spec.env)
const inner = [
'set +e',
'umask 077',
'dsh_e2b_pgid="$(ps -o pgid= -p "$$" | tr -d " ")"',
`printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`,
`mapfile -d '' -t dsh_e2b_explicit < ${quoteE2BShellArg(paths.environment)}`,
`: > ${quoteE2BShellArg(paths.environment)}`,
'dsh_e2b_env=()',
`dsh_e2b_explicit=(${environmentNames})`,
'while IFS= read -r dsh_e2b_name; do',
"while IFS= read -r -d '' dsh_e2b_entry; do",
' dsh_e2b_name="${dsh_e2b_entry%%=*}"',
' case "${dsh_e2b_name^^}" in DSH_*|*KEY*|*SECRET*|*TOKEN*) continue ;; esac',
' dsh_e2b_env+=("$dsh_e2b_name=${!dsh_e2b_name}")',
'done < <(compgen -e)',
'for dsh_e2b_name in "${dsh_e2b_explicit[@]}"; do dsh_e2b_env+=("$dsh_e2b_name=${!dsh_e2b_name}"); done',
`env -i "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
' dsh_e2b_env+=("$dsh_e2b_entry")',
'done < <(env -0)',
`env -i "\${dsh_e2b_env[@]}" "\${dsh_e2b_explicit[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
'dsh_e2b_status=$?',
'wait',
`printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`,
@@ -131,7 +132,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
private remotePid = -1
private settled = false
private terminationRequested = false
private terminationSignal: NodeJS.Signals | null = null
private termination: Promise<void> | undefined
@@ -150,6 +150,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
this.paths = {
pid: posix.join(stateDir, 'pid'),
status: posix.join(stateDir, 'exit-code'),
environment: posix.join(stateDir, 'environment'),
stdout: posix.join(stateDir, 'stdout.log'),
stderr: posix.join(stateDir, 'stderr.log'),
}
@@ -182,7 +183,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
/** @inheritdoc */
terminate(): void {
if (this.terminationRequested || this.settled) return
if (this.terminationRequested) return
this.terminationRequested = true
this.termination = this.terminateRemote()
void this.termination.catch(() => {})
@@ -240,7 +241,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
cwd: this.spec.cwd,
stdin: this.spec.stdio.stdin !== 'ignore',
timeoutMs: 0,
...(this.spec.env !== undefined ? { envs: this.spec.env } : {}),
onStdout: async (data) => { await this.dispatchOutput('stdout', data) },
onStderr: async (data) => { await this.dispatchOutput('stderr', data) },
},
@@ -250,7 +250,12 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
const completion = handle.wait()
void completion.catch(() => {})
this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
try {
this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
} catch (error: unknown) {
await Promise.allSettled([handle.kill()])
throw error
}
this.readyState.resolve(handle)
await this.writeBatchStdin(handle)
const outcome = await this.waitForCommand(completion)
@@ -260,7 +265,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
this.readyState.reject(error)
throw error
} finally {
this.settled = true
this.spec.signal?.removeEventListener('abort', this.onAbort)
this.stdout?.end()
this.stderr?.end()
@@ -269,17 +273,16 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private async prepareState(sandbox: Sandbox): Promise<void> {
await sandbox.files.makeDir(this.stateDir)
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`)
const files = [
{ path: this.paths.pid, data: '' },
{ path: this.paths.status, data: '' },
{ path: this.paths.environment, data: explicitEnvironment(this.spec.env) },
...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []),
...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
]
await sandbox.files.write(files)
await sandbox.commands.run([
`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`,
`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`,
].join('\n'))
await sandbox.commands.run(`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`)
}
private async writeBatchStdin(handle: CommandHandle): Promise<void> {

View File

@@ -80,6 +80,7 @@ class FakeSandbox {
readonly handle = new FakeCommandHandle()
readonly commandsSeen: string[] = []
readonly writtenFiles: string[][] = []
readonly writtenFileData = new Map<string, string>()
readonly removed: string[] = []
readonly directories: string[] = []
startOptions: StartOptions | undefined
@@ -129,6 +130,7 @@ class FakeSandbox {
},
write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
this.writtenFiles.push(files.map(file => file.path))
for (const file of files) this.writtenFileData.set(file.path, file.data)
return files.map(() => ({}))
},
read: async (): Promise<string> => this.processGroupReads.shift() ?? this.processGroupId,
@@ -251,7 +253,7 @@ describe('E2BSubprocessHandle', () => {
const handle = new E2BSubprocessHandle(runtime(fake), spec({
argv: ['tool', 'argument with spaces'],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } },
env: { PATH: '/bin', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' },
env: { PATH: '/bin', 'FOO-BAR': 'hyphen-value', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' },
}), '/workspace/.dsh-e2b/processes/one')
expect(handle.pid).toBe(-1)
handle.stdin!.write('hello')
@@ -261,17 +263,26 @@ describe('E2BSubprocessHandle', () => {
expect(handle.pid).toBe(4343)
expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
expect(fake.handle.closes).toBe(1)
expect(fake.startOptions?.envs).toEqual({ PATH: '/bin', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' })
expect(fake.startOptions?.envs).toBeUndefined()
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('exec setsid --wait -- bash -c')
expect(command).toContain('DEEPSEEK_API_KEY')
expect(command).toContain('DSH_MODE')
expect(command).not.toContain('DEEPSEEK_API_KEY')
expect(command).not.toContain('DSH_MODE')
expect(command).not.toContain('FOO-BAR')
expect(command).not.toContain('explicit-secret')
expect(command).not.toContain('hyphen-value')
expect(command).not.toContain('${!dsh_e2b_name}')
expect(command).toContain('env -0')
expect(command).toContain('mapfile -d')
expect(fake.writtenFiles[0]).toEqual([
'/workspace/.dsh-e2b/processes/one/pid',
'/workspace/.dsh-e2b/processes/one/exit-code',
'/workspace/.dsh-e2b/processes/one/environment',
'/workspace/.dsh-e2b/processes/one/stderr.log',
])
expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe(
'PATH=/bin\0FOO-BAR=hyphen-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
)
let piped = ''
handle.stdout!.on('data', (chunk) => { piped += String(chunk) })
@@ -350,6 +361,11 @@ describe('E2BSubprocessHandle', () => {
await handle.done
expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
expect(fake.removed).toContain('/runtime/oversize/stdout.log')
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('head -c 3')
expect(command).toContain('/runtime/oversize/stdout.log')
expect(command).toContain('tee --output-error=warn-nopipe')
expect(command).not.toContain('tee -a')
})
it('contains remote spill-removal failures and routes empty inherited output', async () => {
@@ -415,6 +431,22 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
})
it('can terminate a surviving process group after the command leader settles', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/surviving-group')
await flush()
fake.handle.succeed(0)
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(fake.alive).toBe(true)
handle.terminate()
await flush()
const signaled = fake.commandsSeen.includes('kill -TERM -- -4242')
if (!signaled) fake.finish()
await expect(handle.waitForExit()).resolves.toBe(true)
expect(signaled).toBe(true)
})
it('bounds waitForExit while startup or a live group is pending', async () => {
const fake = new FakeSandbox()
fake.deferStart()
@@ -564,8 +596,14 @@ describe('E2BSubprocessHandle', () => {
it('rejects invalid or absent process-group publication', async () => {
const invalidGroup = new FakeSandbox()
invalidGroup.processGroupId = 'not-a-pid\n'
vi.spyOn(invalidGroup.handle, 'kill').mockImplementation(async () => {
invalidGroup.handle.kills += 1
invalidGroup.finish()
return true
})
const invalid = new E2BSubprocessHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group')
await expect(invalid.done).rejects.toThrow(/invalid process-group id/)
expect(invalidGroup.handle.kills).toBe(1)
const absentGroup = new FakeSandbox()
absentGroup.processGroupId = ''
@@ -573,6 +611,7 @@ describe('E2BSubprocessHandle', () => {
await flush()
absentGroup.finish()
await expect(absent.done).rejects.toThrow(/exited before publishing/)
expect(absentGroup.handle.kills).toBe(1)
})
it('waits for delayed process-group publication', async () => {