Files
scriptc/scripts/ci-test-plan.mjs
T
Malte UblandClaude Opus 5.5 041565d7f2 Split the slowest sanitized suites by case and raise the sanitized bootstrap's stack
Under ASan, self-hosting-llvm-emitter, three-library, npm-static and npm
each take 20 to 40 minutes as one Vitest file, so a sharded run cannot
finish before them.

- self-hosting-llvm-emitter splits into its emitter-cases-and-programs
  and fixed-point halves; a shard builds the seed once for the halves it
  owns. three-library skips the optimization its shard does not own.
  Both put their tests in a describe block, which skipUnownedTest
  partitions.
- CI runs both in every shard's case-sharded side queue
  (scripts/ci-test-plan.mjs); pnpm test:sandbox case-shards them with npm
  and npm-static.
- npm.test.ts keeps a skipped placeholder in a shard that owns no
  program, so an empty shard does not fail the file.

The sanitized native-driver bootstrap ran its instrumented compiler with
the default 8 MiB stack. Its frontend phase overflows it (SC3004) on main
as well, outside CI's ulimit for the emitter phase. The test now raises
the soft stack limit to the hard one for every sanitized compiler process
it starts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 05:32:27 -07:00

269 lines
10 KiB
JavaScript

import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
// Case-sharded harnesses run in every job without Vitest file sharding.
export const effectFiles = ["tests/harness/effect.test.ts", "tests/harness/effect4.test.ts"];
export const corpusFiles = [
"tests/harness/differential.test.ts",
"tests/harness/llvm-differential.test.ts",
"tests/harness/npm.test.ts",
"tests/harness/server.test.ts",
"tests/harness/test262.test.ts",
"tests/harness/three.test.ts",
];
const coverageFile = "tests/harness/coverage.test.ts";
const cacheFile = "packages/compiler/src/backend/native-toolchain.test.ts";
// Expensive integration suites run in their own vitest process, serially
// beside the corpus, avoiding worker contention between large compilations.
export const isolatedFiles = [
["self-hosting serialization", "tests/harness/self-hosting-serialization.test.ts"],
["self-hosting", "tests/harness/self-hosting.test.ts"],
["fetch", "tests/harness/fetch.test.ts"],
["surface manifest", "tests/harness/surface-manifest.test.ts"],
];
// Suites that split their own tests across the shards (SCRIPTC_TEST_SHARD,
// skipUnownedTest in tests/harness/shard.ts): every shard runs its share in
// its own vitest process.
export const caseShardedSideFiles = [
["npm static", "tests/harness/npm-static.test.ts"],
["self-hosting LLVM emitter", "tests/harness/self-hosting-llvm-emitter.test.ts"],
["three.js library", "tests/harness/three-library.test.ts"],
];
export const fileExclusions = [
...corpusFiles,
...effectFiles,
coverageFile,
cacheFile,
...isolatedFiles.map(([, file]) => file),
...caseShardedSideFiles.map(([, file]) => file),
"tests/harness/self-hosting-native-driver.test.ts",
];
// Milliseconds each side-queue file takes on a CI runner, per flavor, from the
// logs of green main runs (scripts/ci-test-costs.mjs refreshes it).
export const testCosts = JSON.parse(
readFileSync(new URL("./ci-test-costs.json", import.meta.url), "utf8"),
);
// Side-queue files whose process tree, run alone, peaks far above a plain
// vitest run (about 1 GB): megabytes at the peak, measured with CI's settings.
// They run beside the corpus task's three workers, so no two of them share a
// shard while there are shards without one.
export const memoryHeavyFiles = {
"tests/harness/decl-boundary.test.ts": 1838,
};
// The shard (1-based) of each measured side-queue file and isolated suite:
// longest first to the shard with the least work so far, counting about a
// second per file for vitest to import it. Ties go to the highest shard, so the
// longest files start from the last shard while the cost-balanced corpus and
// three.js cases (tests/harness/shard.ts) place their longest from the first.
// An isolated suite or memory-heavy file without a measurement weighs as much
// as the median measured file. Memory-heavy files are placed after the others,
// each on the shard with the least memory-heavy work so far and, among those,
// the least work. Files without a
// measurement are left to vitest's --shard, which splits them by count.
export function sideOwners(flavor, count) {
const costs = testCosts[flavor];
const known = Object.values(costs).sort((a, b) => a - b);
const median = known.length === 0 ? 0 : known[Math.floor(known.length / 2)];
const weight = (file) => (costs[file] ?? median) + 1000;
const heavy = Object.keys(memoryHeavyFiles).sort(
(a, b) => memoryHeavyFiles[b] - memoryHeavyFiles[a] || (a < b ? -1 : a > b ? 1 : 0),
);
const files = [
...new Set([...Object.keys(costs), ...isolatedFiles.map(([, file]) => file)]),
].filter((file) => !(file in memoryHeavyFiles));
files.sort((a, b) => weight(b) - weight(a) || (a < b ? -1 : a > b ? 1 : 0));
const loads = new Array(count).fill(0);
const memory = new Array(count).fill(0);
const owners = new Map();
for (const file of files) {
let lightest = count - 1;
for (let shard = count - 2; shard >= 0; shard--)
if (loads[shard] < loads[lightest]) lightest = shard;
loads[lightest] += weight(file);
owners.set(file, lightest + 1);
}
for (const file of heavy) {
let lightest = count - 1;
for (let shard = count - 2; shard >= 0; shard--)
if (
memory[shard] < memory[lightest] ||
(memory[shard] === memory[lightest] && loads[shard] < loads[lightest])
)
lightest = shard;
memory[lightest] += memoryHeavyFiles[file];
loads[lightest] += weight(file);
owners.set(file, lightest + 1);
}
return owners;
}
// Pull requests and pushes to main run the full suite on Linux. Pull requests
// that change darwin code (scripts/ci-lanes.mjs) also run the darwin suite on
// macOS, which keeps the darwin clang/libc/kqueue oracles: the Node
// differential corpus, plus every file whose assertions run only on darwin or
// take darwin-specific branches (including the Mach-O library localization arm
// and the arm64 runtime string oracle). Pushes to main run no macOS jobs. The
// nightly schedule and manual runs run the full suite on macOS, or the darwin
// suite with macos_suite set to darwin.
export const darwinCorpusFiles = ["tests/harness/differential.test.ts"];
export const darwinFiles = [
"packages/cli/test/bootstrap.test.ts",
"packages/cli/test/native-link-info.test.ts",
"packages/cli/test/native-output.test.ts",
"packages/cli/test/runtime-pack.test.ts",
"packages/compiler/test/debug-variables.test.ts",
"packages/compiler/test/native-codegen-integration.test.ts",
"packages/compiler/test/source-debugging.test.ts",
"packages/runtime-darwin-arm64/test/archive.test.ts",
"packages/runtime/test/string.test.ts",
"tests/harness/ffi-callback-pool.test.ts",
"tests/harness/ffi-catalog.test.ts",
"tests/harness/ffi-frameworks.test.ts",
"tests/harness/library-callbacks.test.ts",
"tests/harness/library-dispatch.test.ts",
"tests/harness/library-multi.test.ts",
"tests/harness/native-object-example.test.ts",
"tests/harness/runtime-tree-shaking.test.ts",
"tests/harness/terminal-geometry.test.ts",
];
// LeakSanitizer over the sanitized corpus on Linux (macOS ASan has none, and
// the workflow keeps it off everywhere else): a leak fails its program. The
// suppressions file lists intentional leaks, with the reason for each.
export const leakSuppressions = fileURLToPath(
new URL("../tests/harness/lsan.supp", import.meta.url),
);
// The sanitized three.js nodes build runs in its own Linux job
// (three_nodes_san in .github/workflows/ci.yml). Beside the corpus's other
// workers it took 860 to more than 1200 seconds, past its test timeout.
export const sanitizedNodesJobEnv = { SCRIPTC_THREE_SANITIZED_NODES_JOB: "1" };
function corpusThreeEnv(flavor, platform) {
return flavor === "san" && platform === "linux" ? sanitizedNodesJobEnv : {};
}
function corpusLeakEnv(flavor, platform) {
if (flavor !== "san" || platform !== "linux") return {};
return {
ASAN_OPTIONS: "detect_leaks=1",
LSAN_OPTIONS: `suppressions=${leakSuppressions}:print_suppressions=0`,
};
}
export function ciTestPlan({
shard,
flavor,
workers = 4,
suite = "full",
platform = process.platform,
}) {
const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(shard);
if (
!match ||
!Number.isSafeInteger(Number(match[1])) ||
!Number.isSafeInteger(Number(match[2])) ||
Number(match[1]) > Number(match[2])
)
throw new Error("invalid CI shard");
if (flavor !== "plain" && flavor !== "san") throw new Error("invalid CI flavor");
if (!Number.isInteger(workers) || workers < 1) throw new Error("invalid CI worker budget");
if (suite !== "full" && suite !== "darwin") throw new Error("invalid CI suite");
const index = Number(match[1]),
count = Number(match[2]);
const sideWorkers = workers === 1 ? 0 : 1;
const env = { SCRIPTC_TEST_SHARD: shard, SCRIPTC_SAN: flavor === "san" ? "1" : "" };
if (suite === "darwin") {
// vitest runs one file's cases in sequence, so the shard's corpus cases
// run as two halves in separate processes: case shards 2i-1 and 2i of
// 2n. The second half follows the darwin contracts in the side queue.
const half = (part) => ({
name: `case-sharded differential corpus ${part}/2`,
args: ["test", ...darwinCorpusFiles],
env: {
...env,
SCRIPTC_TEST_SHARD: `${2 * index - 2 + part}/${2 * count}`,
SCRIPTC_TEST_WORKERS: "1",
},
});
return {
sideWorkers,
corpus: half(1),
side: [
{
name: "darwin contracts",
args: ["test", "--passWithNoTests", "--shard=" + shard, ...darwinFiles],
env: { ...env, SCRIPTC_TEST_WORKERS: "1" },
},
half(2),
],
};
}
const owners = sideOwners(flavor, count);
const isolated = new Set(isolatedFiles.map(([, file]) => file));
const balanced = [...owners]
.filter(([file, owner]) => owner === index && !isolated.has(file))
.map(([file]) => file)
.sort();
const side = [
{
name: "file-sharded tests",
args: [
"test",
"--passWithNoTests",
"--shard=" + shard,
...[...new Set([...fileExclusions, ...owners.keys()])].map((file) => "--exclude=" + file),
],
env: { ...env, SCRIPTC_TEST_WORKERS: "1" },
},
];
if (balanced.length > 0)
side.push({
name: "cost-balanced tests",
args: ["test", "--passWithNoTests", ...balanced],
env: { ...env, SCRIPTC_TEST_WORKERS: "1" },
});
for (const [name, file] of isolatedFiles) {
if (owners.get(file) === index)
side.push({ name, args: ["test", file], env: { ...env, SCRIPTC_TEST_WORKERS: "1" } });
}
for (const [name, file] of caseShardedSideFiles)
side.push({ name, args: ["test", file], env: { ...env, SCRIPTC_TEST_WORKERS: "1" } });
// Frontend coverage and cache invalidation do not consume sanitizer state.
if (flavor === "plain") {
side.push({
name: "native-cache correctness",
args: ["test", cacheFile],
env: {
...env,
SCRIPTC_TEST_WORKERS: "1",
SCRIPTC_CACHE_TEST_SHARD: shard,
SCRIPTC_TEST_STABLE_TOOLCHAIN: "0",
},
});
side.push({
name: "coverage sweep",
args: ["test", coverageFile],
env: { ...env, SCRIPTC_TEST_WORKERS: "1" },
});
}
return {
sideWorkers,
corpus: {
name: "case-sharded corpus",
args: ["test", ...corpusFiles],
env: {
...env,
...corpusLeakEnv(flavor, platform),
...corpusThreeEnv(flavor, platform),
SCRIPTC_TEST_WORKERS: String(workers - sideWorkers),
SCRIPTC_LLVM_TEST_MODE: "dev",
},
},
side,
};
}