@@ -19,6 +19,7 @@ import type {
1919 SandboxCommandResult ,
2020 SandboxHandle ,
2121 SandboxProvider ,
22+ SandboxSessionRequest ,
2223} from '@/lib/execution/remote-sandbox/types'
2324
2425const { mockCreate, mockFindSessionSandbox, mockResolveWorkspaceSandbox } = vi . hoisted ( ( ) => ( {
@@ -41,6 +42,7 @@ vi.mock('@/lib/core/execution-limits/metrics', () => ({
4142 recordSandboxProviderLimit : vi . fn ( ) ,
4243} ) )
4344
45+ import { isTimeoutAbortReason } from '@/lib/core/execution-limits'
4446import {
4547 executeInSandbox ,
4648 executeShellInSandbox ,
@@ -56,6 +58,10 @@ import {
5658 recordSessionFileInput ,
5759} from '@/lib/execution/remote-sandbox/session-file-provenance'
5860import { writeSessionSandboxFile } from '@/lib/execution/remote-sandbox/session-files'
61+ import {
62+ MAX_SANDBOX_TIMEOUT_OUTPUT_TAIL_BYTES ,
63+ SandboxExecutionTimeoutError ,
64+ } from '@/lib/execution/remote-sandbox/timeout'
5965
6066remoteSandboxProviderMockFns . mockResolveProvider . mockImplementation (
6167 ( ) : SandboxProvider => ( {
@@ -941,6 +947,122 @@ describe('session sandbox lease', () => {
941947 expect ( calls . killed ) . toBe ( false )
942948 } )
943949
950+ describe ( 'a workbench run stopped by its own timeout' , ( ) => {
951+ const OUTPUT_DIR = '/tmp/sim/outputs/call-timeout'
952+
953+ async function timedOutRun (
954+ kind : 'code' | 'shell' ,
955+ streams : { stdout : string ; stderr : string } ,
956+ listFiles : SandboxHandle [ 'listFiles' ] ,
957+ acceptOutputProvenance ?: SandboxSessionRequest [ 'acceptOutputProvenance' ]
958+ ) : Promise < unknown > {
959+ const { handle } = fakeSandbox ( `timeout-${ kind } ` )
960+ mockFindSessionSandbox . mockResolvedValue ( handle )
961+ handle . runCode = async ( ) => ( { text : '' , ...streams , timedOut : true } )
962+ handle . runCommand = async ( ) => ( { ...streams , exitCode : 124 , timedOut : true } )
963+ handle . listFiles = listFiles
964+ const request = {
965+ ...CODE_REQUEST ,
966+ session : {
967+ key : `timeout-${ kind } ` ,
968+ ...( acceptOutputProvenance ? { acceptOutputProvenance } : { } ) ,
969+ } ,
970+ outputSandboxDir : OUTPUT_DIR ,
971+ }
972+ return (
973+ kind === 'code'
974+ ? executeInSandbox ( request )
975+ : executeShellInSandbox ( { ...request , envs : { } } )
976+ ) . then (
977+ ( ) => {
978+ throw new Error ( 'Expected the run to time out' )
979+ } ,
980+ ( error : unknown ) => error
981+ )
982+ }
983+
984+ it . each ( [ 'code' , 'shell' ] as const ) (
985+ 'reports the %s output so far and the files already exported' ,
986+ async ( kind ) => {
987+ const error = await timedOutRun (
988+ kind ,
989+ { stdout : 'rendered 1 of 3\nrendered 2 of 3' , stderr : 'slow frame' } ,
990+ async ( ) => [
991+ {
992+ path : `${ OUTPUT_DIR } /${ SANDBOX_OUTPUT_DIR_SENTINEL } ` ,
993+ relativePath : SANDBOX_OUTPUT_DIR_SENTINEL ,
994+ kind : 'file' ,
995+ size : 0 ,
996+ } ,
997+ { path : `${ OUTPUT_DIR } /frames` , relativePath : 'frames' , kind : 'directory' , size : 0 } ,
998+ {
999+ path : `${ OUTPUT_DIR } /frames/2.png` ,
1000+ relativePath : 'frames/2.png' ,
1001+ kind : 'file' ,
1002+ size : 9 ,
1003+ } ,
1004+ {
1005+ path : `${ OUTPUT_DIR } /frames/1.png` ,
1006+ relativePath : 'frames/1.png' ,
1007+ kind : 'file' ,
1008+ size : 9 ,
1009+ } ,
1010+ ]
1011+ )
1012+ expect ( isTimeoutAbortReason ( error ) ) . toBe ( true )
1013+ if ( ! ( error instanceof SandboxExecutionTimeoutError ) ) throw error
1014+ expect ( error . progress . output ) . toBe ( 'rendered 1 of 3\nrendered 2 of 3\nslow frame' )
1015+ expect ( error . progress . outputFiles ) . toEqual ( [
1016+ `${ OUTPUT_DIR } /frames/1.png` ,
1017+ `${ OUTPUT_DIR } /frames/2.png` ,
1018+ ] )
1019+ expect ( error . progress . stoppedAfterMs ) . toBeGreaterThan ( 0 )
1020+ expect ( error . progress . stoppedAfterMs ) . toBeLessThanOrEqual ( CODE_REQUEST . timeoutMs )
1021+ }
1022+ )
1023+
1024+ it . each ( [
1025+ [ 'fails' , async ( ) => Promise . reject ( new Error ( 'listing unavailable' ) ) ] ,
1026+ [ 'never answers' , ( ) => new Promise < never > ( ( ) => { } ) ] ,
1027+ ] as const ) ( 'still reports the output when the file listing %s' , async ( _case , listFiles ) => {
1028+ vi . useFakeTimers ( { toFake : [ 'setTimeout' , 'clearTimeout' ] } )
1029+ try {
1030+ const pending = timedOutRun ( 'shell' , { stdout : 'step 1' , stderr : '' } , listFiles )
1031+ await vi . runAllTimersAsync ( )
1032+ const error = await pending
1033+ if ( ! ( error instanceof SandboxExecutionTimeoutError ) ) throw error
1034+ expect ( error . progress . output ) . toBe ( 'step 1' )
1035+ expect ( error . progress . outputFiles ) . toEqual ( [ ] )
1036+ } finally {
1037+ vi . useRealTimers ( )
1038+ }
1039+ } )
1040+
1041+ it ( 'withholds the output when the workbench secret provenance is unavailable' , async ( ) => {
1042+ const error = await timedOutRun (
1043+ 'shell' ,
1044+ { stdout : 'token=secret-value' , stderr : '' } ,
1045+ async ( ) => [ ] ,
1046+ async ( ) => {
1047+ throw new Error ( 'Workbench output withheld because its secret provenance is unavailable' )
1048+ }
1049+ )
1050+ expect ( error ) . not . toBeInstanceOf ( SandboxExecutionTimeoutError )
1051+ expect ( getErrorMessage ( error ) ) . not . toContain ( 'secret-value' )
1052+ } )
1053+
1054+ it ( 'keeps only the end of an output too large to return' , async ( ) => {
1055+ const lines = Array . from ( { length : 20_000 } , ( _ , index ) => `frame ${ index } ` ) . join ( '\n' )
1056+ const error = await timedOutRun ( 'shell' , { stdout : lines , stderr : '' } , async ( ) => [ ] )
1057+ if ( ! ( error instanceof SandboxExecutionTimeoutError ) ) throw error
1058+ expect ( Buffer . byteLength ( error . progress . output ) ) . toBeLessThanOrEqual (
1059+ MAX_SANDBOX_TIMEOUT_OUTPUT_TAIL_BYTES + 64
1060+ )
1061+ expect ( error . progress . output . endsWith ( 'frame 19999' ) ) . toBe ( true )
1062+ expect ( error . progress . output . startsWith ( '[earlier output omitted]' ) ) . toBe ( true )
1063+ } )
1064+ } )
1065+
9441066 it ( 'keeps the one-shot teardown when no session is requested' , async ( ) => {
9451067 const { handle, calls } = fakeSandbox ( 'sb-oneshot' )
9461068 mockCreate . mockResolvedValue ( handle )
0 commit comments