| author | |
| committer | |
| log | 40009735eea88b2266cf7d112217e85a688b3e63 |
| tree | a32d9f7215a3f7b29b8f1a2ed271f42a8ef70254 |
| parent | df9d34004f45d529ae2fb47a590776e75578a645 |
| signature | Signed by PGP key B5690EEEBB952194 |
3 files changed, 125 insertions(+), 48 deletions(-)
apps/desktop/src/backendStartupReadiness.test.ts created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | import { describe, expect, it, vi } from "vitest"; | ||
| 2 | |||
| 3 | import { BackendReadinessAbortedError } from "./backendReadiness.ts"; | ||
| 4 | import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; | ||
| 5 | |||
| 6 | describe("waitForBackendStartupReady", () => { | ||
| 7 | it("falls back to the HTTP probe when no listening signal exists", async () => { | ||
| 8 | const waitForHttpReady = vi.fn<() => Promise<void>>().mockResolvedValue(undefined); | ||
| 9 | const cancelHttpWait = vi.fn(); | ||
| 10 | |||
| 11 | await expect( | ||
| 12 | waitForBackendStartupReady({ | ||
| 13 | waitForHttpReady, | ||
| 14 | cancelHttpWait, | ||
| 15 | }), | ||
| 16 | ).resolves.toBe("http"); | ||
| 17 | |||
| 18 | expect(waitForHttpReady).toHaveBeenCalledTimes(1); | ||
| 19 | expect(cancelHttpWait).not.toHaveBeenCalled(); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("uses the listening signal and cancels the HTTP probe", async () => { | ||
| 23 | let rejectHttpWait: ((error: unknown) => void) | null = null; | ||
| 24 | const waitForHttpReady = vi.fn( | ||
| 25 | () => | ||
| 26 | new Promise<void>((_resolve, reject) => { | ||
| 27 | rejectHttpWait = reject; | ||
| 28 | }), | ||
| 29 | ); | ||
| 30 | const cancelHttpWait = vi.fn(() => { | ||
| 31 | rejectHttpWait?.(new BackendReadinessAbortedError()); | ||
| 32 | }); | ||
| 33 | |||
| 34 | await expect( | ||
| 35 | waitForBackendStartupReady({ | ||
| 36 | listeningPromise: Promise.resolve(), | ||
| 37 | waitForHttpReady, | ||
| 38 | cancelHttpWait, | ||
| 39 | }), | ||
| 40 | ).resolves.toBe("listening"); | ||
| 41 | |||
| 42 | expect(waitForHttpReady).toHaveBeenCalledTimes(1); | ||
| 43 | expect(cancelHttpWait).toHaveBeenCalledTimes(1); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it("rejects when the listening signal fails before HTTP readiness", async () => { | ||
| 47 | const error = new Error("backend exited"); | ||
| 48 | const waitForHttpReady = vi.fn(() => new Promise<void>(() => {})); | ||
| 49 | |||
| 50 | await expect( | ||
| 51 | waitForBackendStartupReady({ | ||
| 52 | listeningPromise: Promise.reject(error), | ||
| 53 | waitForHttpReady, | ||
| 54 | cancelHttpWait: vi.fn(), | ||
| 55 | }), | ||
| 56 | ).rejects.toBe(error); | ||
| 57 | }); | ||
| 58 | }); | ||
apps/desktop/src/backendStartupReadiness.ts created+56| ... | @@ -0,0 +1,56 @@ | ||
| 1 | import { isBackendReadinessAborted } from "./backendReadiness.ts"; | ||
| 2 | |||
| 3 | export interface WaitForBackendStartupReadyOptions { | ||
| 4 | readonly listeningPromise?: Promise<void> | null; | ||
| 5 | readonly waitForHttpReady: () => Promise<void>; | ||
| 6 | readonly cancelHttpWait: () => void; | ||
| 7 | } | ||
| 8 | |||
| 9 | export async function waitForBackendStartupReady( | ||
| 10 | options: WaitForBackendStartupReadyOptions, | ||
| 11 | ): Promise<"listening" | "http"> { | ||
| 12 | const httpReadyPromise = options.waitForHttpReady(); | ||
| 13 | const listeningPromise = options.listeningPromise; | ||
| 14 | |||
| 15 | if (!listeningPromise) { | ||
| 16 | await httpReadyPromise; | ||
| 17 | return "http"; | ||
| 18 | } | ||
| 19 | |||
| 20 | return await new Promise<"listening" | "http">((resolve, reject) => { | ||
| 21 | let settled = false; | ||
| 22 | |||
| 23 | const settleResolve = (source: "listening" | "http") => { | ||
| 24 | if (settled) { | ||
| 25 | return; | ||
| 26 | } | ||
| 27 | settled = true; | ||
| 28 | if (source === "listening") { | ||
| 29 | options.cancelHttpWait(); | ||
| 30 | } | ||
| 31 | resolve(source); | ||
| 32 | }; | ||
| 33 | |||
| 34 | const settleReject = (error: unknown) => { | ||
| 35 | if (settled) { | ||
| 36 | return; | ||
| 37 | } | ||
| 38 | settled = true; | ||
| 39 | reject(error); | ||
| 40 | }; | ||
| 41 | |||
| 42 | listeningPromise.then( | ||
| 43 | () => settleResolve("listening"), | ||
| 44 | (error) => settleReject(error), | ||
| 45 | ); | ||
| 46 | httpReadyPromise.then( | ||
| 47 | () => settleResolve("http"), | ||
| 48 | (error) => { | ||
| 49 | if (settled && isBackendReadinessAborted(error)) { | ||
| 50 | return; | ||
| 51 | } | ||
| 52 | settleReject(error); | ||
| 53 | }, | ||
| 54 | ); | ||
| 55 | }); | ||
| 56 | } | ||
apps/desktop/src/main.ts+11-48| ... | @@ -57,6 +57,7 @@ import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness. | ... | @@ -57,6 +57,7 @@ import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness. |
| 57 | import { showDesktopConfirmDialog } from "./confirmDialog.ts"; | 57 | import { showDesktopConfirmDialog } from "./confirmDialog.ts"; |
| 58 | import { resolveDesktopServerExposure } from "./serverExposure.ts"; | 58 | import { resolveDesktopServerExposure } from "./serverExposure.ts"; |
| 59 | import { syncShellEnvironment } from "./syncShellEnvironment.ts"; | 59 | import { syncShellEnvironment } from "./syncShellEnvironment.ts"; |
| 60 | import { waitForBackendStartupReady } from "./backendStartupReadiness.ts"; | ||
| 60 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; | 61 | import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts"; |
| 61 | import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; | 62 | import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts"; |
| 62 | import { ServerListeningDetector } from "./serverListeningDetector.ts"; | 63 | import { ServerListeningDetector } from "./serverListeningDetector.ts"; |
| ... | @@ -459,51 +460,13 @@ function cancelBackendReadinessWait(): void { | ... | @@ -459,51 +460,13 @@ function cancelBackendReadinessWait(): void { |
| 459 | } | 460 | } |
| 460 | 461 | ||
| 461 | async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { | 462 | async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> { |
| 462 | const httpReadyPromise = waitForBackendHttpReady(baseUrl, { | 463 | return await waitForBackendStartupReady({ |
| 463 | timeoutMs: 60_000, | 464 | listeningPromise: backendListeningDetector?.promise ?? null, |
| 464 | }); | 465 | waitForHttpReady: () => |
| 465 | const listeningPromise = backendListeningDetector?.promise; | 466 | waitForBackendHttpReady(baseUrl, { |
| 466 | 467 | timeoutMs: 60_000, | |
| 467 | if (!listeningPromise) { | 468 | }), |
| 468 | await httpReadyPromise; | 469 | cancelHttpWait: cancelBackendReadinessWait, |
| 469 | return "http"; | ||
| 470 | } | ||
| 471 | |||
| 472 | return await new Promise<"listening" | "http">((resolve, reject) => { | ||
| 473 | let settled = false; | ||
| 474 | |||
| 475 | const settleResolve = (source: "listening" | "http") => { | ||
| 476 | if (settled) { | ||
| 477 | return; | ||
| 478 | } | ||
| 479 | settled = true; | ||
| 480 | if (source === "listening") { | ||
| 481 | cancelBackendReadinessWait(); | ||
| 482 | } | ||
| 483 | resolve(source); | ||
| 484 | }; | ||
| 485 | |||
| 486 | const settleReject = (error: unknown) => { | ||
| 487 | if (settled) { | ||
| 488 | return; | ||
| 489 | } | ||
| 490 | settled = true; | ||
| 491 | reject(error); | ||
| 492 | }; | ||
| 493 | |||
| 494 | listeningPromise.then( | ||
| 495 | () => settleResolve("listening"), | ||
| 496 | (error) => settleReject(error), | ||
| 497 | ); | ||
| 498 | httpReadyPromise.then( | ||
| 499 | () => settleResolve("http"), | ||
| 500 | (error) => { | ||
| 501 | if (settled && isBackendReadinessAborted(error)) { | ||
| 502 | return; | ||
| 503 | } | ||
| 504 | settleReject(error); | ||
| 505 | }, | ||
| 506 | ); | ||
| 507 | }); | 470 | }); |
| 508 | } | 471 | } |
| 509 | 472 | ||
| ... | @@ -2119,9 +2082,9 @@ async function bootstrap(): Promise<void> { | ... | @@ -2119,9 +2082,9 @@ async function bootstrap(): Promise<void> { |
| 2119 | if (isDevelopment) { | 2082 | if (isDevelopment) { |
| 2120 | mainWindow = createWindow(); | 2083 | mainWindow = createWindow(); |
| 2121 | writeDesktopLogHeader("bootstrap main window created"); | 2084 | writeDesktopLogHeader("bootstrap main window created"); |
| 2122 | void waitForBackendHttpReady(backendHttpUrl) | 2085 | void waitForBackendWindowReady(backendHttpUrl) |
| 2123 | .then(() => { | 2086 | .then((source) => { |
| 2124 | writeDesktopLogHeader("bootstrap backend ready"); | 2087 | writeDesktopLogHeader(`bootstrap backend ready source=${source}`); |
| 2125 | }) | 2088 | }) |
| 2126 | .catch((error) => { | 2089 | .catch((error) => { |
| 2127 | if (isBackendReadinessAborted(error)) { | 2090 | if (isBackendReadinessAborted(error)) { |