Skip to content

Commit 3fa33cf

Browse files
authored
improvement(sandbox): return a timed-out workbench run's output and the files it already wrote (#9596)
* improvement(sandbox): return a timed-out workbench run's output and the files it already wrote A run_code call killed at its own timeout returned an empty stdout and only "timed out after Nms", so models reran long jobs from scratch. E2B's SDK throws a stream-less TimeoutError, which dropped the output the stream callbacks had already observed. - E2B adapter: a timed-out command returns the stdout/stderr its callbacks observed - Workbench runs (session sandboxes) that hit their own timeout throw a timeout abort carrying the output tail (64 KB) and the files already in the call's output directory; every existing timeout check still accepts it - The route returns that output in stdout with a 408 and says the workbench and its files persist, so the next call can rerun only the remaining work - Output goes through the same session provenance check and secret-name reporting as any other result; one-shot sandboxes and billing are unchanged * fix(sandbox): keep a cancellation during the timed-out output listing a cancellation
1 parent 5f4ea51 commit 3fa33cf

8 files changed

Lines changed: 375 additions & 20 deletions

File tree

‎apps/sim/lib/execution/remote-sandbox/e2b-session.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,6 +613,36 @@ describe('E2B session recovery', () => {
613613
expect(stopCommand).toHaveBeenCalledOnce()
614614
expect(killSandbox).not.toHaveBeenCalled()
615615
})
616+
617+
it('returns what a session command printed before its own timeout stopped it', async () => {
618+
runCommand.mockImplementation(
619+
async (
620+
_command: string,
621+
options: { onStdout?: (chunk: string) => void; onStderr?: (chunk: string) => void }
622+
) => {
623+
options.onStdout?.('rendered 1 of 3\n')
624+
options.onStderr?.('slow frame\n')
625+
options.onStdout?.('rendered 2 of 3\n')
626+
return {
627+
pid: 7,
628+
wait: async () => {
629+
throw Object.assign(
630+
new Error("deadline_exceeded: This error is likely due to exceeding 'timeoutMs'"),
631+
{ name: 'TimeoutError' }
632+
)
633+
},
634+
}
635+
}
636+
)
637+
const sandbox = await sessionSandbox('reconnected')
638+
const result = await sandbox?.runCommand('render', { timeoutMs: 1000 })
639+
expect(result).toMatchObject({
640+
timedOut: true,
641+
exitCode: 124,
642+
stdout: 'rendered 1 of 3\nrendered 2 of 3\n',
643+
stderr: 'slow frame\n',
644+
})
645+
})
616646
})
617647

