@@ -30,8 +30,29 @@ export interface LivenessIo {
3030 readRuntimeFn ?: ( pid ?: number ) => { pid ?: number ; port : number ; hostname ?: string } | null ;
3131 configFn ?: ( ) => { port ?: number ; hostname ?: string } ;
3232 timeoutMs ?: number ;
33+ /**
34+ * How many times to retry a probe that failed with a transport error (timeout /
35+ * connection refused). Definitive answers (non-OK HTTP, foreign /healthz body, pid
36+ * mismatch) do not retry. Default 1 = no retry. Stop paths should pass 2–3 (#764).
37+ */
38+ attempts ?: number ;
39+ sleepFn ?: ( ms : number ) => Promise < void > ;
40+ /**
41+ * Absolute wall-clock deadline for discovery. When set, each probe attempt aborts
42+ * once the remaining budget cannot cover another fetch — so multi-candidate
43+ * `findLiveProxy` under `SERVICE_STOP_LIVENESS` cannot overrun the stop-path
44+ * verification window (#764 / CodeRabbit).
45+ */
46+ deadlineAt ?: number ;
47+ nowFn ?: ( ) => number ;
3348}
3449
50+ /** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */
51+ export const SERVICE_STOP_LIVENESS : Pick < LivenessIo , "timeoutMs" | "attempts" > = {
52+ timeoutMs : 1500 ,
53+ attempts : 3 ,
54+ } ;
55+
3556export interface LiveProxy {
3657 pid : number | null ;
3758 port : number ;
@@ -72,19 +93,37 @@ export async function proxyIdentityAt(
7293 io : LivenessIo = { } ,
7394) : Promise < { pid : number | null } | null > {
7495 const fetchFn = io . fetchFn ?? fetch ;
75- try {
76- const res = await fetchFn ( `http://${ probeHostname ( opts . hostname ) } :${ port } /healthz` , {
77- signal : AbortSignal . timeout ( io . timeoutMs ?? 750 ) ,
78- } ) ;
79- if ( ! res . ok ) return null ;
80- const body = ( await res . json ( ) . catch ( ( ) => null ) ) as HealthzIdentity | null ;
81- if ( ! isOpencodexHealthz ( body ) ) return null ;
82- const pid = typeof body ?. pid === "number" ? body . pid : null ;
83- if ( opts . expectedPid !== undefined && pid !== null && pid !== opts . expectedPid ) return null ;
84- return { pid } ;
85- } catch {
86- return null ;
96+ const sleepFn = io . sleepFn ?? ( ( ms : number ) => new Promise < void > ( r => setTimeout ( r , ms ) ) ) ;
97+ const nowFn = io . nowFn ?? Date . now ;
98+ const baseTimeoutMs = io . timeoutMs ?? 750 ;
99+ const requestedAttempts = Math . trunc ( io . attempts ?? 1 ) ;
100+ const attempts = Number . isNaN ( requestedAttempts )
101+ ? 1
102+ : Math . max ( 1 , Math . min ( requestedAttempts , 5 ) ) ;
103+
104+ for ( let attempt = 1 ; attempt <= attempts ; attempt ++ ) {
105+ const remainingMs = io . deadlineAt === undefined ? baseTimeoutMs : io . deadlineAt - nowFn ( ) ;
106+ if ( remainingMs <= 0 ) return null ;
107+ const timeoutMs = Math . min ( baseTimeoutMs , remainingMs ) ;
108+ try {
109+ const res = await fetchFn ( `http://${ probeHostname ( opts . hostname ) } :${ port } /healthz` , {
110+ signal : AbortSignal . timeout ( timeoutMs ) ,
111+ } ) ;
112+ if ( ! res . ok ) return null ;
113+ const body = ( await res . json ( ) . catch ( ( ) => null ) ) as HealthzIdentity | null ;
114+ if ( ! isOpencodexHealthz ( body ) ) return null ;
115+ const pid = typeof body ?. pid === "number" ? body . pid : null ;
116+ if ( opts . expectedPid !== undefined && pid !== null && pid !== opts . expectedPid ) return null ;
117+ return { pid } ;
118+ } catch {
119+ // Transport failure (timeout / refused) — retry while budget remains; a proxy that
120+ // has only just begun listening can miss a single short probe (#764).
121+ if ( attempt >= attempts ) return null ;
122+ if ( io . deadlineAt !== undefined && io . deadlineAt - nowFn ( ) <= 0 ) return null ;
123+ await sleepFn ( 100 ) ;
124+ }
87125 }
126+ return null ;
88127}
89128
90129/**
0 commit comments