Skip to content

Commit bf023b2

Browse files
committed
fix(sandbox): keep a workbench timeout report when its stop settles after the call deadline
A workbench run stopped by its own timeout reports its output and files, but both run paths checked the call signal before the report. When the provider's stop round trip outlasted the report reserve, the call deadline had already passed and the run returned a bare timeout with its output dropped. - The cancellation check moves into throwIfRunTimedOut: a passed deadline keeps the report, a cancellation at any point stays a cancellation - One-shot sandboxes and runs that did not time out keep their checks
1 parent a2495e3 commit bf023b2

2 files changed

Lines changed: 63 additions & 9 deletions

File tree

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

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,17 @@ function throwIfSandboxTimedOut(result: { timedOut?: boolean }): void {
308308
/** Bounds the output-directory listing a timeout report adds; a slow listing reports no files. */
309309
const TIMED_OUT_OUTPUT_LISTING_MS = 3_000
310310

311+
/** Throws when the call was cancelled; a passed deadline is not a cancellation. */
312+
function throwIfCancelled(signal: AbortSignal): void {
313+
if (!isTimeoutAbortReason(signal.reason)) throwIfAborted(signal)
314+
}
315+
311316
/**
312-
* A workbench run stopped by its own timeout reports what it produced, because the workbench and
313-
* its files outlive the call and the next call can resume from them. A one-shot sandbox is torn
314-
* down with everything in it, so it keeps the plain timeout.
317+
* Settles a finished run against the call's signal. A workbench run stopped by its own timeout
318+
* reports what it produced, because the workbench and its files outlive the call and the next
319+
* call can resume from them; the call's deadline passing while that stop settles keeps the
320+
* report, and a cancellation at any point stays a cancellation. A one-shot sandbox is torn down
321+
* with everything in it, so it keeps the plain timeout.
315322
*/
316323
async function throwIfRunTimedOut(
317324
result: { timedOut?: boolean },
@@ -324,8 +331,12 @@ async function throwIfRunTimedOut(
324331
signal: AbortSignal
325332
}
326333
): Promise<void> {
327-
if (!result.timedOut) return
328-
if (!run.lease.session) throw new DOMException('timeout', 'AbortError')
334+
if (!result.timedOut || !run.lease.session) {
335+
throwIfAborted(run.signal)
336+
throwIfSandboxTimedOut(result)
337+
return
338+
}
339+
throwIfCancelled(run.signal)
329340
const { outputSandboxDir, sandbox } = run
330341
// Half of what the call has left, so the report lands before the call's deadline.
331342
const listingMs = Math.min(
@@ -339,8 +350,7 @@ async function throwIfRunTimedOut(
339350
run.signal
340351
).catch(() => [])
341352
: []
342-
// A cancellation that arrives while listing stays a cancellation.
343-
throwIfAborted(run.signal)
353+
throwIfCancelled(run.signal)
344354
throw new SandboxExecutionTimeoutError(sandboxTimeoutProgress(streams, run.timeoutMs, listed))
345355
}
346356

@@ -1092,7 +1102,6 @@ async function executeInSandboxWithinBudget(
10921102
throwIfAborted(signal)
10931103
throw error
10941104
}
1095-
throwIfAborted(signal)
10961105
await throwIfRunTimedOut(execution, [execution.text, execution.stdout, execution.stderr], {
10971106
sandbox,
10981107
lease,
@@ -1316,7 +1325,6 @@ async function executeShellInSandboxWithinBudget(
13161325
throwIfAborted(signal)
13171326
throw error
13181327
}
1319-
throwIfAborted(signal)
13201328
await throwIfRunTimedOut(result, [result.stdout, result.stderr], {
13211329
sandbox,
13221330
lease,

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,6 +1167,52 @@ describe('session sandbox lease', () => {
11671167
}
11681168
})
11691169

1170+
it('reports a short run whose stop settles after the call deadline', async () => {
1171+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
1172+
try {
1173+
const { handle } = fakeSandbox('timeout-slow-stop')
1174+
mockFindSessionSandbox.mockResolvedValue(handle)
1175+
handle.listFiles = async () => []
1176+
// The provider's own timeout stops the command, and that stop settles after the call's
1177+
// deadline; an abort before the stop interrupts the command.
1178+
handle.runCommand = (_command, options) =>
1179+
new Promise<SandboxCommandResult>((resolve, reject) => {
1180+
let stopping = false
1181+
const stop = () => {
1182+
stopping = true
1183+
setTimeout(
1184+
() => resolve({ stdout: 'step 1', stderr: '', exitCode: 124, timedOut: true }),
1185+
3_000
1186+
)
1187+
}
1188+
setTimeout(() => setTimeout(stop, options.timeoutMs), 300)
1189+
options.signal?.addEventListener(
1190+
'abort',
1191+
() => {
1192+
if (!stopping) reject(options.signal?.reason)
1193+
},
1194+
{ once: true }
1195+
)
1196+
})
1197+
const pending = executeShellInSandbox({
1198+
...CODE_REQUEST,
1199+
timeoutMs: 10_000,
1200+
envs: {},
1201+
session: { key: 'timeout-slow-stop' },
1202+
outputSandboxDir: OUTPUT_DIR,
1203+
}).then(
1204+
() => undefined,
1205+
(rejection: unknown) => rejection
1206+
)
1207+
await vi.runAllTimersAsync()
1208+
const error = await pending
1209+
if (!(error instanceof SandboxExecutionTimeoutError)) throw error
1210+
expect(error.progress.output).toBe('step 1')
1211+
} finally {
1212+
vi.useRealTimers()
1213+
}
1214+
})
1215+
11701216
it('keeps a cancellation that arrives while listing the outputs a cancellation', async () => {
11711217
const { handle } = fakeSandbox('timeout-cancelled')
11721218
mockFindSessionSandbox.mockResolvedValue(handle)

0 commit comments

Comments
 (0)