diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a7308b28f..6d9cfa258f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(sse): normalize array user-message content in the Command Code executor to prevent upstream 400** — when a client sends a user turn whose `content` is an array of content parts (e.g. `[{type:"text",text:"…"}, …]`), the raw array was forwarded verbatim to the Command Code upstream, which requires `messages[N].content` for the `user` role to be a plain string — resulting in `expected string, received array` / HTTP 400 on DeepSeek V4-Pro and other Command Code models. The user branch of `convertMessages` now calls `normalizeContentText()` (already used by system, assistant, and tool branches) so multi-part user content is joined to a string before dispatch. Partially addresses ([#5166](https://github.com/diegosouzapw/OmniRoute/issues/5166)); the 0-output-token symptom on reasoning-only models is tracked separately. - **fix(mcp): return HTTP 404 (not 400) for an unknown/expired Streamable HTTP session id** — when an MCP session is terminated or idles out and the client reuses the stale `Mcp-Session-Id` header, the Streamable HTTP transport replied with HTTP 400. The MCP spec (2025-03-26 and 2025-11-25, Session Management) mandates HTTP 404 Not Found in that case, and spec-compliant clients only re-initialize a session on 404 — so the 400 was non-recoverable. The handler now returns 404 for a present-but-unknown session id, while a *missing* session id on a non-initialize request correctly stays 400. ([#5169](https://github.com/diegosouzapw/OmniRoute/issues/5169) — thanks @czer323) - **fix(api): blocking "Auto (Zero-Config)" in Security settings now removes `auto/*` from `/v1/models`** — the built-in `auto/*` combo advertiser (#4164 / #4235) at the top of the models catalog ignored `settings.blockedProviders`, so checking **Auto (Zero-Config)** under Security → Blocked Providers had no effect and the model picker kept listing every `auto/*` entry. The injection loop now skips the entire `auto/*` block when the system provider `auto` (its id and alias are both `auto`) is blocked, consistent with how every other provider is filtered from the catalog. ([#5192](https://github.com/diegosouzapw/OmniRoute/issues/5192) — thanks @WslzGmzs) +- **fix(cli): auto-calibrate the server V8 heap from physical RAM instead of a fixed 512MB default** — the server was spawned with a hard-coded `--max-old-space-size=512` (`omniroute serve`) or with no heap flag at all (Electron desktop, which then inherited the runtime's low ~512MB default), so RAM-rich machines still OOM-crashed under load (`FATAL ERROR: Ineffective mark-compacts near heap limit … ~500MB` at code=134) with many providers/accounts and large model catalogs (one report: 16GB RAM, 65 providers, ~100 accounts, ~2600 models). A new `calibrateHeapFallbackMb(os.totalmem())` helper derives the default heap as ~35% of physical RAM, clamped to `[512, 4096]`, and is wired into both `bin/cli/commands/serve.mjs` and `electron/main.js`. An explicit `OMNIROUTE_MEMORY_MB` (or a pre-set `--max-old-space-size`) still wins, so the #2939 override contract is unchanged. ([#5172](https://github.com/diegosouzapw/OmniRoute/issues/5172), [#5160](https://github.com/diegosouzapw/OmniRoute/issues/5160), [#5152](https://github.com/diegosouzapw/OmniRoute/issues/5152) — thanks @manchairwang, @Xyzjesus) --- diff --git a/bin/cli/commands/serve.mjs b/bin/cli/commands/serve.mjs index af56c49e21..45c6a06a30 100644 --- a/bin/cli/commands/serve.mjs +++ b/bin/cli/commands/serve.mjs @@ -2,11 +2,15 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; -import { platform } from "node:os"; +import { platform, totalmem } from "node:os"; import { t } from "../i18n.mjs"; import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs"; import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs"; import { isTermux } from "../../../scripts/build/postinstallSupport.mjs"; +import { + resolveMaxOldSpaceMb, + calibrateHeapFallbackMb, +} from "../../../scripts/build/runtime-env.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = join(__dirname, "..", "..", ".."); @@ -126,9 +130,13 @@ export async function runServe(opts = {}) { console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`); - const rawMemory = parseInt(process.env.OMNIROUTE_MEMORY_MB || "512", 10); - const memoryLimit = - Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512; + // #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()) + ); const env = { ...process.env, diff --git a/electron/main.js b/electron/main.js index b9e9ff486f..5d37a91c22 100644 --- a/electron/main.js +++ b/electron/main.js @@ -614,8 +614,32 @@ function startNextServer() { const nodeExecutable = resolveNodeExecutable(serverEnv); + // #5172/#5160/#5152: the Electron-spawned server inherited the runtime's low + // default V8 heap (~512MB) and OOM-crashed on RAM-rich boxes under load + // (65 providers / 2600 models → "Ineffective mark-compacts near heap limit"). + // Default the heap to ~35% of physical RAM (clamped [512, 4096]); an explicit + // OMNIROUTE_MEMORY_MB or a pre-set --max-old-space-size still wins. Mirrors + // scripts/build/runtime-env.mjs (CJS can't import the ESM helper). + const serverNodeOptions = (() => { + const existing = serverEnv.NODE_OPTIONS || ""; + if (existing.includes("--max-old-space-size")) return existing; + const explicit = parseInt(serverEnv.OMNIROUTE_MEMORY_MB, 10); + let heapMb; + if (Number.isFinite(explicit) && explicit >= 64 && explicit <= 16384) { + heapMb = explicit; + } else { + const totalMb = require("os").totalmem() / (1024 * 1024); + heapMb = + Number.isFinite(totalMb) && totalMb > 0 + ? Math.min(4096, Math.max(512, Math.floor(totalMb * 0.35))) + : 512; + } + return `${existing} --max-old-space-size=${heapMb}`.trim(); + })(); + console.log("[Electron] Starting Next.js server on port", serverPort); console.log("[Electron] Using Node executable:", nodeExecutable); + console.log("[Electron] Server NODE_OPTIONS:", serverNodeOptions); sendToRenderer("server-status", { status: "starting", port: serverPort }); // Fix #10: Use pipe instead of inherit for logging & readiness detection @@ -630,6 +654,7 @@ function startNextServer() { NODE_ENV: "production", ELECTRON_RUN_AS_NODE: "1", NODE_PATH: resolveServerNodePath(serverEnv), + NODE_OPTIONS: serverNodeOptions, }, stdio: "pipe", windowsHide: true, diff --git a/scripts/build/runtime-env.mjs b/scripts/build/runtime-env.mjs index 76b8df8b92..6423183756 100644 --- a/scripts/build/runtime-env.mjs +++ b/scripts/build/runtime-env.mjs @@ -19,6 +19,23 @@ export function resolveMaxOldSpaceMb(value, fallback = 512) { return Number.isFinite(parsed) && parsed >= 64 && parsed <= 16384 ? parsed : fallback; } +/** + * Derive a sane DEFAULT V8 heap ceiling (MB) from the host's physical RAM, used + * when `OMNIROUTE_MEMORY_MB` is unset. A fixed 512MB default crashed boxes with + * plenty of RAM under load (65 providers / 2600 models → "Ineffective + * mark-compacts near heap limit ~500MB"); see #5172 / #5160 / #5152. Targets + * ~35% of total RAM, clamped to [512, 4096]. Invalid/zero totalmem → 512. + * Pass the result as the `fallback` of {@link resolveMaxOldSpaceMb} so an + * explicit OMNIROUTE_MEMORY_MB override always wins. + * @param {number | undefined | null} totalmemBytes — typically `os.totalmem()` + */ +export function calibrateHeapFallbackMb(totalmemBytes) { + const totalMb = Number(totalmemBytes) / (1024 * 1024); + if (!Number.isFinite(totalMb) || totalMb <= 0) return 512; + const target = Math.floor(totalMb * 0.35); + return Math.min(4096, Math.max(512, target)); +} + /** * @param {NodeJS.ProcessEnv | Record} [fromEnv] * Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn. diff --git a/tests/unit/heap-autocalibrate-5172.test.ts b/tests/unit/heap-autocalibrate-5172.test.ts new file mode 100644 index 0000000000..ae781cf91b --- /dev/null +++ b/tests/unit/heap-autocalibrate-5172.test.ts @@ -0,0 +1,50 @@ +/** + * Issue #5172 / #5160 / #5152 — server OOM ("Ineffective mark-compacts near heap + * limit ... ~500MB") on machines with plenty of RAM. Root cause: the server was + * spawned with a FIXED 512MB heap default (`omniroute serve`) or with no + * `--max-old-space-size` at all (Electron), so a 16GB box with 65 providers / + * 2600 models still crashed at ~512MB. + * + * Fix: `calibrateHeapFallbackMb(totalmemBytes)` derives a sane default heap from + * the host's physical RAM (~35%, clamped to [512, 4096]) so the out-of-the-box + * ceiling scales with the machine. An explicit `OMNIROUTE_MEMORY_MB` still wins + * (resolveMaxOldSpaceMb), and the existing #2939 contract is unchanged. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { calibrateHeapFallbackMb, resolveMaxOldSpaceMb } = + await import("../../scripts/build/runtime-env.mjs"); + +const GB = 1024 * 1024 * 1024; + +test("#5172 calibrates the default heap to ~35% of physical RAM", () => { + // 8GB → 8192 * 0.35 ≈ 2867 + assert.equal(calibrateHeapFallbackMb(8 * GB), 2867); + // 4GB → floor(4096 * 0.35) = 1433 + assert.equal(calibrateHeapFallbackMb(4 * GB), 1433); +}); + +test("#5172 clamps the calibrated default to [512, 4096]", () => { + // 16GB → 5734 → clamped to the 4096 ceiling (the reporter's box) + assert.equal(calibrateHeapFallbackMb(16 * GB), 4096); + assert.equal(calibrateHeapFallbackMb(64 * GB), 4096); + // 1GB → 358 → floored to 512 + assert.equal(calibrateHeapFallbackMb(1 * GB), 512); +}); + +test("#5172 falls back to 512 for missing/invalid totalmem", () => { + assert.equal(calibrateHeapFallbackMb(0), 512); + assert.equal(calibrateHeapFallbackMb(undefined), 512); + assert.equal(calibrateHeapFallbackMb(null), 512); + assert.equal(calibrateHeapFallbackMb(NaN), 512); + assert.equal(calibrateHeapFallbackMb(-1), 512); +}); + +test("#5172 an explicit OMNIROUTE_MEMORY_MB still wins over the calibrated default", () => { + const calibrated = calibrateHeapFallbackMb(16 * GB); // 4096 + // explicit override (in-range) is honored verbatim, not the calibrated default + assert.equal(resolveMaxOldSpaceMb("1536", calibrated), 1536); + // unset → the calibrated default is used (not the old fixed 512) + assert.equal(resolveMaxOldSpaceMb(undefined, calibrated), 4096); +});