Skip to content

Commit 31dddda

Browse files
committed
perf(electron): bound lightweight readiness polling
1 parent 50cb187 commit 31dddda

5 files changed

Lines changed: 150 additions & 53 deletions

File tree

electron/lib/serverReadiness.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Pure helpers for polling the embedded or remote OmniRoute server without
3+
* importing the Electron main process.
4+
*/
5+
6+
const DEFAULT_TIMEOUT_MS = 180000;
7+
const DEFAULT_REQUEST_TIMEOUT_MS = 2000;
8+
const DEFAULT_POLL_INTERVAL_MS = 500;
9+
10+
function buildReadinessUrl(baseUrl) {
11+
return `${baseUrl.replace(/\/+$/, "")}/api/health/ping`;
12+
}
13+
14+
async function waitForServer(url, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) {
15+
const {
16+
fetchFn = globalThis.fetch,
17+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
18+
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
19+
nowFn = Date.now,
20+
sleepFn = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
21+
warnFn = console.warn,
22+
} = options;
23+
24+
const startedAt = nowFn();
25+
while (nowFn() - startedAt < timeoutMs) {
26+
const remainingMs = timeoutMs - (nowFn() - startedAt);
27+
const attemptTimeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs));
28+
const controller = new AbortController();
29+
let timeoutId;
30+
31+
try {
32+
const response = await Promise.race([
33+
fetchFn(url, { signal: controller.signal }),
34+
new Promise((resolve) => {
35+
timeoutId = setTimeout(() => {
36+
controller.abort();
37+
resolve(null);
38+
}, attemptTimeoutMs);
39+
}),
40+
]);
41+
42+
if (response?.ok) return true;
43+
} catch {
44+
/* server not ready yet */
45+
} finally {
46+
if (timeoutId !== undefined) clearTimeout(timeoutId);
47+
}
48+
49+
const pollRemainingMs = timeoutMs - (nowFn() - startedAt);
50+
if (pollRemainingMs <= 0) break;
51+
await sleepFn(Math.min(pollIntervalMs, pollRemainingMs));
52+
}
53+
54+
warnFn("[Electron] Server readiness timeout — showing window anyway");
55+
return false;
56+
}
57+
58+
module.exports = {
59+
buildReadinessUrl,
60+
waitForServer,
61+
};

electron/main.js

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const { resolveServerEntry } = require("./lib/resolveServerEntry");
3939
const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper");
4040
const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl");
4141
const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences");
42+
const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness");
4243

4344
// ── Single Instance Lock ───────────────────────────────────
4445
const gotTheLock = app.requestSingleInstanceLock();
@@ -86,6 +87,7 @@ let remoteServerUrl = resolveRemoteServerUrl({
8687
});
8788

8889
const getServerUrl = () => remoteServerUrl || `http://localhost:${serverPort}`;
90+
const getServerReadinessUrl = () => buildReadinessUrl(getServerUrl());
8991

9092
function resolveNodeExecutable(env = process.env) {
9193
// #1081: Ensure Next.js standalone runs using Electron's Node runtime
@@ -185,26 +187,6 @@ function sendToRenderer(channel, data) {
185187
}
186188
}
187189