618648
describe('E2B session lease', () => {

‎apps/sim/lib/execution/remote-sandbox/e2b.ts‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,14 @@ export async function stopE2BSessionProcess(
373373
if (sandbox) await stopSessionCommand(sandbox, process.id, signal)
374374
}
375375

376+
/** Joins what the stream callbacks observed with the streams the SDK returned, without repeating either. */
377+
function mergeObservedOutput(observed: string, returned: string): string {
378+
if (!observed) return returned
379+
if (!returned || observed.endsWith(returned)) return observed
380+
if (returned.startsWith(observed)) return returned
381+
return observed + returned
382+
}
383+
376384
class E2BSandboxHandle implements SandboxHandle {
377385
private killed = false
378386
private killPromise: Promise<void> | null = null
@@ -676,12 +684,6 @@ class E2BSandboxHandle implements SandboxHandle {
676684
// process handle because `background: true` is fixed above.
677685
result = started
678686
}
679-
const mergeObservedOutput = (observed: string, returned: string): string => {
680-
if (!observed) return returned
681-
if (!returned || observed.endsWith(returned)) return observed
682-
if (returned.startsWith(observed)) return returned
683-
return observed + returned
684-
}
685687
const stdout = mergeObservedOutput(observedStdout, result.stdout ?? '')
686688
const stderr = mergeObservedOutput(observedStderr, result.stderr ?? '')
687689
assertSandboxProcessOutputWithinLimit([stdout, stderr], options.maxOutputBytes)
@@ -727,16 +729,21 @@ class E2BSandboxHandle implements SandboxHandle {
727729
)
728730
const tailIfStreamed = (value: string | undefined, retain: boolean) =>
729731
retain || value === undefined ? value : tailStreamedSandboxOutput(value)
730-
const failureStdout = tailIfStreamed(failure.stdout, retainStdout)
731-
const failureStderr = tailIfStreamed(failure.stderr, retainStderr)
732732
if (isE2BExecutionTimeout(error)) {
733+
// The SDK's timeout error carries no streams; the callbacks observed the run's output.
734+
const stdout = mergeObservedOutput(observedStdout, failure.stdout ?? '')
735+
const stderr = mergeObservedOutput(observedStderr, failure.stderr ?? '')
733736
return {
734-
stdout: failureStdout ?? '',
735-
stderr: failureStderr ?? failure.message ?? '',
737+
stdout: retainStdout ? stdout : tailStreamedSandboxOutput(stdout),
738+
stderr:
739+
(retainStderr ? stderr : tailStreamedSandboxOutput(stderr)) ||
740+
`Command timed out after ${options.timeoutMs}ms`,
736741
exitCode: 124,
737742
timedOut: true,
738743
}
739744
}
745+
const failureStdout = tailIfStreamed(failure.stdout, retainStdout)
746+
const failureStderr = tailIfStreamed(failure.stderr, retainStderr)
740747
return {
741748
stdout: failureStdout ?? '',
742749
stderr: failureStderr ?? failure.message ?? getErrorMessage(error),

‎apps/sim/lib/execution/remote-sandbox/index.ts‎

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
isTimeoutAbortReason,
1010
} from '@/lib/core/execution-limits'
1111
import { recordSandboxTeardownFailure } from '@/lib/core/execution-limits/metrics'
12+
import { withinDeadline } from '@/lib/core/utils/deadline'
1213
import { buildJavaScriptRuntimeBindingsSource } from '@/lib/execution/code-placeholders/javascript-runtime'
1314
import { SANDBOX_SYSTEM_PATH } from '@/lib/execution/remote-sandbox/cli-tools.server'
1415
import { describeProcessExit } from '@/lib/execution/remote-sandbox/code-failure'
@@ -65,6 +66,10 @@ import {
6566
recordSessionFileInput,
6667
} from '@/lib/execution/remote-sandbox/session-file-provenance'
6768
import { withSandboxSessionLock } from '@/lib/execution/remote-sandbox/session-lock'
69+
import {
70+
SandboxExecutionTimeoutError,
71+
sandboxTimeoutProgress,
72+
} from '@/lib/execution/remote-sandbox/timeout'
6873
import type {
6974
CreateSandboxOptions,
7075
SandboxCodeResult,
@@ -258,6 +263,40 @@ function throwIfSandboxTimedOut(result: { timedOut?: boolean }): void {
258263
if (result.timedOut) throw new DOMException('timeout', 'AbortError')
259264
}
260265

266+
/** Bounds the output-directory listing a timeout report adds; a slow listing reports no files. */
267+
const TIMED_OUT_OUTPUT_LISTING_MS = 3_000
268+
269+
/**
270+
* A workbench run stopped by its own timeout reports what it produced, because the workbench and
271+
* its files outlive the call and the next call can resume from them. A one-shot sandbox is torn
272+
* down with everything in it, so it keeps the plain timeout.
273+
*/
274+
async function throwIfRunTimedOut(
275+
result: { timedOut?: boolean },
276+
streams: Array<string | undefined>,
277+
run: {
278+
sandbox: SandboxHandle
279+
lease: SandboxLease
280+
outputSandboxDir: string | undefined
281+
timeoutMs: number
282+
signal: AbortSignal
283+
}
284+
): Promise<void> {
285+
if (!result.timedOut) return
286+
if (!run.lease.session) throw new DOMException('timeout', 'AbortError')
287+
const { outputSandboxDir, sandbox } = run
288+
const listed = outputSandboxDir
289+
? await withinDeadline(
290+
() => sandbox.listFiles(outputSandboxDir, { depth: SANDBOX_OUTPUT_DIR_MAX_DEPTH }),
291+
Date.now() + TIMED_OUT_OUTPUT_LISTING_MS,
292+
run.signal
293+
).catch(() => [])
294+
: []
295+
// A cancellation that arrives while listing stays a cancellation.
296+
throwIfAborted(run.signal)
297+
throw new SandboxExecutionTimeoutError(sandboxTimeoutProgress(streams, run.timeoutMs, listed))
298+
}
299+
261300
function bindSandboxAbort(
262301
sandbox: SandboxHandle,
263302
provider: SandboxProviderId,
@@ -969,10 +1008,11 @@ async function executeInSandboxWithinBudget(
9691008
Object.keys(privateInputFiles.environment).length > 0
9701009

9711010
let execution: SandboxCodeResult
1011+
const runTimeoutMs = codeBudgetMs(req, signal)
9721012
try {
9731013
const endRun = phases.start('run')
9741014
execution = await sandbox.runCode(code, {
975-
timeoutMs: codeBudgetMs(req, signal),
1015+
timeoutMs: runTimeoutMs,
9761016
javascriptPreload: buildJavaScriptRuntimeBindingsSource(req.runtimeBindings ?? []),
9771017
maxOutputBytes: MAX_SANDBOX_PROCESS_OUTPUT_BYTES,
9781018
signal,
@@ -984,7 +1024,13 @@ async function executeInSandboxWithinBudget(
9841024
throw error
9851025
}
9861026
throwIfAborted(signal)
987-
throwIfSandboxTimedOut(execution)
1027+
await throwIfRunTimedOut(execution, [execution.text, execution.stdout, execution.stderr], {
1028+
sandbox,
1029+
lease,
1030+
outputSandboxDir: req.outputSandboxDir,
1031+
timeoutMs: runTimeoutMs,
1032+
signal,
1033+
})
9881034

9891035
if (execution.error) {
9901036
const errorMessage = `${execution.error.name}: ${execution.error.value}`
@@ -1166,6 +1212,7 @@ async function executeShellInSandboxWithinBudget(
11661212
endPrepare()
11671213

11681214
let result: SandboxCommandResult
1215+
const runTimeoutMs = codeBudgetMs(req, signal)
11691216
try {
11701217
const endRun = phases.start('run')
11711218
result = await sandbox.runCommand(code, {
@@ -1177,7 +1224,7 @@ async function executeShellInSandboxWithinBudget(
11771224
...privateInputFiles.environment,
11781225
...(req.session && req.outputSandboxDir ? { SIM_OUTPUT_DIR: req.outputSandboxDir } : {}),
11791226
},
1180-
timeoutMs: codeBudgetMs(req, signal),
1227+
timeoutMs: runTimeoutMs,
11811228
maxOutputBytes: MAX_SANDBOX_PROCESS_OUTPUT_BYTES,
11821229
signal,
11831230
rootUser: !lease.session,
@@ -1189,7 +1236,13 @@ async function executeShellInSandboxWithinBudget(
11891236
throw error
11901237
}
11911238
throwIfAborted(signal)
1192-
throwIfSandboxTimedOut(result)
1239+
await throwIfRunTimedOut(result, [result.stdout, result.stderr], {
1240+
sandbox,
1241+
lease,
1242+
outputSandboxDir: req.outputSandboxDir,
1243+
timeoutMs: runTimeoutMs,
1244+
signal,
1245+
})
11931246

11941247
const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n')
11951248

‎apps/sim/lib/execution/remote-sandbox/output-limits.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,16 @@ const STREAMED_OUTPUT_TRUNCATION_NOTE =
4848
*/
4949
export function tailStreamedSandboxOutput(
5050
value: string | undefined,
51-
limitBytes = MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES
51+
limitBytes = MAX_SANDBOX_STREAMED_OUTPUT_TAIL_BYTES,
52+
truncationNote = STREAMED_OUTPUT_TRUNCATION_NOTE
5253
): string {
5354
if (!value) return ''
5455
const buffer = Buffer.from(value, 'utf8')
5556
if (buffer.length <= limitBytes) return value
5657

5758
let start = buffer.length - limitBytes
5859
while (start < buffer.length && (buffer[start] & 0xc0) === 0x80) start += 1
59-
return `${STREAMED_OUTPUT_TRUNCATION_NOTE}${buffer.subarray(start).toString('utf8')}`
60+
return `${truncationNote}${buffer.subarray(start).toString('utf8')}`
6061
}
6162

6263
/**

0 commit comments

Comments
 (0)