Skip to content

Commit d837304

Browse files
authored
fix(desktop): retry controller startup after readiness failure (#750)
1 parent d055c1a commit d837304

5 files changed

Lines changed: 1782 additions & 1051 deletions

File tree

apps/desktop/main/services/launchd-bootstrap.ts

Lines changed: 285 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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";
2731
import { type PlistEnv, generatePlist } from "./plist-generator";
2832

2933
export 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

9096
export 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
}

tests/desktop/data-directory-runtime.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { join } from "node:path";
2424
import { afterEach, beforeEach, describe, expect, it } from "vitest";
2525

2626
const IS_MACOS = process.platform === "darwin";
27+
const RUN_REAL_LAUNCHD_TESTS = process.env.RUN_REAL_LAUNCHD_TESTS === "1";
2728
const NODE_BIN = process.execPath;
2829
const UID = IS_MACOS
2930
? execFileSync("id", ["-u"], { encoding: "utf8" }).trim()
@@ -281,7 +282,7 @@ describe("runtime-config NEXU_HOME resolution chain", () => {
281282
// 3. REAL launchd: start service, verify ACTUAL env from launchctl print
282283
// =========================================================================
283284

284-
describe.skipIf(!IS_MACOS)(
285+
describe.skipIf(!IS_MACOS || !RUN_REAL_LAUNCHD_TESTS)(
285286
"real launchd: controller env vars at runtime",
286287
() => {
287288
const LABEL = `io.nexu.test.datadir.${process.pid}`;

0 commit comments

Comments
 (0)