@@ -447,6 +447,7 @@ import { spawnSync } from 'node:child_process';
447447import { existsSync , mkdtempSync , readFileSync , readdirSync , rmSync , statSync , writeFileSync } from 'node:fs' ;
448448import { tmpdir } from 'node:os' ;
449449import { join , posix , resolve } from 'node:path' ;
450+ import { getHeapStatistics } from 'node:v8' ;
450451import {
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 ( / - - m a x [ - _ ] o l d [ - _ ] s p a c e [ - _ ] s i z e = ( \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.
51575427if ( 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