188-
// ── Helper: Wait for server readiness (#1, #10) ────────────
189-
// Default raised to 180s: the first launch after an upgrade can run long DB
190-
// migrations, during which the server accepts the TCP connection but holds the
191-
// HTTP response until handlers initialize. The previous 30s cap timed out and
192-
// left the window stuck on a hanging connection (#2460).
193-
async function waitForServer(url, timeoutMs = 180000) {
194-
const start = Date.now();
195-
while (Date.now() - start < timeoutMs) {
196-
try {
197-
const res = await fetch(url);
198-
if (res.ok || res.status < 500) return true;
199-
} catch {
200-
/* server not ready yet */
201-
}
202-
await new Promise((r) => setTimeout(r, 500));
203-
}
204-
console.warn("[Electron] Server readiness timeout — showing window anyway");
205-
return false;
206-
}
207-
208190
// ── Helper: Wait for server process exit with timeout (#2) ─
209191
async function waitForServerExit(proc, timeoutMs = 5000) {
210192
if (!proc) return;
@@ -533,7 +515,7 @@ async function changePort(newPort) {
533515

534516
// Start server on new port
535517
startNextServer();
536-
await waitForServer(getServerUrl());
518+
await waitForServer(getServerReadinessUrl());
537519

538520
// Reload window and update tray
539521
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -603,7 +585,7 @@ async function setRemoteServerUrl(nextUrl) {
603585

604586
startNextServer();
605587
try {
606-
await waitForServer(`${getServerUrl()}/api/monitoring/health`);
588+
await waitForServer(getServerReadinessUrl());
607589
} catch (err) {
608590
console.warn("[Electron] Server did not become ready after remote-server change:", err.message);
609591
}
@@ -935,7 +917,7 @@ function setupIpcHandlers() {
935917
stopNextServer();
936918
await waitForServerExit(serverToStop);
937919
startNextServer();
938-
await waitForServer(getServerUrl());
920+
await waitForServer(getServerReadinessUrl());
939921
return { success: true };
940922
});
941923

@@ -1078,8 +1060,8 @@ app.whenReady().then(async () => {
10781060
startNextServer();
10791061
let serverReady = true;
10801062
if (!isDev) {
1081-
// Probe the auth-exempt health endpoint (not the root URL, which may redirect).
1082-
serverReady = await waitForServer(`${getServerUrl()}/api/monitoring/health`);
1063+
// Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state.
1064+
serverReady = await waitForServer(getServerReadinessUrl());
10831065
}
10841066

10851067
if (isHeadless) {
@@ -1095,7 +1077,7 @@ app.whenReady().then(async () => {
10951077
// If readiness timed out (e.g. very long first-launch migrations), don't leave the
10961078
// window stuck on a hanging connection — keep polling and reload once it responds (#2460).
10971079
if (!isDev && !serverReady && !isHeadless) {
1098-
void waitForServer(`${getServerUrl()}/api/monitoring/health`, 300000).then((ready) => {
1080+
void waitForServer(getServerReadinessUrl(), 300000).then((ready) => {
10991081
if (ready && mainWindow && !mainWindow.isDestroyed()) {
11001082
mainWindow.loadURL(getServerUrl());
11011083
}

electron/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"lib/resolveNodeHelper.js",
6767
"lib/resolveRemoteServerUrl.js",
6868
"lib/remoteServerPreferences.js",
69+
"lib/serverReadiness.js",
6970
"assets/remoteServerPrompt.html",
7071
"package.json",
7172
"node_modules/**/*"

tests/unit/electron-main.test.ts

Lines changed: 23 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { join } from "node:path";
1919
import { createRequire } from "node:module";
2020

2121
const require = createRequire(import.meta.url);
22+
const { waitForServer } = require("../../electron/lib/serverReadiness");
2223

2324
function raceDelays(firstMs, secondMs) {
2425
return new Promise((resolve) => {
@@ -272,23 +273,12 @@ describe("Server Port Management", () => {
272273

273274
describe("Server Readiness Logic", () => {
274275
it("waitForServer should timeout and return false", async () => {
275-
// Simulate the polling logic with an always-failing fetch
276-
async function waitForServer(url, timeoutMs = 100) {
277-
const start = Date.now();
278-
while (Date.now() - start < timeoutMs) {
279-
try {
280-
const res = await fetch(url);
281-
if (res.ok || res.status < 500) return true;
282-
} catch {
283-
/* not ready */
284-
}
285-
await new Promise((r) => setTimeout(r, 30));
286-
}
287-
return false;
288-
}
289-
290-
// Should timeout immediately since nothing is running on that port
291-
const result = await waitForServer("http://localhost:59999", 100);
276+
const result = await waitForServer("http://localhost:59999/api/health/ping", 20, {
277+
fetchFn: async () => ({ ok: false }),
278+
pollIntervalMs: 1,
279+
requestTimeoutMs: 5,
280+
warnFn: () => {},
281+
});
292282
assert.equal(result, false);
293283
});
294284

@@ -302,18 +292,20 @@ describe("Server Readiness Logic", () => {
302292
serverUp = true;
303293
}, 60);
304294

305-
async function waitForServer(_url, timeoutMs) {
306-
const start = Date.now();
307-
while (Date.now() - start < timeoutMs) {
308-
if (serverUp) return true;
309-
await new Promise((r) => setTimeout(r, 15));
310-
}
311-
return false;
312-
}
295+
const readinessOptions = {
296+
fetchFn: async () => ({ ok: serverUp }),
297+
pollIntervalMs: 5,
298+
requestTimeoutMs: 5,
299+
warnFn: () => {},
300+
};
313301

314302
try {
315303
// Initial probe with a short budget times out (server not up yet).
316-
const initialReady = await waitForServer("http://localhost/api/monitoring/health", 20);
304+
const initialReady = await waitForServer(
305+
"http://localhost/api/health/ping",
306+
20,
307+
readinessOptions
308+
);
317309
assert.equal(initialReady, false);
318310

319311
let reloaded = false;
@@ -325,7 +317,11 @@ describe("Server Readiness Logic", () => {
325317
};
326318

327319
// Background retry with a generous budget should succeed and reload the window.
328-
const retryReady = await waitForServer("http://localhost/api/monitoring/health", 5000);
320+
const retryReady = await waitForServer(
321+
"http://localhost/api/health/ping",
322+
5000,
323+
readinessOptions
324+
);
329325
if (retryReady && mainWindow && !mainWindow.isDestroyed()) {
330326
mainWindow.loadURL("http://localhost");
331327
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import assert from "node:assert/strict";
2+
import { createRequire } from "node:module";
3+
import { describe, it } from "node:test";
4+
5+
const require = createRequire(import.meta.url);
6+
const { buildReadinessUrl, waitForServer } = require("../../electron/lib/serverReadiness");
7+
8+
describe("Electron server readiness", () => {
9+
it("builds the lightweight readiness URL from local and remote base URLs", () => {
10+
assert.equal(
11+
buildReadinessUrl("http://localhost:20128"),
12+
"http://localhost:20128/api/health/ping"
13+
);
14+
assert.equal(
15+
buildReadinessUrl("https://omniroute.example.com/"),
16+
"https://omniroute.example.com/api/health/ping"
17+
);
18+
});
19+
20+
it("accepts only a successful HTTP response", async () => {
21+
let attempts = 0;
22+
const ready = await waitForServer("http://localhost/api/health/ping", 100, {
23+
fetchFn: async () => ({ ok: ++attempts === 2 }),
24+
pollIntervalMs: 1,
25+
requestTimeoutMs: 20,
26+
warnFn: () => {},
27+
});
28+
29+
assert.equal(ready, true);
30+
assert.equal(attempts, 2);
31+
});
32+
33+
it("returns false after repeated unsuccessful responses", async () => {
34+
const ready = await waitForServer("http://localhost/api/health/ping", 20, {
35+
fetchFn: async () => ({ ok: false }),
36+
pollIntervalMs: 1,
37+
requestTimeoutMs: 5,
38+
warnFn: () => {},
39+
});
40+
41+
assert.equal(ready, false);
42+
});
43+
44+
it("bounds a stalled request by both the attempt and overall deadlines", async () => {
45+
const startedAt = Date.now();
46+
const ready = await waitForServer("http://localhost/api/health/ping", 35, {
47+
fetchFn: () => new Promise(() => {}),
48+
pollIntervalMs: 1,
49+
requestTimeoutMs: 10,
50+
warnFn: () => {},
51+
});
52+
const elapsedMs = Date.now() - startedAt;
53+
54+
assert.equal(ready, false);
55+
assert.ok(elapsedMs < 150, `stalled readiness probe took ${elapsedMs}ms`);
56+
});
57+
});

0 commit comments

Comments
 (0)