mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
* fix(cli): escalate the readiness probe timeout so a slow health response is not a phantom boot failure `omniroute serve` reported "Server did not respond within 60s" over servers that were up and serving traffic. Every probe of /api/monitoring/health was aborted at a fixed 2s, and a timed-out probe is classified "hanging", which never counts toward readiness (#6800). So whenever the first health response takes longer than 2s the poll can never succeed: each abort discards the in-flight request before the route finishes (its own 1s payload cache is never populated either), and 500ms later the next probe restarts the same work into the same ceiling, for the whole 60s budget. Reproduced by the new test: against a health route that answers 200 in 3.2s, the old poller ran 12 probes over 30s and reported ready=false every time. The per-probe timeout now escalates after each hang (2s, 4s, 8s, 15s), clamped to the time left in the budget so the caller's total timeout still holds. Only a hang escalates, so #6800's guarantee is unchanged: a socket that accepts TCP and never answers still resolves false. waitForServer also reports each probe outcome to an optional onOutcome callback, and the readiness-timeout diagnostic uses it to say whether the port was accepting connections, which separates "up and still warming" from "never bound the port". Same failure family as #10508, which fixed it by taking a DNS lookup out of the 2s budget rather than by widening it. The heavy /api/monitoring/health route is what makes that budget tight in the first place (its own docstring points high-frequency pollers at /api/health/ping, which is what the Electron readiness poller uses); switching the CLI probe route is a larger change, left as a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CJf2dxEpiwZqyZujWk57T2 * chore(changelog): link the readiness-probe fix to PR 12484 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CJf2dxEpiwZqyZujWk57T2 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
619 lines
22 KiB
JavaScript
619 lines
22 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
import { platform, totalmem } from "node:os";
|
|
import { t } from "../i18n.mjs";
|
|
import {
|
|
writePidFile,
|
|
cleanupPidFile,
|
|
waitForServer,
|
|
resolveReadyTimeoutMs,
|
|
} from "../utils/pid.mjs";
|
|
import {
|
|
ServerSupervisor,
|
|
detectMitmCrash,
|
|
BUN_PRELOAD_PATH,
|
|
} from "../runtime/processSupervisor.mjs";
|
|
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
|
|
import {
|
|
ensureAndroidCacheDir,
|
|
isFatalInstrumentationHookFailure,
|
|
formatAndroidInstrumentationFailureHint,
|
|
} from "../utils/ensureAndroidCacheDir.mjs";
|
|
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
|
|
import {
|
|
resolveMaxOldSpaceMb,
|
|
calibrateHeapFallbackMb,
|
|
buildServerNodeOptions,
|
|
buildNodeHeapArgs,
|
|
} from "../../../scripts/build/runtime-env.mjs";
|
|
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
|
|
import { startDetachedTray, validateTrayOptions } from "../tray/detachedTray.mjs";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8"));
|
|
|
|
// URL scheme for the "OmniRoute is running" banner — flipped to https when
|
|
// opt-in TLS (#5242) is active. Process-scoped: one `serve` run = one scheme.
|
|
let urlScheme = "http";
|
|
const ROOT = join(__dirname, "..", "..", "..");
|
|
// The standalone bundle ships in `dist/` (since the build-output-isolation
|
|
// refactor). Fall back to the legacy `app/` location so an upgrade over a
|
|
// partially-replaced install — or a package built before the rename — still
|
|
// boots. Backward-compatible by design: every deployed runtime keeps its path.
|
|
const APP_DIR = existsSync(join(ROOT, "dist", "server.js"))
|
|
? join(ROOT, "dist")
|
|
: join(ROOT, "app");
|
|
|
|
function parsePort(value, fallback) {
|
|
const parsed = parseInt(String(value), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
|
|
}
|
|
|
|
export function registerServe(program) {
|
|
const command = program
|
|
.command("serve", { isDefault: true })
|
|
.description(t("serve.description"))
|
|
.option("--port <port>", t("serve.port"))
|
|
.option("--no-open", t("serve.no_open"))
|
|
.option("--daemon", t("serve.daemon"))
|
|
.option("--log", t("serve.log"))
|
|
.option("--no-recovery", t("serve.no_recovery"))
|
|
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
|
|
.option("--tray", t("serve.tray") || "Start in the system tray (desktop only)")
|
|
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
|
|
.option(
|
|
"--ready-timeout <ms>",
|
|
t("serve.ready_timeout") ||
|
|
"Readiness probe timeout in ms (also OMNIROUTE_READY_TIMEOUT_MS, default 60000)"
|
|
)
|
|
.option(
|
|
"--tls-cert <path>",
|
|
t("serve.tls_cert") ||
|
|
"Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)"
|
|
)
|
|
.option(
|
|
"--tls-key <path>",
|
|
t("serve.tls_key") ||
|
|
"Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
|
|
)
|
|
.action(async (opts) => {
|
|
await runServe(opts);
|
|
});
|
|
command.addOption(command.createOption("--tray-worker").hideHelp());
|
|
command.addOption(command.createOption("--tray-ready-port <port>").hideHelp());
|
|
command.addOption(command.createOption("--tray-ready-token <token>").hideHelp());
|
|
}
|
|
|
|
/** Once-per-process guard so the Android/Termux cache hint is not spammed. */
|
|
let instrumentationFailureHintPrinted = false;
|
|
|
|
/**
|
|
* If child output looks like Next.js failed to load its instrumentation hook
|
|
* on Android/Termux, print a clear operator-facing fix hint.
|
|
* Exported for unit tests.
|
|
*
|
|
* @param {string} text
|
|
* @returns {boolean} true when a hint was printed
|
|
*/
|
|
export function maybeReportInstrumentationHookFailure(text) {
|
|
if (instrumentationFailureHintPrinted) return false;
|
|
if (!isFatalInstrumentationHookFailure(text)) return false;
|
|
instrumentationFailureHintPrinted = true;
|
|
process.stderr.write(formatAndroidInstrumentationFailureHint(process.env.XDG_CACHE_HOME));
|
|
return true;
|
|
}
|
|
|
|
/** Test-only reset for the once-per-process hint guard. */
|
|
export function resetInstrumentationFailureHintForTests() {
|
|
instrumentationFailureHintPrinted = false;
|
|
}
|
|
|
|
export async function runServe(opts = {}) {
|
|
const startedAt = performance.now();
|
|
|
|
const trayOptionError = validateTrayOptions(opts);
|
|
if (trayOptionError) throw new Error(trayOptionError);
|
|
|
|
if (opts.tray === true && opts.trayWorker !== true) {
|
|
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
|
|
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
|
|
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
|
|
urlScheme = resolveTlsOptions({
|
|
...process.env,
|
|
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
|
|
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
|
|
})
|
|
? "https"
|
|
: "http";
|
|
const result = await startDetachedTray({
|
|
cliPath: join(ROOT, "bin", "omniroute.mjs"),
|
|
port,
|
|
maxRestarts: opts.maxRestarts ?? 2,
|
|
tlsCert,
|
|
tlsKey,
|
|
});
|
|
console.log(`\x1b[32m✔ OmniRoute tray started in background\x1b[0m`);
|
|
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${port}`);
|
|
return result;
|
|
}
|
|
|
|
// Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call
|
|
// (tests / programmatic) still gets a writable Next.js cache dir before spawn.
|
|
ensureAndroidCacheDir({ env: process.env });
|
|
|
|
const { isNativeBinaryCompatible } =
|
|
await import("../../../scripts/build/native-binary-compat.mjs");
|
|
const { getNodeRuntimeSupport, getNodeRuntimeWarning } =
|
|
await import("../../nodeRuntimeSupport.mjs");
|
|
|
|
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
|
|
const apiPort = parsePort(process.env.API_PORT ?? String(port), port);
|
|
const dashboardPort = parsePort(process.env.DASHBOARD_PORT ?? String(port), port);
|
|
const noOpen = opts.open === false;
|
|
|
|
console.log(`
|
|
\x1b[36m ____ _ ____ _
|
|
/ __ \\ (_) __ \\ | |
|
|
| | | |_ __ ___ _ __ _| |__) |___ _ _| |_ ___
|
|
| | | | '_ \` _ \\| '_ \\ | _ // _ \\| | | | __/ _ \\
|
|
| |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/
|
|
\\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___|
|
|
\x1b[0m`);
|
|
console.log(`\x1b[2m v${_pkg.version}\x1b[0m\n`);
|
|
|
|
const nodeSupport = getNodeRuntimeSupport();
|
|
if (!nodeSupport.nodeCompatible) {
|
|
const runtimeWarning = getNodeRuntimeWarning() || "Unsupported Node.js runtime detected.";
|
|
console.warn(`\x1b[33m ⚠ Warning: You are running Node.js ${process.versions.node}.
|
|
${runtimeWarning}
|
|
|
|
Supported secure runtimes: ${nodeSupport.supportedDisplay}
|
|
Recommended: use Node.js ${nodeSupport.recommendedVersion} or newer on the 22.x LTS line.
|
|
Workaround: npm rebuild better-sqlite3
|
|
Or run: omniroute runtime repair (rebuilds into a user-writable runtime; works without a C++ toolchain)\x1b[0m
|
|
`);
|
|
}
|
|
|
|
// GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a
|
|
// deliberate local-first choice, but it must be loud at startup — an operator
|
|
// on an untrusted network learns the two escape hatches here, not after a
|
|
// surprise quota bill.
|
|
const exposureWarning = resolveExposureWarning();
|
|
if (exposureWarning) {
|
|
console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`);
|
|
}
|
|
|
|
const serverWsJs = join(APP_DIR, "server-ws.mjs");
|
|
const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js");
|
|
|
|
if (!existsSync(serverJs)) {
|
|
console.error("\x1b[31m✖ Server not found at:\x1b[0m", serverJs);
|
|
console.error(" The package may not have been built correctly.");
|
|
console.error("");
|
|
const nodeExec = process.execPath || "";
|
|
const isMise = nodeExec.includes("mise") || nodeExec.includes(".local/share/mise");
|
|
const isNvm = nodeExec.includes(".nvm") || nodeExec.includes("nvm");
|
|
if (isMise) {
|
|
console.error(
|
|
" \x1b[33m⚠ mise detected:\x1b[0m If you installed via `npm install -g omniroute`,"
|
|
);
|
|
console.error(" try: \x1b[36mnpx omniroute@latest\x1b[0m (downloads a fresh copy)");
|
|
console.error(" or: \x1b[36mmise exec -- npx omniroute\x1b[0m");
|
|
} else if (isNvm) {
|
|
console.error(
|
|
" \x1b[33m⚠ nvm detected:\x1b[0m Try reinstalling after loading the correct Node version:"
|
|
);
|
|
console.error(" \x1b[36mnvm use --lts && npm install -g omniroute\x1b[0m");
|
|
} else {
|
|
console.error(" Try: \x1b[36mnpm install -g omniroute\x1b[0m (reinstall)");
|
|
console.error(" Or: \x1b[36mnpx omniroute@latest\x1b[0m");
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
const sqliteBinary = join(
|
|
APP_DIR,
|
|
"node_modules",
|
|
"better-sqlite3",
|
|
"build",
|
|
"Release",
|
|
"better_sqlite3.node"
|
|
);
|
|
if (
|
|
!process.versions.bun &&
|
|
existsSync(sqliteBinary) &&
|
|
!isNativeBinaryCompatible(sqliteBinary)
|
|
) {
|
|
console.error(
|
|
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
|
|
);
|
|
console.error(` Run: cd ${APP_DIR} && npm rebuild better-sqlite3`);
|
|
console.error(
|
|
" Or run: \x1b[36momniroute runtime repair\x1b[0m" +
|
|
" (rebuilds into a user-writable runtime; works without a C++ toolchain)"
|
|
);
|
|
if (platform() === "darwin") {
|
|
console.error(" If build tools are missing: xcode-select --install");
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
|
|
|
|
// #5172/#5160/#5152: default the V8 heap to ~35% of physical RAM (clamped
|
|
// [512, 4096]) instead of a fixed 512MB, which OOM-crashed boxes with plenty
|
|
// of RAM under load. An explicit OMNIROUTE_MEMORY_MB still wins.
|
|
const memoryLimit = resolveMaxOldSpaceMb(
|
|
process.env.OMNIROUTE_MEMORY_MB,
|
|
calibrateHeapFallbackMb(totalmem())
|
|
);
|
|
|
|
// #5242: opt-in native HTTPS. CLI flags take precedence over env; the child
|
|
// server (server-ws.mjs) reads these and terminates TLS on the same listener.
|
|
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
|
|
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
|
|
|
|
const env = {
|
|
...process.env,
|
|
OMNIROUTE_PORT: String(port),
|
|
PORT: String(dashboardPort),
|
|
DASHBOARD_PORT: String(dashboardPort),
|
|
API_PORT: String(apiPort),
|
|
// #10492: HOSTNAME is standard shell state on Unix-like systems, not an
|
|
// OmniRoute bind setting. The resolver only keeps its legacy meaning on
|
|
// Windows; OMNIROUTE_SERVER_HOST is the cross-platform explicit setting.
|
|
HOSTNAME: resolveServerHost(),
|
|
NODE_ENV: "production",
|
|
// #5238: preserve a user-set NODE_OPTIONS (incl. their own
|
|
// `--max-old-space-size=…`) instead of clobbering it with the calibrated
|
|
// default — mirror the Electron/standalone launchers.
|
|
NODE_OPTIONS: buildServerNodeOptions(process.env, memoryLimit),
|
|
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
|
|
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
|
|
};
|
|
|
|
// Validate the TLS pair up front so the operator sees a clear warning in the
|
|
// CLI (the child re-validates authoritatively). Drives the banner scheme;
|
|
// when null we fall through to identical plain-HTTP behavior as before.
|
|
urlScheme = resolveTlsOptions(env) ? "https" : "http";
|
|
|
|
const isDaemon = opts.daemon === true;
|
|
const useTray = opts.tray === true;
|
|
|
|
if (isDaemon) {
|
|
return runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort);
|
|
}
|
|
|
|
// Commander stores `--no-recovery` as `recovery === false`, never as `noRecovery`.
|
|
if (opts.recovery === false || opts.noRecovery === true) {
|
|
return runWithoutRecovery(
|
|
serverJs,
|
|
env,
|
|
memoryLimit,
|
|
dashboardPort,
|
|
apiPort,
|
|
noOpen,
|
|
startedAt
|
|
);
|
|
}
|
|
|
|
return runWithSupervisor(
|
|
serverJs,
|
|
env,
|
|
memoryLimit,
|
|
dashboardPort,
|
|
apiPort,
|
|
noOpen,
|
|
opts.log === true,
|
|
opts.maxRestarts ?? 2,
|
|
startedAt,
|
|
useTray,
|
|
{
|
|
trayReadyPort: opts.trayReadyPort,
|
|
trayReadyToken: opts.trayReadyToken,
|
|
readyTimeoutMs: resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout }),
|
|
}
|
|
);
|
|
}
|
|
|
|
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
|
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
|
|
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
|
|
const server = spawn(
|
|
process.versions.bun ? process.execPath : "node",
|
|
[
|
|
...(process.versions.bun
|
|
? ["--preload", BUN_PRELOAD_PATH]
|
|
: buildNodeHeapArgs(process.env, memoryLimit)),
|
|
serverJs,
|
|
],
|
|
{
|
|
cwd: APP_DIR,
|
|
env,
|
|
stdio: "ignore",
|
|
detached: true,
|
|
}
|
|
);
|
|
writePidFile("server", server.pid);
|
|
server.unref();
|
|
console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`);
|
|
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${dashboardPort}`);
|
|
console.log(` \x1b[1mAPI Base:\x1b[0m ${urlScheme}://localhost:${apiPort}/v1`);
|
|
}
|
|
|
|
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) {
|
|
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
|
|
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
|
|
const server = spawn(
|
|
process.versions.bun ? process.execPath : "node",
|
|
[
|
|
...(process.versions.bun
|
|
? ["--preload", BUN_PRELOAD_PATH]
|
|
: buildNodeHeapArgs(process.env, memoryLimit)),
|
|
serverJs,
|
|
],
|
|
{
|
|
cwd: APP_DIR,
|
|
env,
|
|
stdio: "pipe",
|
|
}
|
|
);
|
|
|
|
writePidFile("server", server.pid);
|
|
|
|
let started = false;
|
|
|
|
server.stdout.on("data", (data) => {
|
|
const text = data.toString();
|
|
process.stdout.write(text);
|
|
maybeReportInstrumentationHookFailure(text);
|
|
if (
|
|
!started &&
|
|
(text.includes("Ready") || text.includes("started") || text.includes("listening"))
|
|
) {
|
|
started = true;
|
|
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
|
}
|
|
});
|
|
|
|
server.stderr.on("data", (data) => {
|
|
const text = data.toString();
|
|
process.stderr.write(text);
|
|
maybeReportInstrumentationHookFailure(text);
|
|
});
|
|
|
|
server.on("error", (err) => {
|
|
console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message);
|
|
process.exit(1);
|
|
});
|
|
|
|
server.on("exit", (code) => {
|
|
if (code !== 0 && code !== null) {
|
|
console.error(`\x1b[31m✖ Server exited with code ${code}\x1b[0m`);
|
|
}
|
|
process.exit(code ?? 0);
|
|
});
|
|
|
|
const shutdown = () => {
|
|
console.log("\n\x1b[33m⏹ Shutting down OmniRoute...\x1b[0m");
|
|
cleanupPidFile("server");
|
|
server.kill("SIGTERM");
|
|
setTimeout(() => {
|
|
server.kill("SIGKILL");
|
|
process.exit(0);
|
|
}, 5000);
|
|
};
|
|
|
|
process.on("SIGINT", shutdown);
|
|
process.on("SIGTERM", shutdown);
|
|
|
|
setTimeout(() => {
|
|
if (!started) {
|
|
started = true;
|
|
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
|
}
|
|
}, 15000);
|
|
}
|
|
|
|
async function runWithSupervisor(
|
|
serverJs,
|
|
env,
|
|
memoryLimit,
|
|
dashboardPort,
|
|
apiPort,
|
|
noOpen,
|
|
showLog,
|
|
maxRestarts,
|
|
startedAt,
|
|
useTray = false,
|
|
{ trayReadyPort, trayReadyToken, readyTimeoutMs = resolveReadyTimeoutMs() } = {}
|
|
) {
|
|
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
|
|
writePidFile("supervisor", process.pid);
|
|
|
|
const supervisor = new ServerSupervisor({
|
|
serverPath: serverJs,
|
|
env,
|
|
memoryLimit,
|
|
maxRestarts,
|
|
onCrashCallback: async (crashLog) => {
|
|
if (detectMitmCrash(crashLog)) {
|
|
try {
|
|
const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
const { updateSettings } = await import(
|
|
pathToFileURL(join(PROJECT_ROOT, "src/lib/db/settings.ts")).href
|
|
);
|
|
updateSettings({ mitmEnabled: false });
|
|
} catch {}
|
|
return "disable-mitm-and-retry";
|
|
}
|
|
return null;
|
|
},
|
|
});
|
|
|
|
supervisor.start();
|
|
|
|
process.on("SIGINT", () => {
|
|
killTrayIfActive();
|
|
cleanupPidFile("supervisor");
|
|
supervisor.stop();
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
killTrayIfActive();
|
|
cleanupPidFile("supervisor");
|
|
supervisor.stop();
|
|
});
|
|
|
|
if (!showLog) {
|
|
let lastProbeOutcome = null;
|
|
waitForServer(dashboardPort, readyTimeoutMs, {
|
|
onOutcome: (outcome) => {
|
|
lastProbeOutcome = outcome;
|
|
},
|
|
}).then(async (up) => {
|
|
if (up) {
|
|
if (useTray) {
|
|
const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor);
|
|
if (!trayReady) {
|
|
cleanupPidFile("supervisor");
|
|
supervisor.stop();
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
if (trayReadyPort && trayReadyToken) {
|
|
const { notifyTrayReady } = await import("../tray/detachedTray.mjs");
|
|
try {
|
|
await notifyTrayReady(parsePort(trayReadyPort, 0), trayReadyToken);
|
|
} catch {
|
|
cleanupPidFile("supervisor");
|
|
supervisor.stop();
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
|
} else {
|
|
reportReadinessTimeout(dashboardPort, supervisor, lastProbeOutcome);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// #6321: waitForServer resolving `false` used to fall through silently — the CLI
|
|
// printed the banner + "⏳ Starting server..." and then produced ZERO further
|
|
// output forever, even though the child process may well have crashed or be
|
|
// stuck (issue reports show the server sometimes actually comes up later, or is
|
|
// reachable directly while the CLI still looks hung). Surface a clear diagnostic
|
|
// plus whatever stdout/stderr the child buffered instead of going silent.
|
|
export function reportReadinessTimeout(dashboardPort, supervisor, lastProbeOutcome = null) {
|
|
const readyTimeoutMs = resolveReadyTimeoutMs();
|
|
const seconds = Math.round(readyTimeoutMs / 1000);
|
|
console.error(
|
|
`\n\x1b[33m⚠ Server did not respond within ${seconds}s.\x1b[0m It may still be starting, or may` +
|
|
` have failed silently.`
|
|
);
|
|
// The last probe classification separates a real boot failure (nothing ever
|
|
// bound the port, so the buffered output below is the reason) from a server
|
|
// that IS listening and merely did not answer the health route in time:
|
|
// very likely usable already, with only the readiness signal timed out.
|
|
if (lastProbeOutcome === "hanging" || lastProbeOutcome === "fast-reject") {
|
|
console.error(
|
|
` Port ${dashboardPort} IS accepting connections, so the server is probably up and` +
|
|
` still warming up. Check the dashboard before restarting it.`
|
|
);
|
|
} else if (lastProbeOutcome === "not-listening") {
|
|
console.error(
|
|
` Nothing is listening on port ${dashboardPort}, so the server never bound it and the` +
|
|
` output below is the reason.`
|
|
);
|
|
}
|
|
console.error(
|
|
` Tip: set OMNIROUTE_READY_TIMEOUT_MS=${readyTimeoutMs * 2} or --ready-timeout ${readyTimeoutMs * 2} for slower cold starts.`
|
|
);
|
|
console.error(` Try: curl -I http://localhost:${dashboardPort}/api/monitoring/health`);
|
|
console.error(` Or: rerun with \x1b[36m--log\x1b[0m to see live server output.\n`);
|
|
|
|
const recentLog = supervisor?.getRecentLog?.() ?? [];
|
|
if (recentLog.length) {
|
|
console.error("--- Recent server output ---");
|
|
recentLog.forEach((l) => console.error(l));
|
|
console.error("--- End recent output ---\n");
|
|
// If the buffered log already shows the Android instrumentation failure,
|
|
// print the actionable hint even when --log was off (default).
|
|
maybeReportInstrumentationHookFailure(recentLog.join("\n"));
|
|
}
|
|
}
|
|
|
|
let _killTray = null;
|
|
function killTrayIfActive() {
|
|
if (_killTray) {
|
|
try {
|
|
_killTray();
|
|
} catch {}
|
|
_killTray = null;
|
|
}
|
|
}
|
|
|
|
async function maybeStartTray(port, apiPort, supervisor) {
|
|
try {
|
|
const { initTray, isTraySupported } = await import("../tray/index.mjs");
|
|
if (!isTraySupported()) return false;
|
|
const { default: open } = await import("open").catch(() => ({ default: null }));
|
|
const dashboardUrl = `${urlScheme}://localhost:${port}`;
|
|
const tray = await initTray({
|
|
port,
|
|
onQuit: () => {
|
|
killTrayIfActive();
|
|
cleanupPidFile("supervisor");
|
|
supervisor.stop();
|
|
},
|
|
onOpenDashboard: () => open?.(dashboardUrl),
|
|
onShowLogs: () => open?.(`${dashboardUrl}/dashboard/logs`),
|
|
});
|
|
if (tray) {
|
|
const { killTray } = await import("../tray/index.mjs");
|
|
_killTray = killTray;
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch (err) {
|
|
// tray is optional — do not fail the server, but surface why it failed so
|
|
// "--tray shows nothing" is diagnosable instead of silent (#4605).
|
|
process.stderr.write(`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function onReady(dashboardPort, apiPort, noOpen, startedAt) {
|
|
const dashboardUrl = `${urlScheme}://localhost:${dashboardPort}`;
|
|
const apiUrl = `${urlScheme}://localhost:${apiPort}`;
|
|
const elapsed =
|
|
typeof startedAt === "number" && Number.isFinite(startedAt)
|
|
? ((performance.now() - startedAt) / 1000).toFixed(1)
|
|
: "0.0";
|
|
|
|
console.log(`
|
|
\x1b[32m✔ OmniRoute is running!\x1b[0m \x1b[2m(started in ${elapsed}s)\x1b[0m
|
|
|
|
\x1b[1m Dashboard:\x1b[0m ${dashboardUrl}
|
|
\x1b[1m API Base:\x1b[0m ${apiUrl}/v1
|
|
|
|
\x1b[2m Point your CLI tool (Cursor, Cline, Codex) to:\x1b[0m
|
|
\x1b[33m ${apiUrl}/v1\x1b[0m
|
|
|
|
\x1b[2m Press Ctrl+C to stop\x1b[0m
|
|
`);
|
|
|
|
if (!noOpen && !isTermux()) {
|
|
try {
|
|
const open = await import("open");
|
|
await open.default(dashboardUrl);
|
|
} catch {
|
|
// open is optional — skip if unavailable
|
|
}
|
|
}
|
|
}
|