fix(engine): recognize Bun/JavaScriptCore's OOM message in isMemoryExhaustionError

Found while testing the previous commit's OOM-drops-to-1-worker fallback
end-to-end: the producer's deployed runtime is Bun (JavaScriptCore), not
Node (V8) — see packages/gcp-cloud-run/Dockerfile's `bun dist/server.js`
entrypoint. All 7 MEMORY_EXHAUSTION_ERROR_PATTERNS are V8-specific allocation
failure signatures; JSC's equivalent for the same single-oversized-allocation
RangeErrors is the bare string "Out of memory" (verified against real Bun
behavior), which none of them match. Without this, isMemoryExhaustionError
returns false for genuine production OOM, so the memory-specific worker-count
reduction just added would never actually engage where it's deployed — every
OOM would fall through to the generic capture_error retry path instead.

Matches the FULL (trimmed) message only, not merely a substring — same
rationale as the existing V8 patterns' comment: "out of memory" also appears
in benign WebGL/GPU console noise that must not trip this classifier.

Verified end-to-end from a script inside the producer workspace (importing
the real @hyperframes/engine source, not a stale globally-cached npm dist a
script outside the workspace would otherwise resolve to): a genuine Bun
RangeError from new Uint8Array(Number.MAX_SAFE_INTEGER) now correctly
classifies as memory exhaustion and drives both resolveInversionRetryPlan
and resolveParallelRouterRetryPlan down to workerCount=1 on retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-07-09 19:29:14 -07:00
co-authored by Claude Sonnet 5
parent 51353a7ed6
commit b3f244a7e9
2 changed files with 35 additions and 0 deletions
@@ -88,4 +88,25 @@ describe("isMemoryExhaustionError", () => {
expect(isTransientBrowserError(new Error("Set maximum size exceeded"))).toBe(false);
expect(isMemoryExhaustionError(new Error("Target closed"))).toBe(false);
});
// The producer's deployed runtime is Bun (JavaScriptCore), not Node (V8) —
// none of the V8-specific patterns above match JSC's allocation-failure
// message. Verified against real Bun behavior: `new
// Uint8Array(Number.MAX_SAFE_INTEGER)`, an unbounded `Set`, and
// `"x".repeat(2**53)` all throw exactly "Out of memory" under `bun run`.
it("recognizes Bun/JavaScriptCore's exact OOM message", () => {
expect(isMemoryExhaustionError(new Error("Out of memory"))).toBe(true);
expect(isMemoryExhaustionError(new Error("out of memory"))).toBe(true);
expect(isMemoryExhaustionError(new Error("Out of memory."))).toBe(true);
expect(isMemoryExhaustionError(new Error(" Out of memory "))).toBe(true);
});
// Exact-message match only — a compound message merely containing the
// phrase (e.g. wrapped with extra context) must NOT trip this, same
// rationale as the WebGL/GPU noise case above.
it("does not match 'out of memory' as a mere substring of a longer message", () => {
expect(isMemoryExhaustionError(new Error("Worker crashed: Out of memory during capture"))).toBe(
false,
);
});
});
@@ -3291,7 +3291,21 @@ const MEMORY_EXHAUSTION_ERROR_PATTERNS = [
/JavaScript heap out of memory/i,
];
// The producer's deployed runtime is Bun (JavaScriptCore), not Node (V8) —
// see `packages/gcp-cloud-run/Dockerfile`'s `CMD ["bun", "dist/server.js"]`.
// JSC's own allocation-failure message for the equivalent single-oversized-
// allocation RangeErrors above is the bare string "Out of memory" (verified:
// `new Uint8Array(Number.MAX_SAFE_INTEGER)`, an unbounded `Set`, and
// `"x".repeat(2**53)` all throw exactly this under Bun) — none of the V8
// patterns above match it. This is exactly the substring the comment above
// says NOT to match anywhere in the message (benign browser-console noise
// like a WebGL `CONTEXT_LOST … out of memory` carries that phrase too), so
// this checks the ENTIRE (trimmed) message equals it, not merely contains
// it — a compound message with other text around the phrase still misses.
const BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE = /^out of memory\.?$/i;
export function isMemoryExhaustionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
if (BUN_MEMORY_EXHAUSTION_EXACT_MESSAGE.test(message.trim())) return true;
return MEMORY_EXHAUSTION_ERROR_PATTERNS.some((pattern) => pattern.test(message));
}