fix(e2b): close remote lifecycle gaps
This commit is contained in:
@@ -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. */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user