fix(cli): auto-calibrate server V8 heap from physical RAM (#5172) (#5213)

The server was spawned with a fixed --max-old-space-size=512 (omniroute serve)
or no heap flag at all (Electron), so RAM-rich boxes still OOM-crashed under
load (Ineffective mark-compacts near heap limit ~500MB) with many providers/
accounts and large model catalogs. New calibrateHeapFallbackMb(os.totalmem())
defaults the heap to ~35% of RAM clamped [512,4096], wired into serve.mjs and
electron/main.js. Explicit OMNIROUTE_MEMORY_MB still wins (#2939 unchanged).

Also addresses #5160 (same OOM root); #5152 (docker) benefits via the same knob.

Closes #5172
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-28 03:56:10 -03:00
committed by GitHub
parent a5cc116397
commit c57aa04d41
5 changed files with 105 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, string | undefined>} [fromEnv]
* Defaults to process.env. Pass bootstrap `merged` so project `.env` PORT applies before spawn.

View File

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