@@ -224,6 +224,130 @@ test("console.debug is captured; uncommon methods fall through without throwing"
224224 await Promise . all ( [ data , ws ] . map ( ( d ) => fs . rm ( d , { recursive : true , force : true } ) ) )
225225} )
226226
227+ // Timeout isolation: a timed-out snippet keeps running as an orphan (JS
228+ // Promises are not preemptible), so the tool must retire the Session object
229+ // the snippet received and surface captured output in the error. No Chrome
230+ // required — the snippets sleep without touching the browser.
231+ const runTimeout = async ( id : string , code : string , timeout : number , onChunk ?: ( o : string ) => Effect . Effect < void > ) => {
232+ const data = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "bcode-to-" ) )
233+ const ws = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "bcode-to-ws-" ) )
234+ const err = await Effect . runPromise (
235+ Effect . scoped (
236+ Effect . gen ( function * ( ) {
237+ const impl = yield * BrowserExecute . make ( data )
238+ return yield * impl . execute (
239+ { description : "timeout test" , code, timeout } ,
240+ { sessionID : id , workspaceDir : ws , onChunk } ,
241+ )
242+ } ) ,
243+ ) ,
244+ ) . then (
245+ ( ) => { throw new Error ( "expected timeout" ) } ,
246+ ( e : unknown ) => String ( e ) ,
247+ )
248+ await Promise . all ( [ data , ws ] . map ( ( d ) => fs . rm ( d , { recursive : true , force : true } ) ) )
249+ return err
250+ }
251+
252+ test ( "timeout returns partial output and retires the session" , async ( ) => {
253+ const id = "timeout-isolation-test"
254+ const before = SessionStore . get ( id )
255+ const err = await runTimeout (
256+ id ,
257+ `console.log("progress-marker");
258+ await new Promise((r) => setTimeout(r, 60_000));` ,
259+ 100 ,
260+ )
261+ expect ( err ) . toContain ( "timed out after 100 ms" )
262+ expect ( err ) . toContain ( "Partial console output before timeout:" )
263+ expect ( err ) . toContain ( "progress-marker" )
264+ // The orphan's Session is permanently dead...
265+ expect ( before . isConnected ( ) ) . toBe ( false )
266+ await expect ( before . connect ( { wsUrl : "ws://127.0.0.1:9/nope" } ) ) . rejects . toThrow ( / t i m e d o u t a f t e r 1 0 0 m s / )
267+ await expect ( before . domains . Runtime . evaluate ( { expression : "1" } ) ) . rejects . toThrow ( / t i m e d o u t a f t e r 1 0 0 m s / )
268+ // ...and the next tool call gets a fresh one.
269+ expect ( SessionStore . get ( id ) ) . not . toBe ( before )
270+ await SessionStore . evict ( id )
271+ } )
272+
273+ test ( "console capture and onChunk stop after timeout" , async ( ) => {
274+ const chunks : string [ ] = [ ]
275+ const err = await runTimeout (
276+ "timeout-capture-test" ,
277+ `console.log("early");
278+ await new Promise((r) => setTimeout(r, 250));
279+ console.log("late");` ,
280+ 100 ,
281+ ( o ) => Effect . sync ( ( ) => { chunks . push ( o ) } ) ,
282+ )
283+ expect ( err ) . toContain ( "early" )
284+ // Let the orphan's late log fire, then confirm it was not captured.
285+ await new Promise ( ( r ) => setTimeout ( r , 400 ) )
286+ expect ( chunks . some ( ( c ) => c . includes ( "early" ) ) ) . toBe ( true )
287+ expect ( chunks . some ( ( c ) => c . includes ( "late" ) ) ) . toBe ( false )
288+ await SessionStore . evict ( "timeout-capture-test" )
289+ } )
290+
291+ test ( "re-running the execute effect after a timeout gets fresh state" , async ( ) => {
292+ const data = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "bcode-rerun-" ) )
293+ const ws = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "bcode-rerun-ws-" ) )
294+ const impl = await Effect . runPromise ( BrowserExecute . make ( data ) )
295+ // One Effect value, run twice. Each run must resolve its own Session and
296+ // capture buffer — the second run's error must carry its own partial
297+ // output, not inherit the first run's frozen capture or retired Session.
298+ // onChunk deliveries discriminate: a run that inherited a frozen capture
299+ // buffer never tees, so it produces zero chunks (the frozen buffer still
300+ // *contains* run 1's text, which is why asserting on the error message
301+ // alone cannot catch this).
302+ const chunks : string [ ] = [ ]
303+ const eff = impl . execute (
304+ {
305+ description : "rerun test" ,
306+ code : `console.log("progress-marker");
307+ await new Promise((r) => setTimeout(r, 60_000));` ,
308+ timeout : 100 ,
309+ } ,
310+ { sessionID : "rerun-test" , workspaceDir : ws , onChunk : ( o ) => Effect . sync ( ( ) => { chunks . push ( o ) } ) } ,
311+ )
312+ const run = ( ) => Effect . runPromise ( eff ) . then ( ( ) => "resolved" , ( e : unknown ) => String ( e ) )
313+ const first = await run ( )
314+ const afterFirst = chunks . length
315+ const second = await run ( )
316+ expect ( first ) . toContain ( "progress-marker" )
317+ expect ( second ) . toContain ( "progress-marker" )
318+ expect ( afterFirst ) . toBeGreaterThan ( 0 )
319+ expect ( chunks . length ) . toBeGreaterThan ( afterFirst )
320+ await SessionStore . evict ( "rerun-test" )
321+ await Promise . all ( [ data , ws ] . map ( ( d ) => fs . rm ( d , { recursive : true , force : true } ) ) )
322+ } )
323+
324+ test ( "invalidate retires the expected Session even after replacement" , async ( ) => {
325+ const id = "invalidate-replaced-test"
326+ const s1 = SessionStore . get ( id )
327+ await SessionStore . evict ( id )
328+ const s2 = SessionStore . get ( id )
329+ SessionStore . invalidate ( id , s1 , new Error ( "retired stale session" ) )
330+ // The successor entry is untouched, but the stale object is still dead.
331+ expect ( SessionStore . get ( id ) ) . toBe ( s2 )
332+ await expect ( s1 . connect ( { wsUrl : "ws://127.0.0.1:9/nope" } ) ) . rejects . toThrow ( / r e t i r e d s t a l e s e s s i o n / )
333+ await SessionStore . evict ( id )
334+ } )
335+
336+ test ( "timeout output is tail-capped to valid UTF-8" , async ( ) => {
337+ // ~25 KiB of multibyte lines, all logged before the sleep.
338+ const err = await runTimeout (
339+ "timeout-truncate-test" ,
340+ `for (let i = 0; i < 300; i++) console.log("é".repeat(40) + "-line-" + i);
341+ await new Promise((r) => setTimeout(r, 60_000));` ,
342+ 100 ,
343+ )
344+ expect ( err ) . toContain ( "[partial console output truncated; showing final bytes]" )
345+ expect ( err ) . toContain ( "-line-299" )
346+ expect ( err ) . not . toContain ( "-line-0\n" )
347+ expect ( err ) . not . toContain ( "\uFFFD" )
348+ await SessionStore . evict ( "timeout-truncate-test" )
349+ } )
350+
227351// Concurrency safety: two overlapping execute() calls (different sessionIDs)
228352// must each capture their own console output without leaking into each other
229353// or into the real global console. No Chrome required — the snippets never
0 commit comments