@@ -94,6 +94,7 @@ async function waitFor(
9494
9595describe ( "CloudTaskEngine" , ( ) => {
9696 let service : CloudTaskEngine ;
97+ let analyticsMock : { track : ReturnType < typeof vi . fn > } ;
9798
9899 beforeEach ( ( ) => {
99100 const scopedLog = {
@@ -103,7 +104,7 @@ describe("CloudTaskEngine", () => {
103104 error : vi . fn ( ) ,
104105 } ;
105106 const loggerMock = { ...scopedLog , scope : vi . fn ( ( ) => scopedLog ) } ;
106- const analyticsMock = { track : vi . fn ( ) } ;
107+ analyticsMock = { track : vi . fn ( ) } ;
107108 service = createCloudTaskEngine ( {
108109 auth : mockAuthService as never ,
109110 analytics : analyticsMock as never ,
@@ -2404,6 +2405,115 @@ describe("CloudTaskEngine", () => {
24042405 ) . toBe ( false ) ;
24052406 } ) ;
24062407
2408+ it ( "aborts and reconnects a stream that goes silent with no bytes or keepalives" , async ( ) => {
2409+ vi . useFakeTimers ( ) ;
2410+
2411+ const updates : unknown [ ] = [ ] ;
2412+ service . on ( CloudTaskEvent . Update , ( payload ) => updates . push ( payload ) ) ;
2413+
2414+ const makeInProgressRun = ( ) =>
2415+ createJsonResponse ( {
2416+ id : "run-1" ,
2417+ status : "in_progress" ,
2418+ stage : null ,
2419+ output : null ,
2420+ error_message : null ,
2421+ branch : "main" ,
2422+ updated_at : "2026-01-01T00:00:00Z" ,
2423+ } ) ;
2424+
2425+ mockNetFetch
2426+ . mockResolvedValueOnce ( makeInProgressRun ( ) )
2427+ . mockResolvedValueOnce (
2428+ createJsonResponse ( [ ] , 200 , { "X-Has-More" : "false" } ) ,
2429+ )
2430+ . mockImplementation ( ( ) => Promise . resolve ( makeInProgressRun ( ) ) ) ;
2431+
2432+ // First connection hangs forever: no bytes, no error, no EOF, simulating a half-open
2433+ // socket (laptop sleep, NAT rebind). The second connection stays open and delivers a
2434+ // keepalive so recovery is observable once the idle watchdog aborts the first.
2435+ let streamCall = 0 ;
2436+ const encoder = new TextEncoder ( ) ;
2437+ const abortedFirstConnection = { value : false } ;
2438+ mockStreamFetch . mockImplementation (
2439+ ( _input : unknown , init ?: RequestInit ) => {
2440+ streamCall += 1 ;
2441+ if ( streamCall === 1 ) {
2442+ const stream = new ReadableStream < Uint8Array > ( {
2443+ start ( controller ) {
2444+ // Never enqueue or close on our own; the read() promise awaits forever until the
2445+ // idle watchdog aborts it below, mirroring how a real fetch's reader rejects once
2446+ // its AbortSignal fires.
2447+ init ?. signal ?. addEventListener ( "abort" , ( ) => {
2448+ abortedFirstConnection . value = true ;
2449+ controller . error ( new DOMException ( "Aborted" , "AbortError" ) ) ;
2450+ } ) ;
2451+ } ,
2452+ } ) ;
2453+ return Promise . resolve (
2454+ new Response ( stream , {
2455+ status : 200 ,
2456+ headers : { "Content-Type" : "text/event-stream" } ,
2457+ } ) ,
2458+ ) ;
2459+ }
2460+ const stream = new ReadableStream < Uint8Array > ( {
2461+ start ( controller ) {
2462+ controller . enqueue (
2463+ encoder . encode (
2464+ 'event: keepalive\ndata: {"type":"keepalive"}\n\n' ,
2465+ ) ,
2466+ ) ;
2467+ } ,
2468+ } ) ;
2469+ return Promise . resolve (
2470+ new Response ( stream , {
2471+ status : 200 ,
2472+ headers : { "Content-Type" : "text/event-stream" } ,
2473+ } ) ,
2474+ ) ;
2475+ } ,
2476+ ) ;
2477+
2478+ service . watch ( {
2479+ taskId : "task-1" ,
2480+ runId : "run-1" ,
2481+ apiHost : "https://app.example.com" ,
2482+ teamId : 2 ,
2483+ } ) ;
2484+
2485+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length === 1 ) ;
2486+
2487+ // Nothing throws or EOFs; without the idle watchdog this would hang forever.
2488+ await vi . advanceTimersByTimeAsync ( 60_000 ) ;
2489+ expect ( abortedFirstConnection . value ) . toBe ( false ) ;
2490+
2491+ await vi . advanceTimersByTimeAsync ( 40_000 ) ;
2492+ await waitFor ( ( ) => abortedFirstConnection . value , 20_000 ) ;
2493+ await waitFor ( ( ) => mockStreamFetch . mock . calls . length >= 2 , 20_000 ) ;
2494+
2495+ expect (
2496+ analyticsMock . track . mock . calls . some (
2497+ ( [ eventName ] ) => eventName === "Cloud stream idle timeout" ,
2498+ ) ,
2499+ ) . toBe ( true ) ;
2500+
2501+ const watcher = (
2502+ service as unknown as {
2503+ watchers : Map < string , { failed : boolean } > ;
2504+ }
2505+ ) . watchers . get ( "task-1:run-1" ) ;
2506+ expect ( watcher ?. failed ) . toBe ( false ) ;
2507+ expect (
2508+ updates . some (
2509+ ( u ) =>
2510+ typeof u === "object" &&
2511+ u !== null &&
2512+ ( u as { kind ?: string } ) . kind === "error" ,
2513+ ) ,
2514+ ) . toBe ( false ) ;
2515+ } ) ;
2516+
24072517 it ( "stops a cloud run through the run cancel endpoint" , async ( ) => {
24082518 mockNetFetch . mockResolvedValueOnce (
24092519 createJsonResponse ( { id : "run-1" , status : "in_progress" } , 202 ) ,
0 commit comments