Skip to content

Commit 3404bd1

Browse files
Elon Muskclaude
andauthored
fix(scripts): pin the type-check re-measure to CI's own heap ceiling (#12957)
`check:type-check-debt --re-measure` ran tsc under V8's default old-space size, which V8 derives from the physical memory of the box the process starts on. So the gate measured a different world on every machine while only CI's verdict counts: three devs ran it on the agent container against a tree CI had already OOM'd on, and all three read green. A local pass was never a claim about CI, and nothing said so. The ceiling now lives in the gate's own harness, so every caller is CI-shaped without having to remember a flag. The number is read off CI, not off this container: run 33136681083, job `Type Check · debt ledger`, where V8 gave up at `Mark-Compact 4040.3 (4143.8) -> 4029.5 (4147.5) MB` -- bracketing that runner's old space into [4040, 4148] MB, a window whose only V8 default is 4096. The rule is a minimum, never a raise: min(CI's ceiling, this process's own limit, a caller's tighter NODE_OPTIONS cap). A box smaller than CI keeps its own lower ceiling, because promising V8 memory the box lacks converts a recoverable heap error into an exit-137 SIGKILL that carries no diagnostic (the lesson `packages/spec/tsup.config.ts` records from the build side). A caller's ROOMIER cap is refused: it would hand back the exact green-here-red-there reading this ceiling abolishes. The other direction is the one nothing else can catch, so the runner measures it on itself: if CI's own default ever drops BELOW the pinned constant, the pin has stopped describing CI, every local run is silently roomier than CI again, and `--re-measure` refuses on CI naming the reading to re-pin from. On every green run the same line prints the runner's own limit into the log, so the constant stays re-derivable from any CI log of this step instead of from archaeology through a failed job's GC trace. 14 new `--self-test` cases pin all of it, including the two controls that make the family able to fail at all: the roomier-caller refusal, and the same small-box reading OFF ci, which is an ordinary laptop and not a stale pin. Part of #12856 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc8a339 commit 3404bd1

1 file changed

Lines changed: 286 additions & 1 deletion

File tree

scripts/check-type-check-coverage.mjs

