diff --git a/bin/cli/commands/env.mjs b/bin/cli/commands/env.mjs index 77f16b1155..3d44eb3cb4 100644 --- a/bin/cli/commands/env.mjs +++ b/bin/cli/commands/env.mjs @@ -13,6 +13,7 @@ const OMNIROUTE_ENV_VARS = [ "OMNIROUTE_API_KEY", "OMNIROUTE_BASE_URL", "OMNIROUTE_HTTP_TIMEOUT_MS", + "OMNIROUTE_READY_TIMEOUT_MS", ]; const ENV_DEFAULTS = { diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index 80e97725db..95538e6c20 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -4,7 +4,7 @@ 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 } from "../utils/pid.mjs"; +import { writePidFile, cleanupPidFile, waitForServer, resolveReadyTimeoutMs } from "../utils/pid.mjs"; import { ServerSupervisor, detectMitmCrash, @@ -58,6 +58,11 @@ export function registerServe(program) { .option("--max-restarts ", 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 ", + t("serve.ready_timeout") || + "Readiness probe timeout in ms (also OMNIROUTE_READY_TIMEOUT_MS, default 60000)" + ) .option( "--tls-cert ", t("serve.tls_cert") || @@ -452,7 +457,8 @@ async function runWithSupervisor( }); if (!showLog) { - waitForServer(dashboardPort, 60000).then(async (up) => { + const readyTimeoutMs = resolveReadyTimeoutMs({ timeoutMs: opts.readyTimeout }); + waitForServer(dashboardPort, readyTimeoutMs).then(async (up) => { if (up) { if (useTray) { const trayReady = await maybeStartTray(dashboardPort, apiPort, supervisor); @@ -489,10 +495,15 @@ async function runWithSupervisor( // 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) { + const readyTimeoutMs = resolveReadyTimeoutMs(); + const seconds = Math.round(readyTimeoutMs / 1000); console.error( - `\n\x1b[33m⚠ Server did not respond within 60s.\x1b[0m It may still be starting, or may` + + `\n\x1b[33m⚠ Server did not respond within ${seconds}s.\x1b[0m It may still be starting, or may` + ` have failed silently.` ); + 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`); diff --git a/bin/cli/utils/pid.mjs b/bin/cli/utils/pid.mjs index 9d0b10bd9e..4a3721bfdc 100644 --- a/bin/cli/utils/pid.mjs +++ b/bin/cli/utils/pid.mjs @@ -66,13 +66,30 @@ export function sleep(ms) { // #2460: Default raised from 15s to 60s so Windows users (slower Next.js // cold start due to filesystem watchers, antivirus, etc.) get a working // "server ready" signal instead of a phantom timeout while the server is -// still booting. TCP fallback marks the server as ready when the port +// still booting. #13369: Made configurable via OMNIROUTE_READY_TIMEOUT_MS +// so operators on slow cold starts (e.g. 6+ min Windows boots) can raise +// the budget instead of hitting the warning on every start. +// +// TCP fallback marks the server as ready when the port // has been listening for >= 3s consecutively AND the health route is // actively rejecting/resetting connections fast (route not mounted yet, // but the HTTP server is clearly alive and responsive) — never for a // socket that merely accepts TCP and then hangs without ever completing // a single request (#6800: that's a still-booting/CPU-bound process, not // a "route not mounted" gap, and must NOT be reported as ready). +const DEFAULT_READY_TIMEOUT_MS = 60_000; + +export function resolveReadyTimeoutMs(overrides = {}) { + if (typeof overrides.timeoutMs === "number" && overrides.timeoutMs > 0) { + return overrides.timeoutMs; + } + const envValue = Number.parseInt( + process.env.OMNIROUTE_READY_TIMEOUT_MS || "", + 10 + ); + return Number.isFinite(envValue) && envValue > 0 ? envValue : DEFAULT_READY_TIMEOUT_MS; +} + export async function waitForServer(port, timeout = 60000) { const start = Date.now(); let tcpListeningSince = null; diff --git a/docs/guides/TROUBLESHOOTING.md b/docs/guides/TROUBLESHOOTING.md index b0f9bd1d37..071ba2ca65 100644 --- a/docs/guides/TROUBLESHOOTING.md +++ b/docs/guides/TROUBLESHOOTING.md @@ -739,6 +739,33 @@ Issues specific to the v3.8.0 release and their current workarounds. If a fix la --- +## Slow Startup / Readiness Timeout + +If the CLI prints `⚠ Server did not respond within 60s` but the server is +actually working, the readiness probe budget is too short for your environment. + +This commonly happens on Windows (antivirus, filesystem watchers) or containers +with heavy startup workloads. + +**Fix — raise the budget:** + +```bash +# Via env var (persists across starts): +export OMNIROUTE_READY_TIMEOUT_MS=180000 # 3 minutes +omniroute serve + +# Via CLI flag (one-off): +omniroute serve --ready-timeout 180000 +``` + +The default is 60 000 ms (60 s). The warning is informational only; the server +continues starting in the background and will be reachable once boot completes. + +See [`docs/reference/ENVIRONMENT.md`](../reference/ENVIRONMENT.md) for full +details on `OMNIROUTE_READY_TIMEOUT_MS`. + +--- + ## Still Stuck? - **GitHub Issues**: [github.com/diegosouzapw/OmniRoute/issues](https://github.com/diegosouzapw/OmniRoute/issues) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 83ceb71568..0d01b66a54 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -504,6 +504,7 @@ detection above). | `OMNIROUTE_SHOW_LOG` | _(unset)_ | `bin/cli/runtime/processSupervisor.mjs` | Set to `1` to forward server stdout/stderr to the terminal in supervised mode. Equivalent to `--log` flag on `omniroute serve`. | | `OMNIROUTE_CLI_TOKEN` | _(unset)_ | `bin/cli/api.mjs` | Machine-auth token injected as `x-omniroute-cli-token` header. Auto-generated in task 8.12. | | `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. | +| `OMNIROUTE_READY_TIMEOUT_MS` | `60000` | `bin/cli/utils/pid.mjs` | Maximum time (ms) the CLI waits for the server health endpoint before printing a timeout warning. Useful for slow cold starts (e.g. Windows). Also settable via `--ready-timeout`. | | `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. | | `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. CLI-only — it never reaches the server-side plugin scanner, which is pointed by `OMNIROUTE_PLUGINS_DIR` (section 2). | diff --git a/tests/unit/cli/ready-timeout.test.ts b/tests/unit/cli/ready-timeout.test.ts new file mode 100644 index 0000000000..6bfcfc2a8f --- /dev/null +++ b/tests/unit/cli/ready-timeout.test.ts @@ -0,0 +1,71 @@ +import { test, describe, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { resolveReadyTimeoutMs } from "../../../bin/cli/utils/pid.mjs"; + +describe("resolveReadyTimeoutMs", () => { + const savedEnv = process.env.OMNIROUTE_READY_TIMEOUT_MS; + + beforeEach(() => { + // Reset env between tests + if (savedEnv === undefined) { + delete process.env.OMNIROUTE_READY_TIMEOUT_MS; + } else { + process.env.OMNIROUTE_READY_TIMEOUT_MS = savedEnv; + } + }); + + test("returns 60000 by default (no env, no override)", () => { + delete process.env.OMNIROUTE_READY_TIMEOUT_MS; + assert.equal(resolveReadyTimeoutMs(), 60_000); + }); + + test("honours explicit override when provided", () => { + delete process.env.OMNIROUTE_READY_TIMEOUT_MS; + assert.equal(resolveReadyTimeoutMs({ timeoutMs: 120_000 }), 120_000); + }); + + test("explicit override wins over env var", () => { + process.env.OMNIROUTE_READY_TIMEOUT_MS = "300000"; + assert.equal(resolveReadyTimeoutMs({ timeoutMs: 90_000 }), 90_000); + }); + + test("reads OMNIROUTE_READY_TIMEOUT_MS env var", () => { + process.env.OMNIROUTE_READY_TIMEOUT_MS = "180000"; + assert.equal(resolveReadyTimeoutMs(), 180_000); + }); + + test("falls back to default when env var is non-numeric", () => { + process.env.OMNIROUTE_READY_TIMEOUT_MS = "not-a-number"; + assert.equal(resolveReadyTimeoutMs(), 60_000); + }); + + test("falls back to default when env var is zero", () => { + process.env.OMNIROUTE_READY_TIMEOUT_MS = "0"; + assert.equal(resolveReadyTimeoutMs(), 60_000); + }); + + test("falls back to default when env var is negative", () => { + process.env.OMNIROUTE_READY_TIMEOUT_MS = "-5000"; + assert.equal(resolveReadyTimeoutMs(), 60_000); + }); + + test("falls back to default when override is zero", () => { + delete process.env.OMNIROUTE_READY_TIMEOUT_MS; + assert.equal(resolveReadyTimeoutMs({ timeoutMs: 0 }), 60_000); + }); + + test("falls back to default when override is negative", () => { + delete process.env.OMNIROUTE_READY_TIMEOUT_MS; + assert.equal(resolveReadyTimeoutMs({ timeoutMs: -1 }), 60_000); + }); + + test("accepts fractional seconds as milliseconds", () => { + process.env.OMNIROUTE_READY_TIMEOUT_MS = "65536"; + assert.equal(resolveReadyTimeoutMs(), 65_536); + }); + + test("handles empty string env var as unset", () => { + process.env.OMNIROUTE_READY_TIMEOUT_MS = ""; + assert.equal(resolveReadyTimeoutMs(), 60_000); + }); +});