feat(producer): auto low-memory safe render profile (#1225)

## What

Adds an auto-detected **low-memory safe render profile**. On hosts at or below 8 GB total RAM, the render pipeline collapses to its cheapest shape instead of running multiple concurrent Chrome instances.

When `lowMemoryMode` is active and the user hasn't passed `--workers`, the orchestrator:
- **skips auto-worker calibration** — no throwaway second Chrome just to time 5 frames;
- **pins to a single worker** — so the probe Chrome is reused for capture, never N concurrent;
- **prefers screenshot capture over BeginFrame** — avoids the BeginFrame protocol-timeout → relaunch churn on slow hardware;
- logs a one-line explanation of what it did and how to override.

Builds on #1221 (merged), which fixed the calibration timeout cap, the `<= 8192` boundary, and added the CLI timeout flags.

## Why

Reported in #1218 / #1219: renders on 8 GB laptops sit at low progress for minutes or stall. Root cause (per the triage thread) is architectural — the default pipeline launches up to 4 Chrome instances sequentially/overlapping (probe, calibration, capture, screenshot-fallback), each ~256 MB+, on machines with ~3 GB free. The concurrent browsers drive memory pressure that makes every CDP call slow and spikes V8 GC pauses.

#1221 made the timeouts and memory flags *apply correctly*; this PR removes the expensive shape entirely on the machines that can't afford it, rather than tuning it. "Smarter by default."

## How

- **`packages/engine/src/services/systemMemory.ts`** (new): one shared `isLowMemorySystem()` / `getSystemTotalMb()`, de-duplicating the `totalmem()` reads previously copied in `config.ts` and `browserManager.ts`. Threshold is inclusive (`<= 8192 MB`) — real "8 GB" hardware reports ~7600–8192 MB after firmware/iGPU reservations, so a strict `<` would skip the optimisation on the very hardware that needs it.
- **`config.ts`**: new `lowMemoryMode` field on `EngineConfig`, resolved tri-state — explicit override → `PRODUCER_LOW_MEMORY_MODE` (on/off) → auto-detect from total RAM.
- **`renderOrchestrator.ts`**: gate calibration off, pin workers to 1, force screenshot capture, and emit a safe-mode log line when `lowMemoryMode` is set and `--workers` is absent.
- **`render.ts`**: `--low-memory-mode` / `--no-low-memory-mode` override (sets the env var the producer's `resolveConfig` reads) + docs table entry.

Fully overridable: an explicit `--workers N` restores calibration-free parallelism; `--no-low-memory-mode` / `PRODUCER_LOW_MEMORY_MODE=false` restores the full default shape.

### Deliberately deferred (separate PRs)
- **Reuse the probe session for calibration**: only executes on the tier *above* 8 GB (safe-mode skips calibration on the target boxes). A correct BeginFrame-mode reuse would lose calibration's fast-fail-to-screenshot timeout — real risk on a path the reported scenario never hits. Better scoped on its own.
- **Retuning `calculateOptimalWorkers`'s `totalmem*0.5/256` memory model**: hot path for *all* renders incl. servers/Lambda, outside this PR's local-laptop scope.

## Test plan

- [x] Unit tests added/updated — `systemMemory.test.ts` (8192 boundary cases), `config.test.ts` (tri-state env resolution + explicit-override precedence). Engine suite passes (25 relevant tests).
- [x] `tsc` clean across engine/producer/cli; `oxlint` + `oxfmt` clean; removed an unused export so the `fallow --fail-on-issues` dead-code gate stays green.
- [x] Documentation updated — `docs/packages/cli.mdx` render-flags table.
- [ ] Manual testing on a real ≤ 8 GB host — not yet run; behaviour is unit-covered and the safe path (1 worker + screenshot) is already a supported render shape.

Note: one pre-existing producer test (`rejects a maliciously crafted key…`) fails identically on `main` — environment-specific path test, unrelated to this change.
This commit is contained in:
James Russo
2026-06-05 16:24:03 -07:00
committed by GitHub
parent a7cc9161a7
commit bacfb17538
11 changed files with 255 additions and 17 deletions
@@ -9,8 +9,9 @@ import type { Browser, PuppeteerNode } from "puppeteer-core";
import { execSync } from "child_process";
import { existsSync, readdirSync } from "fs";
import { join } from "path";
import { homedir, totalmem } from "os";
import { homedir } from "os";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { getSystemTotalMb, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
let _puppeteer: PuppeteerNode | undefined;
@@ -478,10 +479,6 @@ export function _setPuppeteerForTests(mock: PuppeteerNode | undefined): void {
_puppeteer = mock;
}
function getTotalMemMb(): number {
return Math.floor(totalmem() / (1024 * 1024));
}
let _cachedVramMb: number | null = null;
function probeNvidiaVramMb(): number | null {
@@ -508,15 +505,15 @@ function getGpuMemBudgetMb(): number {
const vram = probeNvidiaVramMb();
if (vram) return Math.min(vram, 16384);
const total = getTotalMemMb();
const total = getSystemTotalMb();
if (total < 4096) return 512;
if (total <= 8192) return 1024;
if (total <= LOW_MEMORY_TOTAL_MB_THRESHOLD) return 1024;
return Math.min(Math.floor(total / 2), 16384);
}
function getLowMemoryFlags(): string[] {
const total = getTotalMemMb();
if (total > 8192) return [];
const total = getSystemTotalMb();
if (total > LOW_MEMORY_TOTAL_MB_THRESHOLD) return [];
const heapMb = total < 4096 ? 256 : 512;
return [`--js-flags=--max-old-space-size=${heapMb}`];
}
@@ -0,0 +1,23 @@
import { describe, it, expect } from "vitest";
import { isLowMemorySystem, LOW_MEMORY_TOTAL_MB_THRESHOLD } from "./systemMemory.js";
describe("isLowMemorySystem", () => {
it("treats sub-threshold RAM as low-memory", () => {
expect(isLowMemorySystem(4096)).toBe(true);
expect(isLowMemorySystem(6000)).toBe(true);
expect(isLowMemorySystem(7600)).toBe(true);
});
it("includes machines reporting exactly the threshold (8 GB boundary)", () => {
// Real "8 GB" hosts report at/just under 8192 MiB after firmware/iGPU
// reservations — the inclusive bound is the whole point (issue #1219).
expect(isLowMemorySystem(LOW_MEMORY_TOTAL_MB_THRESHOLD)).toBe(true);
expect(isLowMemorySystem(8192)).toBe(true);
});
it("treats above-threshold RAM as normal", () => {
expect(isLowMemorySystem(8193)).toBe(false);
expect(isLowMemorySystem(16384)).toBe(false);
expect(isLowMemorySystem(65536)).toBe(false);
});
});
@@ -0,0 +1,52 @@
/**
* System-memory probing for memory-adaptive render behaviour.
*
* The render pipeline tunes itself to the host's RAM in several places —
* frame-cache sizes (`config.ts`), Chrome heap + GPU budget flags
* (`browserManager.ts`), and worker count (`parallelCoordinator.ts`).
* They all need the same "how much memory does this box have" reading, so
* it lives here once instead of being re-derived inline.
*/
import { totalmem } from "os";
/** Total physical RAM in MiB. */
export function getSystemTotalMb(): number {
return Math.floor(totalmem() / (1024 * 1024));
}
/**
* Total-RAM ceiling (MiB) at or below which the host is treated as
* memory-constrained. Tuned to the 8 GB laptops in
* heygen-com/hyperframes#1218 / #1219: on those boxes the default render
* shape (probe Chrome + a throwaway calibration Chrome + N capture
* workers) thrashes, so the pipeline collapses to its cheapest form.
*
* `<=` deliberately includes machines that report exactly 8192 MiB —
* real "8 GB" hardware reports anywhere from ~7600 to 8192 MiB once
* firmware/integrated-GPU reservations are subtracted, and a strict `<`
* would skip the optimisation on the very hardware that needs it.
*/
export const LOW_MEMORY_TOTAL_MB_THRESHOLD = 8192;
/**
* True when the host should run the low-memory render profile.
*
* Keyed on total physical RAM, not free memory: free memory swings
* moment to moment and is underreported on macOS, whereas total RAM is a
* stable proxy for "how many concurrent Chrome instances can this box
* survive". Accepts an explicit `totalMb` so callers (and tests) can pass
* a known value instead of re-probing.
*
* Caveat: `os.totalmem()` reports the *host's* physical RAM, not a
* cgroup/container memory limit. A 4 GB container on a 32 GB host will not
* auto-flag as low-memory, and an 8 GB container on a 64 GB host won't
* either. Containerised and serverless callers (Docker `--docker` renders,
* Lambda) that want a specific profile should set `PRODUCER_LOW_MEMORY_MODE`
* explicitly rather than relying on auto-detection. Hosts whose *total* RAM
* is genuinely <= the threshold (laptops, small VMs, small Lambda tiers) are
* detected correctly regardless of container nesting.
*/
export function isLowMemorySystem(totalMb: number = getSystemTotalMb()): boolean {
return totalMb <= LOW_MEMORY_TOTAL_MB_THRESHOLD;
}