mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 13:14:56 +03:00
perf(electron): bound lightweight readiness polling (#10324)
This commit is contained in:
61
electron/lib/serverReadiness.js
Normal file
61
electron/lib/serverReadiness.js
Normal file
@@ -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,
|
||||
};
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"lib/resolveNodeHelper.js",
|
||||
"lib/resolveRemoteServerUrl.js",
|
||||
"lib/remoteServerPreferences.js",
|
||||
"lib/serverReadiness.js",
|
||||
"assets/remoteServerPrompt.html",
|
||||
"package.json",
|
||||
"node_modules/**/*"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
57
tests/unit/electron-server-readiness.test.ts
Normal file
57
tests/unit/electron-server-readiness.test.ts
Normal file
@@ -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`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user