feat(cli): make startup readiness budget configurable (#13369) (#13433)

The CLI readiness budget is configurable through `OMNIROUTE_READY_TIMEOUT_MS` or `omniroute serve --ready-timeout <ms>` (default unchanged at 60s). The timeout warning prints the budget it actually used and suggests a larger value, for slow cold starts such as Windows (#13369). Documented in `ENVIRONMENT.md` and `TROUBLESHOOTING.md`, with 11 resolver cases.

Validated in one consolidated batch of this series (37 PRs boarded together on `release/v3.8.51`): `typecheck:core`, `check:open-sse-typecheck` and `check:dashboard-typecheck` clean; ESLint clean on every changed file; file-size, complexity, cognitive-complexity, changelog-integrity, docs-counts, docs-sync and migration-numbering gates green (only the pre-existing `open-sse/utils/stream.ts` file-size red remains, inherited from the base); 3,743 focused `node:test` cases plus 34 vitest cases green.

Thanks @KooshaPari!
This commit is contained in:
Koosha Paridehpour
2026-09-14 19:24:46 -07:00
committed by GitHub
parent 1738beb0fe
commit fc111dc196
6 changed files with 132 additions and 4 deletions

View File

@@ -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 = {

View File

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

View File

@@ -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;

View File

@@ -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)

View File

@@ -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). |

View File

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