@@ -23,7 +23,11 @@ import {
2323 type EmbeddedWebServer ,
2424 startEmbeddedWebServer ,
2525} from "./embedded-web-server" ;
26- import { LaunchdManager , SERVICE_LABELS } from "./launchd-manager" ;
26+ import {
27+ LaunchdManager ,
28+ SERVICE_LABELS ,
29+ type ServiceStatus ,
30+ } from "./launchd-manager" ;
2731import { type PlistEnv , generatePlist } from "./plist-generator" ;
2832
2933export interface LaunchdBootstrapEnv {
@@ -85,6 +89,8 @@ export interface LaunchdBootstrapEnv {
8589 proxyEnv : Record < string , string > ;
8690 /** Optional structured logger for packaged mode (console.log is lost in packaged builds) */
8791 log ?: ( message : string ) => void ;
92+ /** Optional override for controller startup validation timeout (tests only). */
93+ controllerStartupValidationTimeoutMs ?: number ;
8894}
8995
9096export interface LaunchdBootstrapResult {
@@ -164,30 +170,201 @@ async function waitForControllerReadiness(
164170 timeoutMs = 15000 ,
165171) : Promise < void > {
166172 const startedAt = Date . now ( ) ;
167- const probeUrl = `http://127.0.0.1:${ port } /api/auth/get-session` ;
168173 let attempt = 0 ;
174+ let lastProbeUrl = `http://127.0.0.1:${ port } /api/internal/desktop/ready` ;
175+ let lastFailureReason = "probe_timeout" ;
169176
170177 while ( Date . now ( ) - startedAt < timeoutMs ) {
171- try {
172- const response = await fetch ( probeUrl , {
173- headers : { Accept : "application/json" } ,
174- } ) ;
175- if ( response . status < 500 ) {
176- console . log (
177- `Controller ready via ${ probeUrl } status=${ response . status } after ${ Date . now ( ) - startedAt } ms` ,
178- ) ;
179- return ;
180- }
181- } catch {
182- // Ignore transient failures during startup
178+ const result = await probeControllerReady ( port , 2000 ) ;
179+ lastProbeUrl = result . probeUrl ;
180+ if ( result . ok ) {
181+ console . log (
182+ `Controller ready via ${ result . probeUrl } status=${ result . status } after ${ Date . now ( ) - startedAt } ms` ,
183+ ) ;
184+ return ;
183185 }
186+ lastFailureReason = result . reason ;
184187 // Adaptive polling: start aggressive (50ms), increase to 250ms
185188 const delay = Math . min ( 50 + attempt * 50 , 250 ) ;
186189 await new Promise ( ( r ) => setTimeout ( r , delay ) ) ;
187190 attempt ++ ;
188191 }
189192
190- throw new Error ( `Controller readiness probe timed out for ${ probeUrl } ` ) ;
193+ throw new Error (
194+ `Controller readiness probe timed out for ${ lastProbeUrl } (reason=${ lastFailureReason } )` ,
195+ ) ;
196+ }
197+
198+ type ControllerProbeFailureReason =
199+ | "port_unreachable"
200+ | "probe_timeout"
201+ | "probe_error"
202+ | "probe_status" ;
203+
204+ type ControllerStartupFailureReason =
205+ | "launchd_stopped"
206+ | "process_exited"
207+ | ControllerProbeFailureReason ;
208+
209+ type ControllerReadyProbeResult =
210+ | {
211+ ok : true ;
212+ probeUrl : string ;
213+ status : number ;
214+ }
215+ | {
216+ ok : false ;
217+ probeUrl : string ;
218+ reason : ControllerProbeFailureReason ;
219+ status ?: number ;
220+ } ;
221+
222+ type ControllerStartupValidationResult =
223+ | {
224+ ok : true ;
225+ }
226+ | {
227+ ok : false ;
228+ reason : ControllerStartupFailureReason ;
229+ launchdStatus : ServiceStatus ;
230+ probeUrl : string ;
231+ probeStatus ?: number ;
232+ } ;
233+
234+ async function probeControllerReady (
235+ port : number ,
236+ timeoutMs = 2000 ,
237+ ) : Promise < ControllerReadyProbeResult > {
238+ const readyUrl = `http://127.0.0.1:${ port } /api/internal/desktop/ready` ;
239+ const sessionUrl = `http://127.0.0.1:${ port } /api/auth/get-session` ;
240+
241+ const portListening = await probePort ( port ) ;
242+ if ( ! portListening ) {
243+ return {
244+ ok : false ,
245+ probeUrl : readyUrl ,
246+ reason : "port_unreachable" ,
247+ } ;
248+ }
249+
250+ try {
251+ const response = await fetch ( readyUrl , {
252+ headers : { Accept : "application/json" } ,
253+ signal : AbortSignal . timeout ( timeoutMs ) ,
254+ } ) ;
255+ if ( response . ok ) {
256+ return { ok : true , probeUrl : readyUrl , status : response . status } ;
257+ }
258+ if ( response . status !== 404 ) {
259+ return {
260+ ok : false ,
261+ probeUrl : readyUrl ,
262+ reason : "probe_status" ,
263+ status : response . status ,
264+ } ;
265+ }
266+ } catch ( error ) {
267+ const name = error instanceof Error ? error . name : undefined ;
268+ return {
269+ ok : false ,
270+ probeUrl : readyUrl ,
271+ reason : name === "TimeoutError" ? "probe_timeout" : "probe_error" ,
272+ } ;
273+ }
274+
275+ try {
276+ const response = await fetch ( sessionUrl , {
277+ headers : { Accept : "application/json" } ,
278+ signal : AbortSignal . timeout ( timeoutMs ) ,
279+ } ) ;
280+ if ( response . status < 500 ) {
281+ return { ok : true , probeUrl : sessionUrl , status : response . status } ;
282+ }
283+ return {
284+ ok : false ,
285+ probeUrl : sessionUrl ,
286+ reason : "probe_status" ,
287+ status : response . status ,
288+ } ;
289+ } catch ( error ) {
290+ const name = error instanceof Error ? error . name : undefined ;
291+ return {
292+ ok : false ,
293+ probeUrl : sessionUrl ,
294+ reason : name === "TimeoutError" ? "probe_timeout" : "probe_error" ,
295+ } ;
296+ }
297+ }
298+
299+ async function validateControllerStartup ( opts : {
300+ launchd : LaunchdManager ;
301+ label : string ;
302+ port : number ;
303+ probeTimeoutMs ?: number ;
304+ } ) : Promise < ControllerStartupValidationResult > {
305+ const launchdStatus = await opts . launchd . getServiceStatus ( opts . label ) ;
306+ if ( launchdStatus . status === "stopped" ) {
307+ return {
308+ ok : false ,
309+ reason : launchdStatus . pid == null ? "launchd_stopped" : "process_exited" ,
310+ launchdStatus,
311+ probeUrl : `http://127.0.0.1:${ opts . port } /api/internal/desktop/ready` ,
312+ } ;
313+ }
314+
315+ const probe = await probeControllerReady (
316+ opts . port ,
317+ opts . probeTimeoutMs ?? 3000 ,
318+ ) ;
319+ if ( ! probe . ok ) {
320+ return {
321+ ok : false ,
322+ reason : probe . reason ,
323+ launchdStatus,
324+ probeUrl : probe . probeUrl ,
325+ probeStatus : probe . status ,
326+ } ;
327+ }
328+
329+ return { ok : true } ;
330+ }
331+
332+ async function waitForControllerStartupValidation ( opts : {
333+ launchd : LaunchdManager ;
334+ label : string ;
335+ port : number ;
336+ timeoutMs ?: number ;
337+ probeTimeoutMs ?: number ;
338+ } ) : Promise < ControllerStartupValidationResult > {
339+ const timeoutMs = opts . timeoutMs ?? 15000 ;
340+ const startedAt = Date . now ( ) ;
341+ let attempt = 0 ;
342+ let lastResult : ControllerStartupValidationResult | null = null ;
343+
344+ while ( Date . now ( ) - startedAt < timeoutMs ) {
345+ lastResult = await validateControllerStartup ( {
346+ launchd : opts . launchd ,
347+ label : opts . label ,
348+ port : opts . port ,
349+ probeTimeoutMs : opts . probeTimeoutMs ,
350+ } ) ;
351+ if ( lastResult . ok ) {
352+ return lastResult ;
353+ }
354+
355+ const delay = Math . min ( 100 + attempt * 100 , 500 ) ;
356+ await new Promise ( ( resolve ) => setTimeout ( resolve , delay ) ) ;
357+ attempt ++ ;
358+ }
359+
360+ return (
361+ lastResult ?? {
362+ ok : false ,
363+ reason : "probe_timeout" ,
364+ launchdStatus : { label : opts . label , plistPath : "" , status : "unknown" } ,
365+ probeUrl : `http://127.0.0.1:${ opts . port } /api/internal/desktop/ready` ,
366+ }
367+ ) ;
191368}
192369
193370// ---------------------------------------------------------------------------
@@ -725,7 +902,7 @@ export async function bootstrapWithLaunchd(
725902 }
726903
727904 // Build plistEnv with final resolved ports
728- const plistEnv : PlistEnv = {
905+ let plistEnv : PlistEnv = {
729906 ...cleanupPlistEnv ,
730907 controllerPort : effectivePorts . controllerPort ,
731908 openclawPort : effectivePorts . openclawPort ,
@@ -759,9 +936,101 @@ export async function bootstrapWithLaunchd(
759936 }
760937 } ;
761938
939+ const formatControllerRecoveryFailure = ( details : {
940+ originalPort : number ;
941+ retryPort ?: number ;
942+ reason : ControllerStartupFailureReason ;
943+ launchdStatus : ServiceStatus ;
944+ probeUrl : string ;
945+ probeStatus ?: number ;
946+ } ) : string => {
947+ const runtimePortsValue = JSON . stringify ( {
948+ controllerPort : details . retryPort ?? effectivePorts . controllerPort ,
949+ openclawPort : effectivePorts . openclawPort ,
950+ webPort : effectivePorts . webPort ,
951+ } ) ;
952+
953+ return [
954+ "Controller startup recovery failed" ,
955+ `originalPort=${ details . originalPort } ` ,
956+ details . retryPort != null ? `retryPort=${ details . retryPort } ` : null ,
957+ `reason=${ details . reason } ` ,
958+ `launchdStatus=${ details . launchdStatus . status } ` ,
959+ `launchdPid=${ details . launchdStatus . pid ?? "none" } ` ,
960+ details . probeStatus != null ? `probeStatus=${ details . probeStatus } ` : null ,
961+ `finalProbeUrl=${ details . probeUrl } ` ,
962+ `runtimePortsValue=${ runtimePortsValue } ` ,
963+ ]
964+ . filter ( Boolean )
965+ . join ( " " ) ;
966+ } ;
967+
968+ const validateOrRecoverController = async ( ) : Promise < void > => {
969+ const originalPort = effectivePorts . controllerPort ;
970+ const validation = await waitForControllerStartupValidation ( {
971+ launchd,
972+ label : labels . controller ,
973+ port : effectivePorts . controllerPort ,
974+ timeoutMs : env . controllerStartupValidationTimeoutMs ?? 15000 ,
975+ probeTimeoutMs : 3000 ,
976+ } ) ;
977+
978+ if ( validation . ok ) {
979+ return ;
980+ }
981+
982+ console . warn (
983+ `[bootstrap] controller post-start validation failed originalPort=${ originalPort } reason=${ validation . reason } launchdStatus=${ validation . launchdStatus . status } launchdPid=${ validation . launchdStatus . pid ?? "none" } probeUrl=${ validation . probeUrl } ${ validation . probeStatus != null ? ` probeStatus=${ validation . probeStatus } ` : "" } ` ,
984+ ) ;
985+
986+ await launchd
987+ . bootoutAndWaitForExit ( labels . controller , 5000 )
988+ . catch ( ( ) => { } ) ;
989+
990+ const retryStartPort = Math . min ( originalPort + 1 , 65535 ) ;
991+ const retryPort = await findFreePort ( retryStartPort ) ;
992+ effectivePorts . controllerPort = retryPort ;
993+ plistEnv = {
994+ ...plistEnv ,
995+ controllerPort : retryPort ,
996+ } ;
997+
998+ console . warn (
999+ `[bootstrap] retrying controller startup originalPort=${ originalPort } retryPort=${ retryPort } ` ,
1000+ ) ;
1001+
1002+ const retryPlist = generatePlist ( "controller" , plistEnv ) ;
1003+ await launchd . installService ( labels . controller , retryPlist ) ;
1004+ await launchd . startService ( labels . controller ) ;
1005+ await ensureRunning ( labels . controller , "controller" ) ;
1006+
1007+ const retryValidation = await waitForControllerStartupValidation ( {
1008+ launchd,
1009+ label : labels . controller ,
1010+ port : retryPort ,
1011+ timeoutMs : env . controllerStartupValidationTimeoutMs ?? 15000 ,
1012+ probeTimeoutMs : 3000 ,
1013+ } ) ;
1014+ if ( retryValidation . ok ) {
1015+ return ;
1016+ }
1017+
1018+ const message = formatControllerRecoveryFailure ( {
1019+ originalPort,
1020+ retryPort,
1021+ reason : retryValidation . reason ,
1022+ launchdStatus : retryValidation . launchdStatus ,
1023+ probeUrl : retryValidation . probeUrl ,
1024+ probeStatus : retryValidation . probeStatus ,
1025+ } ) ;
1026+ console . error ( `[bootstrap] ${ message } ` ) ;
1027+ throw new Error ( message ) ;
1028+ } ;
1029+
7621030 if ( ! controllerHealthy ) {
7631031 await ensureService ( labels . controller , "controller" ) ;
7641032 await ensureRunning ( labels . controller , "controller" ) ;
1033+ await validateOrRecoverController ( ) ;
7651034 } else {
7661035 console . log ( "[bootstrap] controller already healthy, skipping" ) ;
7671036 }
0 commit comments