@@ -1262,12 +1262,75 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
12621262 const controller = new AbortController ( ) ;
12631263 watcher . sseAbortController = controller ;
12641264
1265+ let connectedAt = 0 ;
1266+ let streamWasEstablished = false ;
1267+ let bytesReceived = 0 ;
1268+ let eventsReceived = 0 ;
1269+ let idleTimedOut = false ;
1270+ let idleTimeoutHandle : ReturnType < typeof setTimeout > | null = null ;
1271+ let idlePhase : "target_resolution" | "connection" | "stream" =
1272+ "target_resolution" ;
1273+
1274+ const clearIdleTimeout = ( ) => {
1275+ if ( idleTimeoutHandle ) {
1276+ clearTimeout ( idleTimeoutHandle ) ;
1277+ idleTimeoutHandle = null ;
1278+ }
1279+ } ;
1280+ const armIdleTimeout = ( ) => {
1281+ clearIdleTimeout ( ) ;
1282+ idleTimeoutHandle = setTimeout ( ( ) => {
1283+ idleTimedOut = true ;
1284+ controller . abort ( ) ;
1285+ } , SSE_IDLE_TIMEOUT_MS ) ;
1286+ } ;
1287+ const recordIdleTimeout = ( details : Record < string , unknown > = { } ) => {
1288+ const idleWatcher = this . watchers . get ( key ) ;
1289+ this . log . warn ( "Cloud task stream idle timeout, no bytes received" , {
1290+ key,
1291+ phase : idlePhase ,
1292+ idleTimeoutMs : SSE_IDLE_TIMEOUT_MS ,
1293+ bytesReceived,
1294+ eventsReceived,
1295+ connectionDurationMs : streamWasEstablished
1296+ ? Date . now ( ) - connectedAt
1297+ : 0 ,
1298+ ...details ,
1299+ } ) ;
1300+ if ( idleWatcher ) {
1301+ this . analytics . track ( ANALYTICS_EVENTS . CLOUD_STREAM_IDLE_TIMEOUT , {
1302+ task_id : idleWatcher . taskId ,
1303+ run_id : idleWatcher . runId ,
1304+ team_id : idleWatcher . teamId ,
1305+ idle_timeout_ms : SSE_IDLE_TIMEOUT_MS ,
1306+ bytes_received : bytesReceived ,
1307+ events_received : eventsReceived ,
1308+ } ) ;
1309+ }
1310+ } ;
1311+
12651312 watcher . connStartedAt = 0 ;
12661313 watcher . connDataEventsReceived = 0 ;
12671314
12681315 // Resolve the read target once (proxy URL + token, or Django), reused across reconnects.
12691316 if ( ! watcher . streamTargetResolved ) {
1270- await this . resolveStreamTarget ( watcher ) ;
1317+ armIdleTimeout ( ) ;
1318+ try {
1319+ await this . resolveStreamTarget ( watcher , controller . signal ) ;
1320+ } catch ( error ) {
1321+ if ( ! idleTimedOut ) {
1322+ return ;
1323+ }
1324+ recordIdleTimeout ( ) ;
1325+ await this . handleStreamCompletion ( key , {
1326+ reconnectOnDisconnect : true ,
1327+ reconnectError : error ,
1328+ countReconnectAttempt : true ,
1329+ } ) ;
1330+ return ;
1331+ } finally {
1332+ clearIdleTimeout ( ) ;
1333+ }
12711334 const resolvedWatcher = this . watchers . get ( key ) ;
12721335 if (
12731336 ! resolvedWatcher ||
@@ -1346,30 +1409,11 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
13461409
13471410 // Track how long the body stayed open so healthy long-lived connections cut by churn
13481411 // aren't penalized as failed reconnects (see SSE_HEALTHY_CONNECTION_MS).
1349- let connectedAt = 0 ;
1350- let streamWasEstablished = false ;
1351- let bytesReceived = 0 ;
1352- let eventsReceived = 0 ;
1353- let idleTimedOut = false ;
1354- let idleTimeoutHandle : ReturnType < typeof setTimeout > | null = null ;
1355-
1356- const clearIdleTimeout = ( ) => {
1357- if ( idleTimeoutHandle ) {
1358- clearTimeout ( idleTimeoutHandle ) ;
1359- idleTimeoutHandle = null ;
1360- }
1361- } ;
13621412 // Re-armed on every read that returns a value (data or keepalive bytes), so it only fires
13631413 // when the transport has gone completely silent, not merely between infrequent events.
1364- const armIdleTimeout = ( ) => {
1365- clearIdleTimeout ( ) ;
1366- idleTimeoutHandle = setTimeout ( ( ) => {
1367- idleTimedOut = true ;
1368- controller . abort ( ) ;
1369- } , SSE_IDLE_TIMEOUT_MS ) ;
1370- } ;
1371-
13721414 try {
1415+ idlePhase = "connection" ;
1416+ armIdleTimeout ( ) ;
13731417 // The proxy authenticates with the run-scoped Bearer token; the Django leg uses the session.
13741418 const response = usingProxy
13751419 ? await this . streamFetch ( url . toString ( ) , {
@@ -1425,6 +1469,7 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
14251469 } ) ;
14261470
14271471 const reader = response . body . getReader ( ) ;
1472+ idlePhase = "stream" ;
14281473 armIdleTimeout ( ) ;
14291474
14301475 while ( true ) {
@@ -1498,28 +1543,10 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
14981543 }
14991544
15001545 if ( idleTimedOut ) {
1501- const idleWatcher = this . watchers . get ( key ) ;
1502- this . log . warn ( "Cloud task stream idle timeout, no bytes received" , {
1503- key,
1546+ recordIdleTimeout ( {
15041547 leg,
15051548 streamUrl : url . toString ( ) ,
1506- idleTimeoutMs : SSE_IDLE_TIMEOUT_MS ,
1507- bytesReceived,
1508- eventsReceived,
1509- connectionDurationMs : streamWasEstablished
1510- ? Date . now ( ) - connectedAt
1511- : 0 ,
15121549 } ) ;
1513- if ( idleWatcher ) {
1514- this . analytics . track ( ANALYTICS_EVENTS . CLOUD_STREAM_IDLE_TIMEOUT , {
1515- task_id : idleWatcher . taskId ,
1516- run_id : idleWatcher . runId ,
1517- team_id : idleWatcher . teamId ,
1518- idle_timeout_ms : SSE_IDLE_TIMEOUT_MS ,
1519- bytes_received : bytesReceived ,
1520- events_received : eventsReceived ,
1521- } ) ;
1522- }
15231550 }
15241551
15251552 // Proxy-leg 401: the read token expired or its signing key rotated. Re-resolve to mint a
@@ -1561,6 +1588,7 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
15611588 const isBackendError = error instanceof BackendStreamError ;
15621589 const wasHealthyStream =
15631590 ! isBackendError &&
1591+ ! idleTimedOut &&
15641592 streamWasEstablished &&
15651593 Date . now ( ) - connectedAt >= SSE_HEALTHY_CONNECTION_MS ;
15661594
@@ -2254,11 +2282,15 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
22542282 }
22552283 }
22562284
2257- private async resolveStreamTarget ( watcher : WatcherState ) : Promise < void > {
2285+ private async resolveStreamTarget (
2286+ watcher : WatcherState ,
2287+ signal : AbortSignal ,
2288+ ) : Promise < void > {
22582289 const url = `${ watcher . apiHost } /api/projects/${ watcher . teamId } /tasks/${ watcher . taskId } /runs/${ watcher . runId } /stream_token/` ;
22592290 try {
22602291 const response = await this . auth . authenticatedFetch ( url , {
22612292 method : "GET" ,
2293+ signal,
22622294 } ) ;
22632295 if ( ! response . ok ) {
22642296 watcher . streamBaseUrl = null ;
@@ -2301,6 +2333,9 @@ export class CloudTaskEngine extends TypedEventEmitter<CloudTaskEvents> {
23012333 durableStream : watcher . durableStreamEnabled ,
23022334 } ) ;
23032335 } catch ( error ) {
2336+ if ( signal . aborted ) {
2337+ throw error ;
2338+ }
23042339 // Transient failure: leave unresolved so the next reconnect retries and falls back to Django.
23052340 watcher . streamBaseUrl = null ;
23062341 watcher . streamReadToken = null ;
0 commit comments