From afed1d79efd7004cbcf64947fb8c51f3290692c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 17 Apr 2026 11:00:57 -0700 Subject: [PATCH] Extract backend startup readiness race handling - Factor HTTP/listening readiness coordination into a shared helper - Log which signal made the desktop backend ready - Add tests for the startup readiness flow --- .../src/backendStartupReadiness.test.ts | 58 ++++++++++++++++++ apps/desktop/src/backendStartupReadiness.ts | 56 ++++++++++++++++++ apps/desktop/src/main.ts | 59 ++++--------------- 3 files changed, 125 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/backendStartupReadiness.test.ts create mode 100644 apps/desktop/src/backendStartupReadiness.ts diff --git a/apps/desktop/src/backendStartupReadiness.test.ts b/apps/desktop/src/backendStartupReadiness.test.ts new file mode 100644 index 0000000000..6d1df3d3ec --- /dev/null +++ b/apps/desktop/src/backendStartupReadiness.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; + +import { BackendReadinessAbortedError } from "./backendReadiness.ts"; +import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; + +describe("waitForBackendStartupReady", () => { + it("falls back to the HTTP probe when no listening signal exists", async () => { + const waitForHttpReady = vi.fn<() => Promise>().mockResolvedValue(undefined); + const cancelHttpWait = vi.fn(); + + await expect( + waitForBackendStartupReady({ + waitForHttpReady, + cancelHttpWait, + }), + ).resolves.toBe("http"); + + expect(waitForHttpReady).toHaveBeenCalledTimes(1); + expect(cancelHttpWait).not.toHaveBeenCalled(); + }); + + it("uses the listening signal and cancels the HTTP probe", async () => { + let rejectHttpWait: ((error: unknown) => void) | null = null; + const waitForHttpReady = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectHttpWait = reject; + }), + ); + const cancelHttpWait = vi.fn(() => { + rejectHttpWait?.(new BackendReadinessAbortedError()); + }); + + await expect( + waitForBackendStartupReady({ + listeningPromise: Promise.resolve(), + waitForHttpReady, + cancelHttpWait, + }), + ).resolves.toBe("listening"); + + expect(waitForHttpReady).toHaveBeenCalledTimes(1); + expect(cancelHttpWait).toHaveBeenCalledTimes(1); + }); + + it("rejects when the listening signal fails before HTTP readiness", async () => { + const error = new Error("backend exited"); + const waitForHttpReady = vi.fn(() => new Promise(() => {})); + + await expect( + waitForBackendStartupReady({ + listeningPromise: Promise.reject(error), + waitForHttpReady, + cancelHttpWait: vi.fn(), + }), + ).rejects.toBe(error); + }); +}); diff --git a/apps/desktop/src/backendStartupReadiness.ts b/apps/desktop/src/backendStartupReadiness.ts new file mode 100644 index 0000000000..37a977431d --- /dev/null +++ b/apps/desktop/src/backendStartupReadiness.ts @@ -0,0 +1,56 @@ +import { isBackendReadinessAborted } from "./backendReadiness.ts"; + +export interface WaitForBackendStartupReadyOptions { + readonly listeningPromise?: Promise | null; + readonly waitForHttpReady: () => Promise; + readonly cancelHttpWait: () => void; +} + +export async function waitForBackendStartupReady( + options: WaitForBackendStartupReadyOptions, +): Promise<"listening" | "http"> { + const httpReadyPromise = options.waitForHttpReady(); + const listeningPromise = options.listeningPromise; + + if (!listeningPromise) { + await httpReadyPromise; + return "http"; + } + + return await new Promise<"listening" | "http">((resolve, reject) => { + let settled = false; + + const settleResolve = (source: "listening" | "http") => { + if (settled) { + return; + } + settled = true; + if (source === "listening") { + options.cancelHttpWait(); + } + resolve(source); + }; + + const settleReject = (error: unknown) => { + if (settled) { + return; + } + settled = true; + reject(error); + }; + + listeningPromise.then( + () => settleResolve("listening"), + (error) => settleReject(error), + ); + httpReadyPromise.then( + () => settleResolve("http"), + (error) => { + if (settled && isBackendReadinessAborted(error)) { + return; + } + settleReject(error); + }, + ); + }); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3ef80f5c0a..529ed55d03 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -57,6 +57,7 @@ import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness. import { showDesktopConfirmDialog } from "./confirmDialog.ts"; import { resolveDesktopServerExposure } from "./serverExposure.ts"; import { syncShellEnvironment } from "./syncShellEnvironment.ts"; +import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; import { ServerListeningDetector } from "./serverListeningDetector.ts"; @@ -459,51 +460,13 @@ function cancelBackendReadinessWait(): void { } async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { - const httpReadyPromise = waitForBackendHttpReady(baseUrl, { - timeoutMs: 60_000, - }); - const listeningPromise = backendListeningDetector?.promise; - - if (!listeningPromise) { - await httpReadyPromise; - return "http"; - } - - return await new Promise<"listening" | "http">((resolve, reject) => { - let settled = false; - - const settleResolve = (source: "listening" | "http") => { - if (settled) { - return; - } - settled = true; - if (source === "listening") { - cancelBackendReadinessWait(); - } - resolve(source); - }; - - const settleReject = (error: unknown) => { - if (settled) { - return; - } - settled = true; - reject(error); - }; - - listeningPromise.then( - () => settleResolve("listening"), - (error) => settleReject(error), - ); - httpReadyPromise.then( - () => settleResolve("http"), - (error) => { - if (settled && isBackendReadinessAborted(error)) { - return; - } - settleReject(error); - }, - ); + return await waitForBackendStartupReady({ + listeningPromise: backendListeningDetector?.promise ?? null, + waitForHttpReady: () => + waitForBackendHttpReady(baseUrl, { + timeoutMs: 60_000, + }), + cancelHttpWait: cancelBackendReadinessWait, }); } @@ -2119,9 +2082,9 @@ async function bootstrap(): Promise { if (isDevelopment) { mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); - void waitForBackendHttpReady(backendHttpUrl) - .then(() => { - writeDesktopLogHeader("bootstrap backend ready"); + void waitForBackendWindowReady(backendHttpUrl) + .then((source) => { + writeDesktopLogHeader(`bootstrap backend ready source=${source}`); }) .catch((error) => { if (isBackendReadinessAborted(error)) {