diff --git a/electron/lib/serverReadiness.js b/electron/lib/serverReadiness.js new file mode 100644 index 0000000000..0ae9eb24b2 --- /dev/null +++ b/electron/lib/serverReadiness.js @@ -0,0 +1,61 @@ +/** + * Pure helpers for polling the embedded or remote OmniRoute server without + * importing the Electron main process. + */ + +const DEFAULT_TIMEOUT_MS = 180000; +const DEFAULT_REQUEST_TIMEOUT_MS = 2000; +const DEFAULT_POLL_INTERVAL_MS = 500; + +function buildReadinessUrl(baseUrl) { + return `${baseUrl.replace(/\/+$/, "")}/api/health/ping`; +} + +async function waitForServer(url, timeoutMs = DEFAULT_TIMEOUT_MS, options = {}) { + const { + fetchFn = globalThis.fetch, + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, + nowFn = Date.now, + sleepFn = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + warnFn = console.warn, + } = options; + + const startedAt = nowFn(); + while (nowFn() - startedAt < timeoutMs) { + const remainingMs = timeoutMs - (nowFn() - startedAt); + const attemptTimeoutMs = Math.max(1, Math.min(requestTimeoutMs, remainingMs)); + const controller = new AbortController(); + let timeoutId; + + try { + const response = await Promise.race([ + fetchFn(url, { signal: controller.signal }), + new Promise((resolve) => { + timeoutId = setTimeout(() => { + controller.abort(); + resolve(null); + }, attemptTimeoutMs); + }), + ]); + + if (response?.ok) return true; + } catch { + /* server not ready yet */ + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } + + const pollRemainingMs = timeoutMs - (nowFn() - startedAt); + if (pollRemainingMs <= 0) break; + await sleepFn(Math.min(pollIntervalMs, pollRemainingMs)); + } + + warnFn("[Electron] Server readiness timeout — showing window anyway"); + return false; +} + +module.exports = { + buildReadinessUrl, + waitForServer, +}; diff --git a/electron/main.js b/electron/main.js index b98692b295..7947ad9cb3 100644 --- a/electron/main.js +++ b/electron/main.js @@ -39,6 +39,7 @@ const { resolveServerEntry } = require("./lib/resolveServerEntry"); const { resolveDarwinHelperExecutable } = require("./lib/resolveNodeHelper"); const { resolveRemoteServerUrl, isValidHttpUrl } = require("./lib/resolveRemoteServerUrl"); const { writeRemoteServerUrl } = require("./lib/remoteServerPreferences"); +const { buildReadinessUrl, waitForServer } = require("./lib/serverReadiness"); // ── Single Instance Lock ─────────────────────────────────── const gotTheLock = app.requestSingleInstanceLock(); @@ -86,6 +87,7 @@ let remoteServerUrl = resolveRemoteServerUrl({ }); const getServerUrl = () => remoteServerUrl || `http://localhost:${serverPort}`; +const getServerReadinessUrl = () => buildReadinessUrl(getServerUrl()); function resolveNodeExecutable(env = process.env) { // #1081: Ensure Next.js standalone runs using Electron's Node runtime @@ -185,26 +187,6 @@ function sendToRenderer(channel, data) { } } -// ── Helper: Wait for server readiness (#1, #10) ──────────── -// Default raised to 180s: the first launch after an upgrade can run long DB -// migrations, during which the server accepts the TCP connection but holds the -// HTTP response until handlers initialize. The previous 30s cap timed out and -// left the window stuck on a hanging connection (#2460). -async function waitForServer(url, timeoutMs = 180000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(url); - if (res.ok || res.status < 500) return true; - } catch { - /* server not ready yet */ - } - await new Promise((r) => setTimeout(r, 500)); - } - console.warn("[Electron] Server readiness timeout — showing window anyway"); - return false; -} - // ── Helper: Wait for server process exit with timeout (#2) ─ async function waitForServerExit(proc, timeoutMs = 5000) { if (!proc) return; @@ -533,7 +515,7 @@ async function changePort(newPort) { // Start server on new port startNextServer(); - await waitForServer(getServerUrl()); + await waitForServer(getServerReadinessUrl()); // Reload window and update tray if (mainWindow && !mainWindow.isDestroyed()) { @@ -603,7 +585,7 @@ async function setRemoteServerUrl(nextUrl) { startNextServer(); try { - await waitForServer(`${getServerUrl()}/api/monitoring/health`); + await waitForServer(getServerReadinessUrl()); } catch (err) { console.warn("[Electron] Server did not become ready after remote-server change:", err.message); } @@ -935,7 +917,7 @@ function setupIpcHandlers() { stopNextServer(); await waitForServerExit(serverToStop); startNextServer(); - await waitForServer(getServerUrl()); + await waitForServer(getServerReadinessUrl()); return { success: true }; }); @@ -1078,8 +1060,8 @@ app.whenReady().then(async () => { startNextServer(); let serverReady = true; if (!isDev) { - // Probe the auth-exempt health endpoint (not the root URL, which may redirect). - serverReady = await waitForServer(`${getServerUrl()}/api/monitoring/health`); + // Probe the lightweight auth-exempt endpoint instead of aggregating full monitoring state. + serverReady = await waitForServer(getServerReadinessUrl()); } if (isHeadless) { @@ -1095,7 +1077,7 @@ app.whenReady().then(async () => { // If readiness timed out (e.g. very long first-launch migrations), don't leave the // window stuck on a hanging connection — keep polling and reload once it responds (#2460). if (!isDev && !serverReady && !isHeadless) { - void waitForServer(`${getServerUrl()}/api/monitoring/health`, 300000).then((ready) => { + void waitForServer(getServerReadinessUrl(), 300000).then((ready) => { if (ready && mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL(getServerUrl()); } diff --git a/electron/package.json b/electron/package.json index 4c98337587..132811c4dc 100644 --- a/electron/package.json +++ b/electron/package.json @@ -66,6 +66,7 @@ "lib/resolveNodeHelper.js", "lib/resolveRemoteServerUrl.js", "lib/remoteServerPreferences.js", + "lib/serverReadiness.js", "assets/remoteServerPrompt.html", "package.json", "node_modules/**/*" diff --git a/tests/unit/electron-main.test.ts b/tests/unit/electron-main.test.ts index 7ba0746f22..b8da073bdd 100644 --- a/tests/unit/electron-main.test.ts +++ b/tests/unit/electron-main.test.ts @@ -19,6 +19,7 @@ import { join } from "node:path"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); +const { waitForServer } = require("../../electron/lib/serverReadiness"); function raceDelays(firstMs, secondMs) { return new Promise((resolve) => { @@ -272,23 +273,12 @@ describe("Server Port Management", () => { describe("Server Readiness Logic", () => { it("waitForServer should timeout and return false", async () => { - // Simulate the polling logic with an always-failing fetch - async function waitForServer(url, timeoutMs = 100) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const res = await fetch(url); - if (res.ok || res.status < 500) return true; - } catch { - /* not ready */ - } - await new Promise((r) => setTimeout(r, 30)); - } - return false; - } - - // Should timeout immediately since nothing is running on that port - const result = await waitForServer("http://localhost:59999", 100); + const result = await waitForServer("http://localhost:59999/api/health/ping", 20, { + fetchFn: async () => ({ ok: false }), + pollIntervalMs: 1, + requestTimeoutMs: 5, + warnFn: () => {}, + }); assert.equal(result, false); }); @@ -302,18 +292,20 @@ describe("Server Readiness Logic", () => { serverUp = true; }, 60); - async function waitForServer(_url, timeoutMs) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (serverUp) return true; - await new Promise((r) => setTimeout(r, 15)); - } - return false; - } + const readinessOptions = { + fetchFn: async () => ({ ok: serverUp }), + pollIntervalMs: 5, + requestTimeoutMs: 5, + warnFn: () => {}, + }; try { // Initial probe with a short budget times out (server not up yet). - const initialReady = await waitForServer("http://localhost/api/monitoring/health", 20); + const initialReady = await waitForServer( + "http://localhost/api/health/ping", + 20, + readinessOptions + ); assert.equal(initialReady, false); let reloaded = false; @@ -325,7 +317,11 @@ describe("Server Readiness Logic", () => { }; // Background retry with a generous budget should succeed and reload the window. - const retryReady = await waitForServer("http://localhost/api/monitoring/health", 5000); + const retryReady = await waitForServer( + "http://localhost/api/health/ping", + 5000, + readinessOptions + ); if (retryReady && mainWindow && !mainWindow.isDestroyed()) { mainWindow.loadURL("http://localhost"); } diff --git a/tests/unit/electron-server-readiness.test.ts b/tests/unit/electron-server-readiness.test.ts new file mode 100644 index 0000000000..323291ed4d --- /dev/null +++ b/tests/unit/electron-server-readiness.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; + +const require = createRequire(import.meta.url); +const { buildReadinessUrl, waitForServer } = require("../../electron/lib/serverReadiness"); + +describe("Electron server readiness", () => { + it("builds the lightweight readiness URL from local and remote base URLs", () => { + assert.equal( + buildReadinessUrl("http://localhost:20128"), + "http://localhost:20128/api/health/ping" + ); + assert.equal( + buildReadinessUrl("https://omniroute.example.com/"), + "https://omniroute.example.com/api/health/ping" + ); + }); + + it("accepts only a successful HTTP response", async () => { + let attempts = 0; + const ready = await waitForServer("http://localhost/api/health/ping", 100, { + fetchFn: async () => ({ ok: ++attempts === 2 }), + pollIntervalMs: 1, + requestTimeoutMs: 20, + warnFn: () => {}, + }); + + assert.equal(ready, true); + assert.equal(attempts, 2); + }); + + it("returns false after repeated unsuccessful responses", async () => { + const ready = await waitForServer("http://localhost/api/health/ping", 20, { + fetchFn: async () => ({ ok: false }), + pollIntervalMs: 1, + requestTimeoutMs: 5, + warnFn: () => {}, + }); + + assert.equal(ready, false); + }); + + it("bounds a stalled request by both the attempt and overall deadlines", async () => { + const startedAt = Date.now(); + const ready = await waitForServer("http://localhost/api/health/ping", 35, { + fetchFn: () => new Promise(() => {}), + pollIntervalMs: 1, + requestTimeoutMs: 10, + warnFn: () => {}, + }); + const elapsedMs = Date.now() - startedAt; + + assert.equal(ready, false); + assert.ok(elapsedMs < 150, `stalled readiness probe took ${elapsedMs}ms`); + }); +});