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

@@ -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')