Lines changed: 286 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,7 @@ import { spawnSync } from 'node:child_process';
447447
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
448448
import { tmpdir } from 'node:os';
449449
import { join, posix, resolve } from 'node:path';
450+
import { getHeapStatistics } from 'node:v8';
450451
import {
451452
selfTest as workspaceEnumeratorSelfTest,
452453
workspacePackageDirs,
@@ -2716,6 +2717,163 @@ function countTscErrors(output, { dropRootDirDiagnostics = false } = {}) {
27162717
return n;
27172718
}
27182719

2720+
// ---------------------------------------------------------------------------
2721+
// The heap ceiling `--re-measure` runs tsc under (#12856).
2722+
//
2723+
// ## The defect this closes
2724+
//
2725+
// tsc's ceiling is V8's default old-space size, and V8 derives that from the
2726+
// PHYSICAL MEMORY of the box the process starts on. Nothing here said so, so
2727+
// this gate measured a different world on every machine -- while the only
2728+
// verdict that counts is CI's. Three devs ran `--re-measure` on the agent
2729+
// container against the tree below, all three read green, and CI OOM'd on that
2730+
// same tree. ⚠️ The asymmetry IS the defect: a local pass was never a claim
2731+
// about CI, and nothing said so out loud.
2732+
//
2733+
// ## Where the number comes from -- CI, never this box
2734+
//
2735+
// Read off the CI runner itself: run 33136681083, job `Type Check · debt
2736+
// ledger`, at 6d097a604, Node v22.23.2. The `packages/qa/http-conformance`
2737+
// TEST_DEBT program died there, and the last GC before it says how much heap it
2738+
// was allowed:
2739+
//
2740+
// [5950] 65768 ms: Mark-Compact 4040.3 (4143.8) -> 4029.5 (4147.5) MB,
2741+
// ... allocation failure; GC in old space requested
2742+
// FATAL ERROR: Ineffective mark-compacts near heap limit
2743+
// Allocation failed - JavaScript heap out of memory
2744+
//
2745+
// V8 gave up with 4040.3 MB live and 4147.5 MB of heap committed, which
2746+
// brackets that runner's old-space limit into [4040, 4148] MB. 4096 is the only
2747+
// V8 default that lands in the window, and the two sides agree on the offset:
2748+
// `getHeapStatistics().heap_size_limit` reports the old space plus a fixed
2749+
// ~48 MB of other spaces (8240 reported for 8192 and 560 for 512, both measured
2750+
// with `NODE_OPTIONS` on a box under this file's own eyes), so a 4096 old space
2751+
// reports 4144 and commits the 4147.5 above it.
2752+
//
2753+
// ⚠️ If 4096 is wrong, it is wrong DOWNWARD -- the only safe direction. This
2754+
// number's entire job is to be no HIGHER than CI's ceiling. A pin ABOVE CI's is
2755+
// worse than no pin at all: it makes local runs pass where CI still OOMs, which
2756+
// is exactly this defect with extra confidence attached.
2757+
//
2758+
// ⛔ Do not raise this to make a local measurement complete. `--re-measure`
2759+
// OOMing under this ceiling is the gate WORKING -- it is CI's failure,
2760+
// reproduced on your box before you push. What grew is the type graph, not the
2761+
// memory CI has. (`packages/spec/tsup.config.ts` carries the other half of this
2762+
// lesson from the build side: a ceiling above the box's real memory does not
2763+
// buy a bigger run, it converts a recoverable heap error into an exit-137
2764+
// SIGKILL that carries no diagnostic at all.)
2765+
const CI_TSC_HEAP_CEILING_MB = 4096;
2766+
2767+
/**
2768+
* The last `--max-old-space-size` in a `NODE_OPTIONS` string, in MB, or null.
2769+
*
2770+
* LAST, not first, because that is what V8 does with a repeated flag -- measured
2771+
* both ways: `--max-old-space-size=8192 --max-old-space-size=512` reports a
2772+
* 560 MB limit and the reverse order reports 8240. That is the whole reason
2773+
* `heapCappedEnv` below can APPEND rather than having to rewrite what the
2774+
* caller set, and the reason this parser has to agree with V8 about which
2775+
* occurrence wins: reading the first one would let a caller's stale flag decide
2776+
* a ceiling V8 has already discarded.
2777+
*
2778+
* Only the `=` spelling exists to parse. `NODE_OPTIONS="--max-old-space-size 512"`
2779+
* is not a lower ceiling, it is a node startup error (measured: the space-split
2780+
* form makes node reject an unrelated option and exit), so a run that reaches
2781+
* this gate at all never carries one. Both the dashed and the underscored flag
2782+
* names are accepted, because V8 accepts both.
2783+
*
2784+
* @param {string | undefined} nodeOptions
2785+
* @returns {number | null}
2786+
*/
2787+
function maxOldSpaceMb(nodeOptions) {
2788+
let mb = null;
2789+
for (const m of String(nodeOptions ?? '').matchAll(/--max[-_]old[-_]space[-_]size=(\d+)/g)) {
2790+
mb = Number(m[1]);
2791+
}
2792+
return mb;
2793+
}
2794+
2795+
/**
2796+
* The ceiling this run will hand tsc, and the honest name of where it came from.
2797+
*
2798+
* The rule is a MINIMUM over three numbers, and each one is there for a failure
2799+
* that has actually happened somewhere in this repo:
2800+
*
2801+
* the CI ceiling the point of the exercise -- a roomier box must not
2802+
* measure a roomier world than the box whose verdict
2803+
* counts.
2804+
* this process's own never RAISE a ceiling. On a box smaller than CI,
2805+
* promising V8 memory the box does not have does not buy
2806+
* a bigger run: the kernel kills the process at the
2807+
* container limit long before V8 reaches the ceiling, and
2808+
* exit 137 carries no diagnostic (`packages/spec`'s DTS
2809+
* pass was killed on every docs deploy for two days that
2810+
* way). Lower than CI is the safe direction anyway: heap
2811+
* headroom is monotone, so a program that fits under a
2812+
* smaller ceiling fits under CI's.
2813+
* the caller's an explicit `NODE_OPTIONS` cap is honoured when it is
2814+
* TIGHTER, and refused when it is roomier. A caller who
2815+
* could hand this gate more heap than CI has could hand
2816+
* back the exact green-here-red-there reading this
2817+
* ceiling exists to abolish.
2818+
*
2819+
* `stale` is the other direction, and it is the one nothing else can catch. If
2820+
* the runner's OWN default is below the pinned constant, then the constant is
2821+
* no longer a description of CI: every local run is roomier than CI again,
2822+
* silently, and this file's green is back to meaning nothing. That is only
2823+
* measurable on the runner itself, so it is measured there and refused there --
2824+
* an advisory would be a declaration nobody reads, which is the shape this
2825+
* repo's ledgers exist to stop.
2826+
*
2827+
* @param {{heapLimitMb: number, nodeOptions?: string, onCi?: boolean}} where
2828+
* @returns {{mb: number, from: string, machineMb: number, stale: string | null}}
2829+
*/
2830+
function remeasureHeapCeiling({ heapLimitMb, nodeOptions, onCi = false }) {
2831+
const caller = maxOldSpaceMb(nodeOptions);
2832+
const candidates = [
2833+
{ mb: CI_TSC_HEAP_CEILING_MB, from: `the CI-shaped ceiling pinned by ${SELF}` },
2834+
{ mb: heapLimitMb, from: "this machine's own default, which is BELOW CI's ceiling" },
2835+
...(caller === null ? [] : [{ mb: caller, from: "the caller's NODE_OPTIONS, which is tighter" }]),
2836+
];
2837+
// Ties keep the earlier candidate, so the CI ceiling keeps its name on the
2838+
// machine that IS CI -- where all three numbers agree and the label is the
2839+
// only thing left to read.
2840+
const chosen = candidates.reduce((a, b) => (b.mb < a.mb ? b : a));
2841+
const stale = onCi && heapLimitMb < CI_TSC_HEAP_CEILING_MB
2842+
? `${SELF} pins a CI heap ceiling of ${CI_TSC_HEAP_CEILING_MB} MB, but THIS CI runner's own default is `
2843+
+ `${heapLimitMb} MB -- the pin is now ABOVE the ceiling it claims to describe, so every local run of `
2844+
+ `this gate is roomier than CI again and its green says nothing about this job. Re-pin `
2845+
+ `CI_TSC_HEAP_CEILING_MB from this reading (the runner shrank; the remedy is one constant), and `
2846+
+ `⛔ do not delete the pin instead -- an unpinned run is the defect #12856 closed.`
2847+
: null;
2848+
return { mb: chosen.mb, from: chosen.from, machineMb: heapLimitMb, stale };
2849+
}
2850+
2851+
/**
2852+
* `env` with the ceiling appended to `NODE_OPTIONS`.
2853+
*
2854+
* APPENDED, never substituted: V8 takes the last occurrence (see
2855+
* `maxOldSpaceMb`), so this wins over whatever the caller set without this
2856+
* function having to understand the rest of their `NODE_OPTIONS` -- and the
2857+
* caller's own flags, which may be the reason their run works at all, survive.
2858+
*
2859+
* @param {NodeJS.ProcessEnv} env
2860+
* @param {number} mb
2861+
* @returns {NodeJS.ProcessEnv}
2862+
*/
2863+
function heapCappedEnv(env, mb) {
2864+
return { ...env, NODE_OPTIONS: `${(env.NODE_OPTIONS ?? '').trim()} --max-old-space-size=${mb}`.trim() };
2865+
}
2866+
2867+
// Read once, at the ceiling this process actually got rather than at a number
2868+
// about the box: `heap_size_limit` already accounts for a caller's flags, a
2869+
// cgroup, and whatever V8 decided from physical memory, which is three ways of
2870+
// being wrong that this file then does not have to model.
2871+
const REMEASURE_HEAP = remeasureHeapCeiling({
2872+
heapLimitMb: Math.floor(getHeapStatistics().heap_size_limit / (1024 * 1024)),
2873+
nodeOptions: process.env.NODE_OPTIONS,
2874+
onCi: process.env.GITHUB_ACTIONS === 'true',
2875+
});
2876+
27192877
/**
27202878
* Run the repo's own tsc over one project and return its raw error count.
27212879
* `--pretty false` so the count does not depend on whether a TTY is attached;
@@ -2736,6 +2894,11 @@ function tscErrorCount(project, options = {}) {
27362894
cwd: ROOT,
27372895
encoding: 'utf8',
27382896
maxBuffer: 256 * 1024 * 1024,
2897+
// The CI-shaped ceiling (#12856). Every tsc this gate runs gets it, not
2898+
// just the generated TEST_DEBT program that happened to OOM first: a DEBT
2899+
// entry measured under a roomier ceiling than CI's is the same reading
2900+
// dressed as a different one.
2901+
env: heapCappedEnv(process.env, REMEASURE_HEAP.mb),
27392902
});
27402903
if (run.error) throw new Error(`tsc could not be run for ${project}: ${run.error.message}`);
27412904
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
@@ -4887,6 +5050,112 @@ function selfTest() {
48875050
if (got !== c.expect) failures.push(`TSC_SETUP_ERROR — ${c.label}: expected ${c.expect}, got ${got}`);
48885051
}
48895052

5053+
// THE CI-SHAPED HEAP CEILING (#12856). The only instrument this rule can
5054+
// have. Its production reading is a ceiling that is applied and a run that
5055+
// then passes -- and a run that passes is precisely what the UNPINNED gate
5056+
// also produced on every machine that had the memory to spare. So the
5057+
// production verdict cannot tell a correct ceiling from no ceiling at all,
5058+
// and the adversarial inputs below are the whole difference.
5059+
const ceilingCases = [
5060+
{
5061+
label: 'a roomier box than CI is capped to the CI ceiling -- the defect this pin closes',
5062+
where: { heapLimitMb: 8240 },
5063+
expect: { mb: CI_TSC_HEAP_CEILING_MB, stale: false },
5064+
},
5065+
{
5066+
label: 'on a box shaped like CI the ceiling is a no-op that still names itself',
5067+
where: { heapLimitMb: CI_TSC_HEAP_CEILING_MB + 48, onCi: true },
5068+
expect: { mb: CI_TSC_HEAP_CEILING_MB, stale: false },
5069+
},
5070+
{
5071+
// Never RAISE. Promising V8 memory the box does not have trades a
5072+
// recoverable heap error for a kernel SIGKILL that says nothing.
5073+
label: 'a box SMALLER than CI keeps its own lower ceiling',
5074+
where: { heapLimitMb: 2096 },
5075+
expect: { mb: 2096, stale: false },
5076+
},
5077+
{
5078+
label: "a caller's TIGHTER NODE_OPTIONS cap is honoured",
5079+
where: { heapLimitMb: 8240, nodeOptions: '--max-old-space-size=1024' },
5080+
expect: { mb: 1024, stale: false },
5081+
},
5082+
{
5083+
// The hole a "respect the caller" rule would leave: a roomier explicit
5084+
// cap is the green-here-red-there reading, handed back by request.
5085+
label: "a caller's ROOMIER NODE_OPTIONS cap is refused, not respected",
5086+
where: { heapLimitMb: 12288, nodeOptions: '--max-old-space-size=12288' },
5087+
expect: { mb: CI_TSC_HEAP_CEILING_MB, stale: false },
5088+
},
5089+
{
5090+
// The direction nothing else can catch: the runner shrank, so the pin is
5091+
// now ABOVE the ceiling it describes and every local run is roomier than
5092+
// CI again -- silently, and with the pin's own confidence attached.
5093+
label: 'a CI runner whose own default is BELOW the pin is refused, loudly',
5094+
where: { heapLimitMb: 2096, onCi: true },
5095+
expect: { mb: 2096, stale: true },
5096+
},
5097+
{
5098+
// THE CONTROL for the case above. Off CI the same numbers are an
5099+
// ordinary small box, not evidence about CI -- reading them as a stale
5100+
// pin would refuse on every laptop with 4 GB in it.
5101+
label: 'the same reading OFF ci is a small box, not a stale pin',
5102+
where: { heapLimitMb: 2096, onCi: false },
5103+
expect: { mb: 2096, stale: false },
5104+
},
5105+
];
5106+
for (const c of ceilingCases) {
5107+
const got = remeasureHeapCeiling(c.where);
5108+
if (got.mb !== c.expect.mb || (got.stale !== null) !== c.expect.stale) {
5109+
failures.push(
5110+
`remeasureHeapCeiling — ${c.label}: expected ${c.expect.mb} MB and stale=${c.expect.stale}, `
5111+
+ `got ${got.mb} MB and stale=${got.stale === null ? 'false' : JSON.stringify(got.stale)}`,
5112+
);
5113+
}
5114+
if (got.machineMb !== c.where.heapLimitMb) {
5115+
failures.push(
5116+
`remeasureHeapCeiling — ${c.label}: reported machineMb ${got.machineMb} for a box of `
5117+
+ `${c.where.heapLimitMb} MB. That figure is what a CI log carries forward as the runner's own `
5118+
+ `reading, so a wrong one re-pins the constant wrong.`,
5119+
);
5120+
}
5121+
}
5122+
5123+
// The two mechanical halves of the ceiling: which occurrence V8 obeys, and
5124+
// that appending is therefore enough. Both measured against node itself
5125+
// before they were written down -- `--max-old-space-size=8192
5126+
// --max-old-space-size=512` reports a 560 MB limit, the reverse 8240.
5127+
const heapEnvCases = [
5128+
{ label: 'no NODE_OPTIONS is no caller cap', options: undefined, expect: null },
5129+
{ label: 'an unrelated flag is not a cap', options: '--enable-source-maps', expect: null },
5130+
{ label: 'the flag without a value is not a cap', options: '--max-old-space-size', expect: null },
5131+
{ label: 'the dashed spelling parses', options: '--max-old-space-size=4096', expect: 4096 },
5132+
{ label: 'the underscored spelling V8 also accepts parses', options: '--max_old_space_size=512', expect: 512 },
5133+
{ label: 'a repeated flag reads the LAST, as V8 does', options: '--max-old-space-size=8192 --max-old-space-size=512', expect: 512 },
5134+
{ label: 'the cap is found among other flags', options: '--enable-source-maps --max-old-space-size=2048 --no-warnings', expect: 2048 },
5135+
];
5136+
for (const c of heapEnvCases) {
5137+
const got = maxOldSpaceMb(c.options);
5138+
if (got !== c.expect) failures.push(`maxOldSpaceMb — ${c.label}: expected ${c.expect}, got ${got}`);
5139+
// The appended env must be the thing V8 then obeys -- i.e. OUR flag has to
5140+
// be the last one in the string, whatever the caller put there.
5141+
const appended = heapCappedEnv({ PATH: '/usr/bin', NODE_OPTIONS: c.options }, 777);
5142+
if (maxOldSpaceMb(appended.NODE_OPTIONS) !== 777) {
5143+
failures.push(
5144+
`heapCappedEnv — ${c.label}: the appended ceiling does not win; V8 would obey `
5145+
+ `${maxOldSpaceMb(appended.NODE_OPTIONS)} from ${JSON.stringify(appended.NODE_OPTIONS)}`,
5146+
);
5147+
}
5148+
if (appended.PATH !== '/usr/bin') {
5149+
failures.push(`heapCappedEnv — ${c.label}: dropped the rest of the environment`);
5150+
}
5151+
if (c.options !== undefined && !appended.NODE_OPTIONS.startsWith(c.options)) {
5152+
failures.push(
5153+
`heapCappedEnv — ${c.label}: dropped the caller's own NODE_OPTIONS (${JSON.stringify(c.options)}), `
5154+
+ `which may be the reason their run works at all`,
5155+
);
5156+
}
5157+
}
5158+
48905159
// AUTO-LOWERING (#6376). What it refuses matters more than what it writes.
48915160
const planCases = [
48925161
{
@@ -5082,7 +5351,8 @@ function selfTest() {
50825351
`${TYPECHECK_CONFIGS_CASES + coverCases.length + unreadCases.length + accountedCases.length
50835352
+ derivedCases.length + sourceCandidateCases.length + includeRootCases.length
50845353
+ chainCases.length + generatorCases.length + layerCases.length} observation case(s) + ` +
5085-
`${driftCases.length + countCases.length + projectCases.length + setupErrorCases.length} re-measure case(s) + ` +
5354+
`${driftCases.length + countCases.length + projectCases.length + setupErrorCases.length
5355+
+ ceilingCases.length + heapEnvCases.length} re-measure case(s) + ` +
50865356
`${typeEntryCases.length + closureCases.length + staleCases.length + sourceFileCases.length} ` +
50875357
`built-closure case(s) + ` +
50885358
`${planCases.length + rewriteCases.length + roundTripCases.length} auto-lowering case(s) hold.`,
@@ -5155,6 +5425,21 @@ console.log(
51555425
// measure, and a wall of tsc output would bury the real failure. Reported after
51565426
// the summary so the two verdicts read in the order they were reached.
51575427
if (process.argv.includes('--re-measure')) {
5428+
// The ceiling FIRST, before the four minutes of tsc it shapes (#12856). Two
5429+
// jobs, and the second is the one that keeps the constant honest: on CI this
5430+
// line prints the RUNNER's own default into the run log, so the number
5431+
// `CI_TSC_HEAP_CEILING_MB` claims is re-derivable from any CI log of this
5432+
// step rather than from archaeology through a failed job's GC trace.
5433+
if (REMEASURE_HEAP.stale) {
5434+
if (process.env.GITHUB_ACTIONS === 'true') console.log(`::error::${REMEASURE_HEAP.stale}`);
5435+
console.error(`\ncheck-type-check-coverage --re-measure: ${REMEASURE_HEAP.stale}`);
5436+
process.exit(1);
5437+
}
5438+
console.log(
5439+
` heap: tsc runs under --max-old-space-size=${REMEASURE_HEAP.mb} MB -- ${REMEASURE_HEAP.from}; `
5440+
+ `this process's own limit is ${REMEASURE_HEAP.machineMb} MB. A measurement is only as portable as `
5441+
+ `the ceiling it ran under (#12856).`,
5442+
);
51585443
const started = Date.now();
51595444
const measurements = measureLedgers(packages, root.name, state);
51605445
const { problems: drift, notes, surplus, surplusEntries } = evaluateMeasurements(measurements);

0 commit comments

Comments
 (0)