1717// - race/stagger use AbortController; cancelled requests must be released correctly.
1818import { resolveProfileModel } from './interceptor-core.js' ;
1919import { readFileSync , existsSync } from 'node:fs' ;
20+ import { reportSwallowed } from './error-report.js' ;
2021
2122// ── Configuration ─────────────────────────────────────────────────
2223
@@ -162,7 +163,11 @@ export function resolveRetryConfig(env = process.env, options = {}) {
162163 const fileRaw = JSON . parse ( readFileSync ( _retryConfigPath , 'utf-8' ) ) ;
163164 Object . assign ( cfg , validateRetryConfig ( fileRaw ) ) ;
164165 }
165- } catch { /* file missing/corrupt → use env only, don't block */ }
166+ } catch ( err ) {
167+ // retry-config.json written by the UI is corrupt/unreadable → fall back to env.
168+ // Not fatal, but a silent swallow would hide a config the user believes is active.
169+ reportSwallowed ( 'proxyRetry.load-config-file' , err ) ;
170+ }
166171 }
167172
168173 return cfg ;
@@ -219,7 +224,10 @@ export function isStreamResponse(response) {
219224 try {
220225 const ct = response ?. headers ?. get ?. ( 'content-type' ) || '' ;
221226 return typeof ct === 'string' && ct . toLowerCase ( ) . includes ( 'text/event-stream' ) ;
222- } catch {
227+ } catch ( err ) {
228+ // A misbehaving header object would make us misclassify the response, which
229+ // changes retry behavior (streaming 200 is never retried). Surface it.
230+ reportSwallowed ( 'proxyRetry.is-stream-response' , err ) ;
223231 return false ;
224232 }
225233}
@@ -233,7 +241,10 @@ export function extractModel(body) {
233241 const s = typeof body === 'string' ? body : body . toString ( 'utf-8' ) ;
234242 const obj = JSON . parse ( s ) ;
235243 return typeof obj . model === 'string' ? obj . model : '' ;
236- } catch {
244+ } catch ( err ) {
245+ // Unparseable body → stats detail record loses the model field. Surface it
246+ // so a regression in request-body handling isn't hidden behind empty models.
247+ reportSwallowed ( 'proxyRetry.extract-model' , err ) ;
237248 return '' ;
238249 }
239250}
@@ -253,7 +264,11 @@ export function applyModelReplacement(body, profile) {
253264 if ( ! target ) return body ;
254265 obj . model = target ;
255266 return JSON . stringify ( obj ) ;
256- } catch {
267+ } catch ( err ) {
268+ // JSON.parse failure means model replacement silently no-ops; the request
269+ // still goes out with the original (un-replaced) model. Surface it so the
270+ // mismatch between configured replacement and actual body isn't silent.
271+ reportSwallowed ( 'proxyRetry.apply-model-replacement' , err ) ;
257272 return body ;
258273 }
259274}
@@ -299,13 +314,67 @@ function computeWaitMs(status, retryAfterHeader, cfg) {
299314
300315// ── Single fetch wrapper ─────────────────────────────────────────
301316
317+ /**
318+ * Attaches a streaming-idle watchdog to a streaming response body.
319+ *
320+ * Why: connectTimeoutMs only bounds time-to-HEADERS; once headers arrive the
321+ * connect timer is cleared and the retry loop breaks (streaming 200 is never
322+ * retried — retry-before-first-byte strategy). If the upstream then stalls
323+ * (200 headers but no body chunk ever arrives — a hung upstream), the piped
324+ * response would hang indefinitely, pinning the client socket and the upstream
325+ * socket until the client gives up. streamIdleTimeoutMs bounds the max gap
326+ * between two chunks; exceeding it errors the body so proxy.js's pipeline
327+ * surfaces the stall instead of hanging.
328+ *
329+ * Implemented as a TransformStream pass-through so response.body stays a valid
330+ * ReadableStream (Readable.fromWeb in proxy.js keeps working): each enqueued
331+ * chunk resets the timer; a stalled stream fires the timer, which calls
332+ * controller.error(), aborting the fetch's underlying body and breaking the
333+ * pipeline.
334+ *
335+ * @param {ReadableStream } body original streaming body
336+ * @param {number } idleMs max gap between chunks (0 = disabled)
337+ * @param {AbortSignal } signal external signal (race/stagger loser cancel + client disconnect)
338+ * @returns {ReadableStream } watched body (same chunks, bounded idle)
339+ */
340+ function applyStreamIdleWatchdog ( body , idleMs , signal ) {
341+ if ( ! body || typeof body ?. pipeThrough !== 'function' ) return body ;
342+ if ( ! idleMs || idleMs <= 0 ) return body ;
343+ let timer = null ;
344+ let aborted = false ;
345+ const arm = ( ) => {
346+ if ( timer ) clearTimeout ( timer ) ;
347+ timer = setTimeout ( ( ) => {
348+ aborted = true ;
349+ controller . error ( new Error ( `proxy stream idle timeout (${ idleMs } ms)` ) ) ;
350+ } , idleMs ) ;
351+ } ;
352+ let controller ;
353+ const transform = new TransformStream ( {
354+ start ( ctl ) { controller = ctl ; arm ( ) ; if ( signal ) signal . addEventListener ( 'abort' , disarm , { once : true } ) ; } ,
355+ transform ( chunk , ctl ) {
356+ if ( aborted ) return ; // already errored — drop late chunks
357+ ctl . enqueue ( chunk ) ;
358+ arm ( ) ; // reset on each chunk
359+ } ,
360+ flush ( ) { disarm ( ) ; } ,
361+ cancel ( ) { disarm ( ) ; } ,
362+ } ) ;
363+ function disarm ( ) { if ( timer ) { clearTimeout ( timer ) ; timer = null ; } }
364+ // teeThrough keeps our transform in the path; pipeThrough returns the readable end.
365+ // Only pass signal when present — pipeThrough rejects a null/undefined signal.
366+ return signal
367+ ? body . pipeThrough ( transform , { signal } )
368+ : body . pipeThrough ( transform ) ;
369+ }
370+
302371/**
303372 * Executes a single fetch request with the x-cc-viewer-trace header + network proxy dispatcher.
304373 * Returns the raw Response. Does not throw (on network errors returns { __networkError: true, status: 0 }).
305374 *
306375 * @param {string } url full URL
307376 * @param {object } fetchOptions method/headers/body
308- * @param {object } ctx { dispatcher, connectTimeoutMs, signal }
377+ * @param {object } ctx { dispatcher, connectTimeoutMs, streamIdleTimeoutMs, signal }
309378 */
310379async function singleFetch ( url , fetchOptions , ctx ) {
311380 const opts = {
@@ -342,10 +411,21 @@ async function singleFetch(url, fetchOptions, ctx) {
342411
343412 try {
344413 const response = await fetch ( url , opts ) ;
414+ // Streaming responses: attach the idle watchdog so a hung body (headers in,
415+ // no chunks) breaks within streamIdleTimeoutMs instead of pinning sockets.
416+ // connectTimeoutMs already cleared below can't help — it only bound headers.
417+ if ( response ?. body && ctx . streamIdleTimeoutMs > 0 && isStreamResponse ( response ) ) {
418+ const watched = applyStreamIdleWatchdog ( response . body , ctx . streamIdleTimeoutMs , ctx . signal ) ;
419+ return { ...response , body : watched , __streamWatched : true } ;
420+ }
345421 return response ;
346422 } catch ( err ) {
347423 // Network error/timeout/cancellation → return a pseudo response; status=0 indicates an error
348424 const aborted = ctx . signal ?. aborted || timeoutCtl ?. signal . aborted ;
425+ // Aborts are expected (race loser cancellation, client disconnect, connect
426+ // timeout) — not diagnostic. Only surface genuine network errors so a
427+ // failing upstream isn't hidden behind status=0 pseudo-responses.
428+ if ( err && ! aborted ) reportSwallowed ( 'proxyRetry.single-fetch' , err ) ;
349429 return {
350430 __networkError : true ,
351431 __aborted : ! ! aborted ,
@@ -416,7 +496,10 @@ export async function executeRequest({ url, fetchOptions, retryConfig, ctx }) {
416496 // headers well past 10s — so with retry disabled we must not introduce a new
417497 // failure mode. The timeout applies only when a retry mode is active.
418498 const effectiveConnectTimeoutMs = cfg . mode === 'off' ? 0 : cfg . connectTimeoutMs ;
419- const commonCtx = { dispatcher, connectTimeoutMs : effectiveConnectTimeoutMs } ;
499+ // streamIdleTimeoutMs applies to streaming responses in ALL modes (including off):
500+ // even with retry disabled, a hung streaming body must not pin sockets forever.
501+ const effectiveStreamIdleMs = cfg . streamIdleTimeoutMs > 0 ? cfg . streamIdleTimeoutMs : 0 ;
502+ const commonCtx = { dispatcher, connectTimeoutMs : effectiveConnectTimeoutMs , streamIdleTimeoutMs : effectiveStreamIdleMs } ;
420503
421504 if ( cfg . mode === 'off' || cfg . mode === 'serial' ) {
422505 // off / serial: serial retry. off = no retry (break on any status); serial = controlled by maxRetries (0=infinite, capped by deadline)
0 commit comments