From 771f1cfac9feb64607d573662ba5a92ad6e4e677 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:23:12 -0300 Subject: [PATCH] fix(build): raise Node heap for local next build to stop OOM/stall (#4171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.29 — raise Node heap for local next build (extends #4104 Docker OOM fix to the native path). Validated: 10/10 tests, typecheck:core, file-size, test-discovery, eslint green. --- scripts/build/build-next-isolated.mjs | 41 ++++++++++++++++++-------- tests/unit/build-next-isolated.test.ts | 30 +++++++++++++++++++ 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/scripts/build/build-next-isolated.mjs b/scripts/build/build-next-isolated.mjs index 5660ab8e99..071517d76f 100644 --- a/scripts/build/build-next-isolated.mjs +++ b/scripts/build/build-next-isolated.mjs @@ -107,16 +107,32 @@ export function resolveNextBuildBundlerFlag(baseEnv = process.env) { } export function resolveNextBuildEnv(baseEnv = process.env) { - return { + const env = { ...baseEnv, NEXT_PRIVATE_BUILD_WORKER: baseEnv.NEXT_PRIVATE_BUILD_WORKER || "0", }; + + // Raise the Node heap for the spawned `next build`. The webpack production pass + // ("Compiling instrumentation" bundles the whole server graph) is the heaviest + // phase and overflows V8's default ~2 GB ceiling on memory-constrained machines, + // stalling/OOMing local `npm run build` (npm-global installs). #4076/#4104 fixed + // this only in the Docker builder stage (ENV NODE_OPTIONS); the local/native path + // was left unprotected. Respect an existing --max-old-space-size (Docker already + // sets one — don't clobber/duplicate) and let OMNIROUTE_BUILD_MEMORY_MB override. + if (!/--max-old-space-size/.test(env.NODE_OPTIONS || "")) { + const heapMb = Number(baseEnv.OMNIROUTE_BUILD_MEMORY_MB) || 4096; + env.NODE_OPTIONS = `${env.NODE_OPTIONS || ""} --max-old-space-size=${heapMb}`.trim(); + } + + return env; } async function resetStandaloneOutput(rootDir = projectRoot, fsImpl = fs) { // Use the module-level distDir so NEXT_DIST_DIR is respected const resolvedDistDir = - rootDir === projectRoot ? distDir : path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next"); + rootDir === projectRoot + ? distDir + : path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next"); const standaloneRoot = path.join(resolvedDistDir, "standalone"); if (!(await exists(standaloneRoot))) return; @@ -128,7 +144,9 @@ async function resetStandaloneOutput(rootDir = projectRoot, fsImpl = fs) { export async function pruneStandaloneArtifacts(rootDir = projectRoot, fsImpl = fs) { const resolvedDistDirForPrune = - rootDir === projectRoot ? distDir : path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next"); + rootDir === projectRoot + ? distDir + : path.join(rootDir, process.env.NEXT_DIST_DIR || ".build/next"); const standaloneRoot = path.join(resolvedDistDirForPrune, "standalone"); const pruneTargets = [path.join(standaloneRoot, "_tasks")]; @@ -174,11 +192,9 @@ export async function main() { const standaloneDir = path.join(distDir, "standalone"); if (result.code === 0 && (await exists(standaloneDir))) { try { - await fs.cp( - path.join(projectRoot, "docs"), - path.join(standaloneDir, "docs"), - { recursive: true } - ); + await fs.cp(path.join(projectRoot, "docs"), path.join(standaloneDir, "docs"), { + recursive: true, + }); console.log("[build-next-isolated] Copied docs/ to standalone output"); } catch (docsCopyErr) { console.warn("[build-next-isolated] Non-fatal error copying docs/:", docsCopyErr?.message); @@ -194,7 +210,9 @@ export async function main() { } try { - console.log("[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..."); + console.log( + "[build-next-isolated] Assembling standalone bundle (static + public + natives + extras)..." + ); assembleStandalone({ distDir, outDir: standaloneDir, @@ -202,10 +220,7 @@ export async function main() { copyNatives: true, }); } catch (assembleErr) { - console.warn( - "[build-next-isolated] Non-fatal error assembling standalone:", - assembleErr - ); + console.warn("[build-next-isolated] Non-fatal error assembling standalone:", assembleErr); } } process.exitCode = result.code; diff --git a/tests/unit/build-next-isolated.test.ts b/tests/unit/build-next-isolated.test.ts index a235f42996..401edf6bc6 100644 --- a/tests/unit/build-next-isolated.test.ts +++ b/tests/unit/build-next-isolated.test.ts @@ -107,6 +107,36 @@ test("resolveNextBuildEnv forces stable build worker mode unless already provide assert.equal(preservedEnv.NODE_ENV, "production"); }); +// Escalated bug (WhatsApp BR, cmqiuhd7600): a local `npm run build` stalls/OOMs +// during the webpack production pass ("Compiling instrumentation" bundles the whole +// server graph). #4076/#4104 raised the heap only in the Docker builder stage; the +// local/native path (build-next-isolated.mjs → resolveNextBuildEnv) was left on V8's +// default ~2 GB ceiling, so memory-constrained npm-global installs hit the same OOM. +test("resolveNextBuildEnv raises the Node heap for memory-constrained local builds", () => { + const env = resolveNextBuildEnv({ NODE_ENV: "production" }); + const match = (env.NODE_OPTIONS ?? "").match(/--max-old-space-size=(\d+)/); + assert.ok( + match, + "local build must set NODE_OPTIONS --max-old-space-size to avoid the webpack-pass OOM" + ); + assert.ok( + Number(match[1]) >= 4096, + `build heap default must be >= 4096 MB (the V8 default ~2 GB OOMed); got ${match[1]}` + ); +}); + +test("resolveNextBuildEnv does not clobber an existing --max-old-space-size (Docker)", () => { + const env = resolveNextBuildEnv({ NODE_OPTIONS: "--max-old-space-size=8192" }); + const occurrences = (env.NODE_OPTIONS.match(/--max-old-space-size=/g) || []).length; + assert.equal(occurrences, 1, "must not duplicate the heap flag when one is already set"); + assert.match(env.NODE_OPTIONS, /--max-old-space-size=8192/); +}); + +test("resolveNextBuildEnv honors the OMNIROUTE_BUILD_MEMORY_MB override", () => { + const env = resolveNextBuildEnv({ OMNIROUTE_BUILD_MEMORY_MB: "6144" }); + assert.match(env.NODE_OPTIONS, /--max-old-space-size=6144/); +}); + test("getTransientBuildPaths leaves _tasks in place by default", () => { const paths = getTransientBuildPaths("/repo", {});