authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-04-17 11:09:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-17 11:09:26-07:00
log40009735eea88b2266cf7d112217e85a688b3e63
treea32d9f7215a3f7b29b8f1a2ed271f42a8ef70254
parentdf9d34004f45d529ae2fb47a590776e75578a645
signature Signed by PGP key B5690EEEBB952194

Extract backend startup readiness coordination (#2133)


3 files changed, 125 insertions(+), 48 deletions(-)

apps/desktop/src/backendStartupReadiness.test.ts created+58
...@@ -0,0 +1,58 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { BackendReadinessAbortedError } from "./backendReadiness.ts";
4import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
5
6describe("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 @@
1import { isBackendReadinessAborted } from "./backendReadiness.ts";
2
3export interface WaitForBackendStartupReadyOptions {
4 readonly listeningPromise?: Promise<void> | null;
5 readonly waitForHttpReady: () => Promise<void>;
6 readonly cancelHttpWait: () => void;
7}
8
9export 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.
57import { showDesktopConfirmDialog } from "./confirmDialog.ts";57import { showDesktopConfirmDialog } from "./confirmDialog.ts";
58import { resolveDesktopServerExposure } from "./serverExposure.ts";58import { resolveDesktopServerExposure } from "./serverExposure.ts";
59import { syncShellEnvironment } from "./syncShellEnvironment.ts";59import { syncShellEnvironment } from "./syncShellEnvironment.ts";
60import { waitForBackendStartupReady } from "./backendStartupReadiness.ts";
60import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";61import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
61import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts";62import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts";
62import { ServerListeningDetector } from "./serverListeningDetector.ts";63import { ServerListeningDetector } from "./serverListeningDetector.ts";
...@@ -459,51 +460,13 @@ function cancelBackendReadinessWait(): void {...@@ -459,51 +460,13 @@ function cancelBackendReadinessWait(): void {
459}460}
460461
461async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {462async 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, {
466467 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}
509472
...@@ -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)) {