diff --git a/docs/testing.md b/docs/testing.md index 8d277f604d..7ca1ce4877 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -91,6 +91,9 @@ was SIGKILLed by heap pressure under workspace worker budgeting. The top-level `pretest` artifact bootstrap runs once before the orchestrator; lane subprocesses must not re-run `scripts/ensure-test-artifacts.mjs`. + +When `scripts/test-changed.mjs` runs affected-package `vitest --changed` scopes, `@fusion/engine` is split out from other scopable packages and gets a dedicated memory envelope: `NODE_OPTIONS=--max-old-space-size=6144` plus `FUSION_TEST_TOTAL_WORKERS=1`, `FUSION_TEST_CONCURRENCY=1`, and `VITEST_MAX_WORKERS=1`. This mirrors the dashboard heap-managed precedent while preserving the `runWithWatchdog` changed-class wall-clock budget. Re-measure with a wide changed selection (for example a dirty `packages/core/src/index.ts` boundary edit) before changing this envelope; the expected failure mode after the fix is a normal Vitest pass/fail verdict, not raw pnpm `SIGKILL`. + Concurrency knobs: - `FUSION_DASHBOARD_TEST_CONCURRENCY` controls dashboard quality lane process diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 300cf13cac..07fae0f07c 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -34,8 +34,15 @@ import { buildForwardDependencyMap, collectTransitiveDependencies, computeOwnHash, + createEngineScopedAffectedEnv, + ENGINE_SCOPED_AFFECTED_HEAP_MB, + ENGINE_SCOPED_AFFECTED_PACKAGE, + ENGINE_SCOPED_AFFECTED_WORKERS, + partitionScopedAffectedPackages, } from "../test-changed.mjs"; +import { deriveBudgetMs } from "../lib/run-vitest-watchdog.mjs"; + import { mkdirSync, writeFileSync, mkdtempSync, rmSync, existsSync, utimesSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -253,6 +260,44 @@ test("buildPackageDirByName: uses canonical workspace dirs instead of package al assert.notEqual(result.get("@fusion/engine"), "engine"); }); +// --------------------------------------------------------------------------- +// engine scoped affected memory envelope +// --------------------------------------------------------------------------- + +test("partitionScopedAffectedPackages: isolates engine from other scoped vitest lanes", () => { + const groups = partitionScopedAffectedPackages([ + "@fusion/core", + ENGINE_SCOPED_AFFECTED_PACKAGE, + "@fusion/dashboard", + ]); + + assert.deepEqual(groups, [ + { packages: ["@fusion/core", "@fusion/dashboard"], engineMemoryEnvelope: false }, + { packages: [ENGINE_SCOPED_AFFECTED_PACKAGE], engineMemoryEnvelope: true }, + ]); +}); + +test("createEngineScopedAffectedEnv: caps heap, lowers workers, and leaves watchdog budget finite", () => { + const env = createEngineScopedAffectedEnv({ + NODE_OPTIONS: "--trace-warnings", + FUSION_TEST_TOTAL_WORKERS: "8", + FUSION_TEST_CONCURRENCY: "4", + VITEST_MAX_WORKERS: "4", + HOME: "/tmp/fusion-home", + }); + + assert.match(env.NODE_OPTIONS, new RegExp(`--max-old-space-size=${ENGINE_SCOPED_AFFECTED_HEAP_MB}`)); + assert.match(env.NODE_OPTIONS, /--trace-warnings/); + assert.equal(env.FUSION_TEST_TOTAL_WORKERS, ENGINE_SCOPED_AFFECTED_WORKERS); + assert.equal(env.FUSION_TEST_CONCURRENCY, ENGINE_SCOPED_AFFECTED_WORKERS); + assert.equal(env.VITEST_MAX_WORKERS, ENGINE_SCOPED_AFFECTED_WORKERS); + assert.equal(env.HOME, "/tmp/fusion-home"); + + const budgetMs = deriveBudgetMs({ klass: "changed" }); + assert.equal(Number.isFinite(budgetMs), true); + assert.equal(budgetMs > 0, true); +}); + // --------------------------------------------------------------------------- // decideExecutionPlan // --------------------------------------------------------------------------- diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index cd5705a129..fe9108afed 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -1311,6 +1311,38 @@ export function packageHasVitestConfig(pkgDir, projectRoot = rootDir) { return VITEST_CONFIG_BASENAMES.some((name) => existsSync(path.join(projectRoot, pkgDir, name))); } +export const ENGINE_SCOPED_AFFECTED_PACKAGE = "@fusion/engine"; +export const ENGINE_SCOPED_AFFECTED_HEAP_MB = "6144"; +export const ENGINE_SCOPED_AFFECTED_WORKERS = "1"; + +export function prependNodeOption(currentOptions, option) { + return [option, currentOptions || ""].join(" ").trim(); +} + +export function createEngineScopedAffectedEnv(env = process.env) { + /* + FNXC:TestInfrastructure 2026-06-21-11:24: + The engine affected lane can select hundreds of real-git-heavy files when `vitest --changed` sees a widely imported boundary. Run that scoped lane in its own memory envelope: cap Node old-space like the dashboard heap runner and lower Vitest worker fan-out to one process so the lane returns a real pass/fail verdict instead of an OS OOM SIGKILL. Keep watchdog timing outside this env so hangs still fail through `runWithWatchdog`. + */ + return { + ...env, + NODE_OPTIONS: prependNodeOption(env.NODE_OPTIONS, `--max-old-space-size=${ENGINE_SCOPED_AFFECTED_HEAP_MB}`), + FUSION_TEST_TOTAL_WORKERS: ENGINE_SCOPED_AFFECTED_WORKERS, + FUSION_TEST_CONCURRENCY: ENGINE_SCOPED_AFFECTED_WORKERS, + VITEST_MAX_WORKERS: ENGINE_SCOPED_AFFECTED_WORKERS, + }; +} + +export function partitionScopedAffectedPackages(packages) { + const regularPackages = packages.filter((pkg) => pkg !== ENGINE_SCOPED_AFFECTED_PACKAGE); + const groups = []; + if (regularPackages.length > 0) groups.push({ packages: regularPackages, engineMemoryEnvelope: false }); + if (packages.includes(ENGINE_SCOPED_AFFECTED_PACKAGE)) { + groups.push({ packages: [ENGINE_SCOPED_AFFECTED_PACKAGE], engineMemoryEnvelope: true }); + } + return groups; +} + export function normalizeForwardedArgs(argv) { const normalized = []; @@ -1524,8 +1556,8 @@ export async function main(argv = process.argv.slice(2)) { : []; const fallbackPkgs = activePackages.filter((pkg) => !scopable.includes(pkg)); - for (const { packages, mode } of [ - { packages: scopable, mode: "scoped" }, + for (const { packages, mode, engineMemoryEnvelope = false } of [ + ...partitionScopedAffectedPackages(scopable).map((group) => ({ ...group, mode: "scoped" })), { packages: fallbackPkgs, mode: "full" }, ]) { if (packages.length === 0) continue; @@ -1548,11 +1580,11 @@ export async function main(argv = process.argv.slice(2)) { : [...filterArgs, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs]; console.log( mode === "scoped" - ? `[test-changed] scoped (vitest --changed) run for: ${packages.join(", ")}` + ? `[test-changed] scoped (vitest --changed) run for: ${packages.join(", ")}${engineMemoryEnvelope ? " (engine memory envelope)" : ""}` : `[test-changed] full package-suite run for: ${packages.join(", ")} (no vitest config / no base)`, ); await runMaybeIsolated("pnpm", commandArgs, { - env: isolatedHomeEnv, + env: engineMemoryEnvelope ? createEngineScopedAffectedEnv(isolatedHomeEnv) : isolatedHomeEnv, onBeforeAfterCheck: cleanupIsolatedHome, // Scoped runs are proportional to the diff, so the tight "changed" ceiling // applies; a hang fails fast instead of blocking the 60-min full backstop.