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
1 change: 1 addition & 0 deletions bin/cli/commands/env.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
17 changes: 14 additions & 3 deletions bin/cli/commands/serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -58,6 +58,11 @@ export function registerServe(program) {
.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") ||
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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`);

Expand Down
19 changes: 18 additions & 1 deletion bin/cli/utils/pid.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions docs/guides/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |

Expand Down
71 changes: 71 additions & 0 deletions tests/unit/cli/ready-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading