Skip to content

Commit 922b5fa

Browse files
icecrasher321claude
andcommitted
fix(sandbox): cut Daytona's retained tail to the same bound as E2B
`appendStreamedSandboxOutput` deliberately lets the accumulator grow to twice the tail before collapsing, so a single re-cut is amortized across chunks rather than paid on every one. That leaves it anywhere inside that band when the stream ends. E2B tails the value it returns, Daytona returned the accumulator as-is, so a stream finishing between one and two tails came back roughly 96 KB on Daytona and 64 KB on E2B. The two adapters must agree — a divergence here surfaces as changed behavior during a failover, which is the one moment nobody wants surprises. Daytona now takes the same final cut on every return path. The conformance test that should have caught this asserted the bound as `tail * 2`, which is satisfied by both the correct and the incorrect value. It now asserts the tail plus the truncation note, and a second case exercises the band between one and two tails where the two providers could disagree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6a91592 commit 922b5fa

2 files changed

Lines changed: 59 additions & 7 deletions

File tree

apps/sim/lib/execution/remote-sandbox/conformance.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,9 @@ beforeEach(() => {
294294
mockGetSessionCommand.mockResolvedValue({ exitCode: 0 })
295295
})
296296

297+
/** Headroom for the note `tailStreamedSandboxOutput` prepends when it truncates. */
298+
const TRUNCATION_NOTE_ALLOWANCE_BYTES = 256
299+
297300
describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
298301
beforeEach(() => useProvider(provider))
299302

@@ -408,9 +411,40 @@ describe.each(PROVIDERS)('sandbox conformance [%s]', (provider) => {
408411
// ...but only the tail is retained. Reaching exitCode 0 at all is the point: before the
409412
// exemption this threw `sandbox_output_limit_exceeded` and killed the sandbox mid-run.
410413
expect(result.stdout).toContain('TAIL_MARKER')
414+
// The tail plus its truncation note, NOT a multiple of it. A looser bound here passes on both
415+
// providers even when one returns twice as much as the other, which is the divergence this
416+
// pair exists to prevent.
417+
expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(
418+
MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES + TRUNCATION_NOTE_ALLOWANCE_BYTES
419+
)
420+
})
421+
422+
it('cuts the retained tail identically when a stream ends between one and two tails', async () => {
423+
// The Daytona appender only collapses once the accumulator passes twice the tail, so a stream
424+
// finishing inside that band is the case where the two providers can disagree.
425+
const midBand = 'y'.repeat(Math.floor(MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES * 1.5))
426+
if (provider === 'e2b') {
427+
mockE2BCommandsRun.mockImplementationOnce(async (_cmd, options) => {
428+
options.onStdout?.(midBand)
429+
return { stdout: midBand, stderr: '', exitCode: 0 }
430+
})
431+
} else {
432+
mockGetSessionCommandLogs.mockImplementationOnce(
433+
async (_sessionId: string, _commandId: string, onStdout: (chunk: string) => void) => {
434+
onStdout(midBand)
435+
}
436+
)
437+
mockGetSessionCommand.mockResolvedValue({ exitCode: 0 })
438+
}
439+
440+
const result = await withPiSandbox({}, (runner) =>
441+
runner.run('pi run', { timeoutMs: 1000, onStdout: () => {} })
442+
)
443+
411444
expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(
412-
MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES * 2
445+
MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES + TRUNCATION_NOTE_ALLOWANCE_BYTES
413446
)
447+
expect(Buffer.byteLength(result.stdout)).toBeLessThan(Buffer.byteLength(midBand))
414448
})
415449

416450
it('still bounds a stream the caller does not consume', async () => {

apps/sim/lib/execution/remote-sandbox/daytona.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
SandboxOutputFileError,
2121
SandboxOutputLimitError,
2222
SandboxProcessOutputBudget,
23+
tailStreamedSandboxOutput,
2324
} from '@/lib/execution/remote-sandbox/output-limits'
2425
import type {
2526
CreateSandboxOptions,
@@ -252,6 +253,13 @@ class DaytonaSandboxHandle implements SandboxHandle {
252253
// stdout but not stderr still has stderr fully bounded. The failover must not change behavior.
253254
const retainStdout = options.onStdout === undefined
254255
const retainStderr = options.onStderr === undefined
256+
// The appender keeps the accumulator under twice the tail so it is not re-cut on every chunk,
257+
// which leaves it anywhere in that band when the stream ends. E2B tails the value it returns,
258+
// so the final cut has to happen here too or a stream finishing between one and two tails comes
259+
// back longer on Daytona than on E2B — a failover divergence, which is what this adapter pair
260+
// must never have.
261+
const finalStdout = () => (retainStdout ? stdout : tailStreamedSandboxOutput(stdout))
262+
const finalStderr = () => (retainStderr ? stderr : tailStreamedSandboxOutput(stderr))
255263
try {
256264
await this.sandbox.process.createSession(sessionId)
257265
sessionCreated = true
@@ -366,8 +374,8 @@ class DaytonaSandboxHandle implements SandboxHandle {
366374
if (outcome === 'output-limit' || outputBudget.error) throw outputBudget.error
367375
if (outcome === 'timeout') {
368376
return {
369-
stdout,
370-
stderr: stderr || `Command timed out after ${options.timeoutMs}ms`,
377+
stdout: finalStdout(),
378+
stderr: finalStderr() || `Command timed out after ${options.timeoutMs}ms`,
371379
exitCode: 124,
372380
timedOut: true,
373381
}
@@ -376,12 +384,17 @@ class DaytonaSandboxHandle implements SandboxHandle {
376384
if (outputBudget.error) throw outputBudget.error
377385
const timedOut = isDaytonaExecutionTimeout(streamError)
378386
if (!timedOut) throw streamError
379-
return { stdout, stderr: stderr || getErrorMessage(streamError), exitCode: 124, timedOut }
387+
return {
388+
stdout: finalStdout(),
389+
stderr: finalStderr() || getErrorMessage(streamError),
390+
exitCode: 124,
391+
timedOut,
392+
}
380393
}
381394

382395
const finished = await this.sandbox.process.getSessionCommand(sessionId, commandId)
383396
const exitCode = finished.exitCode ?? 0
384-
return { stdout, stderr, exitCode }
397+
return { stdout: finalStdout(), stderr: finalStderr(), exitCode }
385398
} catch (error) {
386399
if (isSandboxOutputLimitError(error)) {
387400
void this.kill().catch(() => {})
@@ -393,10 +406,15 @@ class DaytonaSandboxHandle implements SandboxHandle {
393406
: new DOMException('Execution cancelled', 'AbortError')
394407
}
395408
if (isDaytonaExecutionTimeout(error)) {
396-
return { stdout, stderr: stderr || getErrorMessage(error), exitCode: 124, timedOut: true }
409+
return {
410+
stdout: finalStdout(),
411+
stderr: finalStderr() || getErrorMessage(error),
412+
exitCode: 124,
413+
timedOut: true,
414+
}
397415
}
398416
if (operation === 'code') throw error
399-
return { stdout, stderr: stderr || getErrorMessage(error), exitCode: 1 }
417+
return { stdout: finalStdout(), stderr: finalStderr() || getErrorMessage(error), exitCode: 1 }
400418
} finally {
401419
if (sessionCreated) {
402420
try {

0 commit comments

Comments
 (0)