fix(e2b): close remote lifecycle gaps
This commit is contained in:
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> ',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`。自定义模板必须保留兼容的命令。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user