Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions electron/lib/serverReadiness.js
Original file line number Diff line number Diff line change
@@ -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,
};
34 changes: 8 additions & 26 deletions electron/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -935,7 +917,7 @@ function setupIpcHandlers() {
stopNextServer();
await waitForServerExit(serverToStop);
startNextServer();
await waitForServer(getServerUrl());
await waitForServer(getServerReadinessUrl());
return { success: true };
});

Expand Down Expand Up @@ -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) {
Expand All @@ -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());
}
Expand Down
1 change: 1 addition & 0 deletions electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"lib/resolveNodeHelper.js",
"lib/resolveRemoteServerUrl.js",
"lib/remoteServerPreferences.js",
"lib/serverReadiness.js",
"assets/remoteServerPrompt.html",
"package.json",
"node_modules/**/*"
Expand Down
50 changes: 23 additions & 27 deletions tests/unit/electron-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
});

Expand All @@ -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;
Expand All @@ -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");
}
Expand Down
57 changes: 57 additions & 0 deletions tests/unit/electron-server-readiness.test.ts
Original file line number Diff line number Diff line change
@@ -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`);
});
});
Loading