perf(electron): bound lightweight readiness polling (#10324)

This commit is contained in:
backryun
2026-08-16 12:14:45 +09:00
committed by GitHub
parent cb51facf12
commit 757b195540
5 changed files with 150 additions and 53 deletions

View 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,
};

View File

@@ -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());
}

View File

@@ -66,6 +66,7 @@
"lib/resolveNodeHelper.js",
"lib/resolveRemoteServerUrl.js",
"lib/remoteServerPreferences.js",
"lib/serverReadiness.js",
"assets/remoteServerPrompt.html",
"package.json",
"node_modules/**/*"

View File

@@ -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");
}

View 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`);
});
});