Skip to content

Commit 008ac5c

Browse files
juliusmarmingecursoragentcodex
authored
Cache provider status and gate desktop startup (#1962)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 9dcea68 commit 008ac5c

18 files changed

+1010
-91
lines changed

apps/desktop/src/backendReadiness.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from "./backendReadiness";
88

99
describe("waitForHttpReady", () => {
10-
it("returns once the backend reports a successful session endpoint", async () => {
10+
it("returns once the backend serves the requested readiness path", async () => {
1111
const fetchImpl = vi
1212
.fn<typeof fetch>()
1313
.mockResolvedValueOnce(new Response(null, { status: 503 }))
@@ -20,6 +20,11 @@ describe("waitForHttpReady", () => {
2020
});
2121

2222
expect(fetchImpl).toHaveBeenCalledTimes(2);
23+
expect(fetchImpl).toHaveBeenNthCalledWith(
24+
1,
25+
"http://127.0.0.1:3773/",
26+
expect.objectContaining({ redirect: "manual" }),
27+
);
2328
});
2429

2530
it("retries after a readiness request stalls past the per-request timeout", async () => {
@@ -80,4 +85,30 @@ describe("waitForHttpReady", () => {
8085
expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true);
8186
expect(isBackendReadinessAborted(new Error("nope"))).toBe(false);
8287
});
88+
89+
it("supports custom readiness predicates", async () => {
90+
const fetchImpl = vi
91+
.fn<typeof fetch>()
92+
.mockResolvedValueOnce(new Response(null, { status: 200 }))
93+
.mockResolvedValueOnce(new Response(null, { status: 204 }));
94+
95+
await waitForHttpReady("http://127.0.0.1:3773", {
96+
fetchImpl,
97+
timeoutMs: 1_000,
98+
intervalMs: 0,
99+
path: "/api/healthz",
100+
isReady: (response) => response.status === 204,
101+
});
102+
103+
expect(fetchImpl).toHaveBeenNthCalledWith(
104+
1,
105+
"http://127.0.0.1:3773/api/healthz",
106+
expect.objectContaining({ redirect: "manual" }),
107+
);
108+
expect(fetchImpl).toHaveBeenNthCalledWith(
109+
2,
110+
"http://127.0.0.1:3773/api/healthz",
111+
expect.objectContaining({ redirect: "manual" }),
112+
);
113+
});
83114
});

apps/desktop/src/backendReadiness.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export interface WaitForHttpReadyOptions {
44
readonly requestTimeoutMs?: number;
55
readonly fetchImpl?: typeof fetch;
66
readonly signal?: AbortSignal;
7+
readonly path?: string;
8+
readonly isReady?: (response: Response) => boolean;
79
}
810

911
const DEFAULT_TIMEOUT_MS = 30_000;
@@ -57,6 +59,8 @@ export async function waitForHttpReady(
5759
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
5860
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
5961
const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
62+
const readinessPath = options?.path ?? "/";
63+
const isReady = options?.isReady ?? ((response: Response) => response.ok);
6064
const deadline = Date.now() + timeoutMs;
6165

6266
for (;;) {
@@ -74,11 +78,11 @@ export async function waitForHttpReady(
7478
signal?.addEventListener("abort", abortRequest, { once: true });
7579

7680
try {
77-
const response = await fetchImpl(`${baseUrl}/api/auth/session`, {
81+
const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), {
7882
redirect: "manual",
7983
signal: requestController.signal,
8084
});
81-
if (response.ok) {
85+
if (isReady(response)) {
8286
return;
8387
}
8488
} catch (error) {

apps/desktop/src/main.ts

Lines changed: 136 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import { showDesktopConfirmDialog } from "./confirmDialog";
5555
import { resolveDesktopServerExposure } from "./serverExposure";
5656
import { syncShellEnvironment } from "./syncShellEnvironment";
5757
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
58+
import { ServerListeningDetector } from "./serverListeningDetector";
5859
import {
5960
createInitialDesktopUpdateState,
6061
reduceDesktopUpdateStateOnCheckFailure,
@@ -144,6 +145,8 @@ let backendWsUrl = "";
144145
let backendEndpointUrl: string | null = null;
145146
let backendAdvertisedHost: string | null = null;
146147
let backendReadinessAbortController: AbortController | null = null;
148+
let backendInitialWindowOpenInFlight: Promise<void> | null = null;
149+
let backendListeningDetector: ServerListeningDetector | null = null;
147150
let restartAttempt = 0;
148151
let restartTimer: ReturnType<typeof setTimeout> | null = null;
149152
let isQuitting = false;
@@ -362,13 +365,17 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null {
362365
return null;
363366
}
364367

365-
async function waitForBackendHttpReady(baseUrl: string): Promise<void> {
368+
async function waitForBackendHttpReady(
369+
baseUrl: string,
370+
options?: Parameters<typeof waitForHttpReady>[1],
371+
): Promise<void> {
366372
cancelBackendReadinessWait();
367373
const controller = new AbortController();
368374
backendReadinessAbortController = controller;
369375

370376
try {
371377
await waitForHttpReady(baseUrl, {
378+
...options,
372379
signal: controller.signal,
373380
});
374381
} finally {
@@ -383,6 +390,88 @@ function cancelBackendReadinessWait(): void {
383390
backendReadinessAbortController = null;
384391
}
385392

393+
async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {
394+
const httpReadyPromise = waitForBackendHttpReady(baseUrl, {
395+
timeoutMs: 60_000,
396+
});
397+
const listeningPromise = backendListeningDetector?.promise;
398+
399+
if (!listeningPromise) {
400+
await httpReadyPromise;
401+
return "http";
402+
}
403+
404+
return await new Promise<"listening" | "http">((resolve, reject) => {
405+
let settled = false;
406+
407+
const settleResolve = (source: "listening" | "http") => {
408+
if (settled) {
409+
return;
410+
}
411+
settled = true;
412+
if (source === "listening") {
413+
cancelBackendReadinessWait();
414+
}
415+
resolve(source);
416+
};
417+
418+
const settleReject = (error: unknown) => {
419+
if (settled) {
420+
return;
421+
}
422+
settled = true;
423+
reject(error);
424+
};
425+
426+
listeningPromise.then(
427+
() => settleResolve("listening"),
428+
(error) => settleReject(error),
429+
);
430+
httpReadyPromise.then(
431+
() => settleResolve("http"),
432+
(error) => {
433+
if (settled && isBackendReadinessAborted(error)) {
434+
return;
435+
}
436+
settleReject(error);
437+
},
438+
);
439+
});
440+
}
441+
442+
function ensureInitialBackendWindowOpen(): void {
443+
const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
444+
if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) {
445+
return;
446+
}
447+
448+
const nextOpen = waitForBackendWindowReady(backendHttpUrl)
449+
.then((source) => {
450+
writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
451+
if (mainWindow ?? BrowserWindow.getAllWindows()[0]) {
452+
return;
453+
}
454+
mainWindow = createWindow();
455+
writeDesktopLogHeader("bootstrap main window created");
456+
})
457+
.catch((error) => {
458+
if (isBackendReadinessAborted(error)) {
459+
return;
460+
}
461+
writeDesktopLogHeader(
462+
`bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
463+
);
464+
console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error);
465+
})
466+
.finally(() => {
467+
if (backendInitialWindowOpenInFlight === nextOpen) {
468+
backendInitialWindowOpenInFlight = null;
469+
}
470+
});
471+
472+
backendInitialWindowOpenInFlight = nextOpen;
473+
}
474+
386475
function writeDesktopStreamChunk(
387476
streamName: "stdout" | "stderr",
388477
chunk: unknown,
@@ -460,14 +549,16 @@ function initializePackagedLogging(): void {
460549
}
461550

462551
function captureBackendOutput(child: ChildProcess.ChildProcess): void {
463-
if (!app.isPackaged || backendLogSink === null) return;
464-
const writeChunk = (chunk: unknown): void => {
465-
if (!backendLogSink) return;
466-
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
467-
backendLogSink.write(buffer);
552+
const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => {
553+
stream?.on("data", (chunk: unknown) => {
554+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
555+
backendLogSink?.write(buffer);
556+
backendListeningDetector?.push(buffer);
557+
});
468558
};
469-
child.stdout?.on("data", writeChunk);
470-
child.stderr?.on("data", writeChunk);
559+
560+
attachStream(child.stdout);
561+
attachStream(child.stderr);
471562
}
472563

473564
initializePackagedLogging();
@@ -1222,7 +1313,7 @@ function startBackend(): void {
12221313
return;
12231314
}
12241315

1225-
const captureBackendLogs = app.isPackaged && backendLogSink !== null;
1316+
const captureBackendLogs = !isDevelopment;
12261317
const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
12271318
cwd: resolveBackendCwd(),
12281319
// In Electron main, process.execPath points to the Electron binary.
@@ -1259,6 +1350,8 @@ function startBackend(): void {
12591350
scheduleBackendRestart("missing desktop bootstrap pipe");
12601351
return;
12611352
}
1353+
const listeningDetector = new ServerListeningDetector();
1354+
backendListeningDetector = listeningDetector;
12621355
backendProcess = child;
12631356
let backendSessionClosed = false;
12641357
const closeBackendSession = (details: string) => {
@@ -1277,6 +1370,10 @@ function startBackend(): void {
12771370
});
12781371

12791372
child.on("error", (error) => {
1373+
if (backendListeningDetector === listeningDetector) {
1374+
listeningDetector.fail(error);
1375+
backendListeningDetector = null;
1376+
}
12801377
const wasExpected = expectedBackendExitChildren.has(child);
12811378
if (backendProcess === child) {
12821379
backendProcess = null;
@@ -1289,6 +1386,14 @@ function startBackend(): void {
12891386
});
12901387

12911388
child.on("exit", (code, signal) => {
1389+
if (backendListeningDetector === listeningDetector) {
1390+
listeningDetector.fail(
1391+
new Error(
1392+
`backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`,
1393+
),
1394+
);
1395+
backendListeningDetector = null;
1396+
}
12921397
const wasExpected = expectedBackendExitChildren.has(child);
12931398
if (backendProcess === child) {
12941399
backendProcess = null;
@@ -1300,10 +1405,13 @@ function startBackend(): void {
13001405
const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
13011406
scheduleBackendRestart(reason);
13021407
});
1408+
1409+
ensureInitialBackendWindowOpen();
13031410
}
13041411

13051412
function stopBackend(): void {
13061413
cancelBackendReadinessWait();
1414+
backendListeningDetector = null;
13071415
if (restartTimer) {
13081416
clearTimeout(restartTimer);
13091417
restartTimer = null;
@@ -1705,7 +1813,7 @@ function createWindow(): BrowserWindow {
17051813
height: 780,
17061814
minWidth: 840,
17071815
minHeight: 620,
1708-
show: isDevelopment,
1816+
show: false,
17091817
autoHideMenuBar: true,
17101818
backgroundColor: getInitialWindowBackgroundColor(),
17111819
...getIconOption(),
@@ -1779,20 +1887,23 @@ function createWindow(): BrowserWindow {
17791887
window.setTitle(APP_DISPLAY_NAME);
17801888
emitUpdateState();
17811889
});
1782-
if (!isDevelopment) {
1783-
window.once("ready-to-show", () => {
1784-
revealWindow(window);
1785-
});
1786-
}
1890+
1891+
let initialRevealScheduled = false;
1892+
const revealInitialWindow = () => {
1893+
if (initialRevealScheduled) {
1894+
return;
1895+
}
1896+
initialRevealScheduled = true;
1897+
revealWindow(window);
1898+
};
1899+
1900+
window.once("ready-to-show", revealInitialWindow);
17871901

17881902
if (isDevelopment) {
17891903
void window.loadURL(resolveDesktopDevServerUrl());
17901904
window.webContents.openDevTools({ mode: "detach" });
1791-
setImmediate(() => {
1792-
revealWindow(window);
1793-
});
17941905
} else {
1795-
void window.loadURL(resolveDesktopWindowUrl());
1906+
void window.loadURL(backendHttpUrl);
17961907
}
17971908

17981909
window.on("closed", () => {
@@ -1804,14 +1915,6 @@ function createWindow(): BrowserWindow {
18041915
return window;
18051916
}
18061917

1807-
function resolveDesktopWindowUrl(): string {
1808-
if (backendHttpUrl) {
1809-
return backendHttpUrl;
1810-
}
1811-
1812-
return `${DESKTOP_SCHEME}://app`;
1813-
}
1814-
18151918
// Override Electron's userData path before the `ready` event so that
18161919
// Chromium session data uses a filesystem-friendly directory name.
18171920
// Must be called synchronously at the top level — before `app.whenReady()`.
@@ -1885,10 +1988,7 @@ async function bootstrap(): Promise<void> {
18851988
return;
18861989
}
18871990

1888-
await waitForBackendHttpReady(backendHttpUrl);
1889-
writeDesktopLogHeader("bootstrap backend ready");
1890-
mainWindow = createWindow();
1891-
writeDesktopLogHeader("bootstrap main window created");
1991+
ensureInitialBackendWindowOpen();
18921992
}
18931993

18941994
app.on("before-quit", () => {
@@ -1922,7 +2022,11 @@ app
19222022
revealWindow(existingWindow);
19232023
return;
19242024
}
1925-
mainWindow = createWindow();
2025+
if (isDevelopment) {
2026+
mainWindow = createWindow();
2027+
return;
2028+
}
2029+
ensureInitialBackendWindowOpen();
19262030
});
19272031
})
19282032
.catch((error) => {

0 commit comments

Comments
 (0)