@zakkster/lite-perf-gate 1.2.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PerfGate.js CHANGED
@@ -2,22 +2,32 @@
2
2
  * @zakkster/lite-perf-gate
3
3
  * Zero-GC and performance regression gate for node:test.
4
4
  *
5
- * Three measurement signals, each for what it can actually see:
5
+ * Five measurement signals, each for what it can actually see:
6
6
  * 1. Scavenge count (perf_hooks 'gc', minor) -- the reliable detector
7
- * of TRANSIENT allocation. Retained-heap misses it (freed before
8
- * snapshot); the V8 sampling profiler misses it (reports live-at-stop).
7
+ * of TRANSIENT young-generation allocation. Retained-heap misses it
8
+ * (freed before snapshot); the V8 sampling profiler misses it
9
+ * (reports live-at-stop).
9
10
  * 2. Custom counters (user-supplied statsOf) -- exact engine internals.
10
11
  * 3. Retained-heap delta (memoryUsage + gc) -- leak detector.
12
+ * 4. Old-gen activity (perf_hooks 'gc', major + incremental) -- catches
13
+ * churn that promotes or triggers old-space marking, which the scavenge
14
+ * counter alone cannot see (PG-03a). This is a GATE signal, not a
15
+ * profiler: when it trips, diagnose the cause with @zakkster/lite-gc-profiler.
16
+ * 5. External/arrayBuffers delta (memoryUsage().arrayBuffers + gc) -- catches
17
+ * backing-store memory that heapUsed excludes (PG-03b). Same boundary:
18
+ * it names that external memory grew and points at lite-gc-profiler.
11
19
  *
12
- * Pass/fail uses scaling: measure at N and k*N. Zero-alloc => ~0 scavenges
13
- * at both; allocation => scavenges scale with total bytes. Controls validate
14
- * the detector on every run.
20
+ * Pass/fail uses scaling on scavenges: measure at N and k*N. Zero-alloc =>
21
+ * ~0 scavenges at both; allocation => scavenges scale with total bytes. The
22
+ * old-gen and external lanes are ABSOLUTE (rare events, so a scaling lane on
23
+ * a 0-or-1 signal is noise -- decisions/0003). Controls validate the detector
24
+ * on every run.
15
25
  *
16
26
  * Copyright (c) 2026 Zahary Shinikchiev <shinikchiev@yahoo.com>
17
27
  * MIT License
18
28
  */
19
29
 
20
- export const VERSION = '1.2.2';
30
+ export const VERSION = '1.4.0';
21
31
 
22
32
  import {PerformanceObserver, constants} from 'node:perf_hooks';
23
33
  import {setTimeout as sleep} from 'node:timers/promises';
@@ -26,18 +36,22 @@ import assert from 'node:assert/strict';
26
36
 
27
37
  const MINOR = constants.NODE_PERFORMANCE_GC_MINOR;
28
38
  const MAJOR = constants.NODE_PERFORMANCE_GC_MAJOR;
39
+ const INCR = constants.NODE_PERFORMANCE_GC_INCREMENTAL;
40
+ const WEAK = constants.NODE_PERFORMANCE_GC_WEAKCB;
29
41
 
30
42
  // ---------------------------------------------------------------------------
31
43
  // GC counter
32
44
  // ---------------------------------------------------------------------------
33
45
 
34
46
  function makeGcCounter() {
35
- const c = {minor: 0, major: 0};
47
+ const c = {minor: 0, major: 0, incremental: 0, weakcb: 0};
36
48
  const obs = new PerformanceObserver(function (list) {
37
49
  for (const e of list.getEntries()) {
38
50
  const k = e.detail ? e.detail.kind : e.kind;
39
51
  if (k === MINOR) c.minor++;
40
52
  else if (k === MAJOR) c.major++;
53
+ else if (k === INCR) c.incremental++;
54
+ else if (k === WEAK) c.weakcb++;
41
55
  }
42
56
  });
43
57
  obs.observe({entryTypes: ['gc']});
@@ -77,6 +91,171 @@ if (typeof process !== 'undefined' && process.env && process.env.PERF_GATE_FLUSH
77
91
  if (envMs > 0 && envMs < 60000) DEFAULT_FLUSH_MS = envMs;
78
92
  }
79
93
 
94
+ // ---------------------------------------------------------------------------
95
+ // Fail-closed doors (v1.4.0) -- all cold path
96
+ // ---------------------------------------------------------------------------
97
+
98
+ const NO_GC_MSG = 'lite-perf-gate: measure: globalThis.gc is not a function -- ' +
99
+ 'the retained signal cannot be measured and the scavenge counts are unsettled. ' +
100
+ 'Run: node --expose-gc --max-semi-space-size=4 <entry> ' +
101
+ '(or node --expose-gc --max-semi-space-size=4 --test <file>). ' +
102
+ 'Pass allowNoGc: true to gate scavenges and counters only.';
103
+ const NO_RETAINED = 'retained is ungateable without --expose-gc ' +
104
+ '(allowNoGc: true with an explicit maxRetainedKB) -- drop one of them.';
105
+ const NO_ARRAYBUFFERS = 'arrayBuffers is ungateable without --expose-gc ' +
106
+ '(allowNoGc: true with an explicit maxArrayBuffersKB) -- drop one of them.';
107
+ const EMPTY_HINT = ' -- pass allowEmpty: true for a controls-only detector smoke run.';
108
+
109
+ /**
110
+ * The ONE option/threshold/scenario door. measure, verdict, zgcSuite and
111
+ * runGate all call it; nothing bypasses it and there is no skip flag.
112
+ * Cold path by construction -- no caller is a hot body.
113
+ *
114
+ * @param {'measure'|'verdict'|'zgcSuite'|'runGate'} where message prefix + mode
115
+ * @param {object|undefined|null} cfg measure options / verdict thresholds / gate config
116
+ * @param {Scenario|null} scenario the single scenario for measure(), else null
117
+ * @returns {{N: number, k: number, flushMs: number, allowNoGc: boolean, allowEmpty: boolean}}
118
+ * @throws {TypeError|RangeError} naming the field and the offending value
119
+ */
120
+ function validateGate(where, cfg, scenario) {
121
+ const P = 'lite-perf-gate: ' + where + ': ';
122
+ if (cfg !== undefined && cfg !== null && typeof cfg !== 'object') {
123
+ throw new TypeError(P + 'config must be an object (got ' + typeof cfg + ')');
124
+ }
125
+ const c = cfg || {};
126
+
127
+ let N = 200000;
128
+ if (c.N !== undefined) {
129
+ if (!Number.isInteger(c.N) || c.N < 1) {
130
+ throw new RangeError(P + 'N must be an integer >= 1 (got ' + String(c.N) + ')');
131
+ }
132
+ N = c.N;
133
+ }
134
+ let k = 8;
135
+ if (c.k !== undefined) {
136
+ if (!Number.isInteger(c.k) || c.k < 2) {
137
+ throw new RangeError(P + 'k must be an integer >= 2 (got ' + String(c.k) + ')');
138
+ }
139
+ k = c.k;
140
+ }
141
+ let flushMs = DEFAULT_FLUSH_MS;
142
+ if (c.flushMs !== undefined) {
143
+ if (typeof c.flushMs !== 'number' || !isFinite(c.flushMs) || c.flushMs < 0) {
144
+ throw new RangeError(P + 'flushMs must be a finite number >= 0 (got ' + String(c.flushMs) + ')');
145
+ }
146
+ flushMs = c.flushMs; // 0 is legal and IS honored (PG-09)
147
+ }
148
+ if (c.allowNoGc !== undefined && c.allowNoGc !== true && c.allowNoGc !== false) {
149
+ throw new TypeError(P + 'allowNoGc must be true or false (got ' + String(c.allowNoGc) + ')');
150
+ }
151
+ if (c.allowEmpty !== undefined && c.allowEmpty !== true && c.allowEmpty !== false) {
152
+ throw new TypeError(P + 'allowEmpty must be true or false (got ' + String(c.allowEmpty) + ')');
153
+ }
154
+ const allowNoGc = c.allowNoGc === true;
155
+ const allowEmpty = c.allowEmpty === true;
156
+
157
+ if (where !== 'measure') {
158
+ if (c.maxScavenges !== undefined &&
159
+ (typeof c.maxScavenges !== 'number' || !isFinite(c.maxScavenges) || c.maxScavenges < 0)) {
160
+ throw new RangeError(P + 'maxScavenges must be a finite number >= 0 (got ' + String(c.maxScavenges) + ')');
161
+ }
162
+ if (c.maxRetainedKB !== undefined &&
163
+ (typeof c.maxRetainedKB !== 'number' || !isFinite(c.maxRetainedKB) || c.maxRetainedKB < 0)) {
164
+ throw new RangeError(P + 'maxRetainedKB must be a finite number >= 0 (got ' + String(c.maxRetainedKB) + ')');
165
+ }
166
+ if (c.maxOldGen !== undefined &&
167
+ (typeof c.maxOldGen !== 'number' || !isFinite(c.maxOldGen) || c.maxOldGen < 0)) {
168
+ throw new RangeError(P + 'maxOldGen must be a finite number >= 0 (got ' + String(c.maxOldGen) + ')');
169
+ }
170
+ if (c.maxArrayBuffersKB !== undefined &&
171
+ (typeof c.maxArrayBuffersKB !== 'number' || !isFinite(c.maxArrayBuffersKB) || c.maxArrayBuffersKB < 0)) {
172
+ throw new RangeError(P + 'maxArrayBuffersKB must be a finite number >= 0 (got ' + String(c.maxArrayBuffersKB) + ')');
173
+ }
174
+ if (c.counters !== undefined && c.counters !== null) {
175
+ if (typeof c.counters !== 'object') {
176
+ throw new TypeError(P + 'counters must be an object of numeric maxima (got ' + typeof c.counters + ')');
177
+ }
178
+ for (const key in c.counters) {
179
+ const t = c.counters[key];
180
+ if (typeof t !== 'number' || !isFinite(t)) {
181
+ throw new RangeError(P + 'counters.' + key + ' must be a finite number (got ' + String(t) + ')');
182
+ }
183
+ }
184
+ }
185
+ if (allowNoGc && c.maxRetainedKB !== undefined) throw new RangeError(P + NO_RETAINED);
186
+ if (allowNoGc && c.maxArrayBuffersKB !== undefined) throw new RangeError(P + NO_ARRAYBUFFERS);
187
+ }
188
+
189
+ let list = null;
190
+ let labels = null;
191
+ if (where === 'measure') {
192
+ list = [scenario];
193
+ labels = ['scenario'];
194
+ } else if (where === 'zgcSuite' || where === 'runGate') {
195
+ const sc = c.scenarios;
196
+ if (!Array.isArray(sc)) {
197
+ throw new TypeError(P + 'scenarios must be an array (got ' +
198
+ (sc === undefined ? 'undefined' : typeof sc) + ')' + EMPTY_HINT);
199
+ }
200
+ if (sc.length === 0 && !allowEmpty) {
201
+ throw new RangeError(P + 'scenarios must be a non-empty array' + EMPTY_HINT);
202
+ }
203
+ list = [];
204
+ labels = [];
205
+ for (let i = 0; i < sc.length; i++) {
206
+ list.push(sc[i]);
207
+ labels.push('scenarios[' + i + ']');
208
+ }
209
+ if (c.mustFail !== undefined) {
210
+ if (!Array.isArray(c.mustFail)) {
211
+ throw new TypeError(P + 'mustFail must be an array (got ' + typeof c.mustFail + ')');
212
+ }
213
+ for (let i = 0; i < c.mustFail.length; i++) {
214
+ list.push(c.mustFail[i]);
215
+ labels.push('mustFail[' + i + ']');
216
+ }
217
+ }
218
+ if (c.positiveControl !== undefined) {
219
+ list.push(c.positiveControl);
220
+ labels.push('positiveControl');
221
+ }
222
+ if (c.negativeControl !== undefined) {
223
+ list.push(c.negativeControl);
224
+ labels.push('negativeControl');
225
+ }
226
+ if (c.largeControl !== undefined) {
227
+ list.push(c.largeControl);
228
+ labels.push('largeControl');
229
+ }
230
+ }
231
+ if (list !== null) {
232
+ for (let i = 0; i < list.length; i++) {
233
+ const s = list[i];
234
+ const L = labels[i];
235
+ if (!s || typeof s !== 'object') {
236
+ throw new TypeError(P + L + ' must be a Scenario object (got ' +
237
+ (s === null ? 'null' : typeof s) + ')');
238
+ }
239
+ if (typeof s.name !== 'string' || s.name.length === 0) {
240
+ throw new TypeError(P + L + '.name must be a non-empty string (got ' + String(s.name) + ')');
241
+ }
242
+ if (typeof s.setup !== 'function') {
243
+ throw new TypeError(P + L + ' "' + s.name + '": setup must be a function (got ' + typeof s.setup + ')');
244
+ }
245
+ if (typeof s.hot !== 'function') {
246
+ throw new TypeError(P + L + ' "' + s.name + '": hot must be a function (got ' + typeof s.hot + ')');
247
+ }
248
+ if (s.statsOf !== undefined && typeof s.statsOf !== 'function') {
249
+ throw new TypeError(P + L + ' "' + s.name + '": statsOf must be a function when present (got ' + typeof s.statsOf + ')');
250
+ }
251
+ if (s.teardown !== undefined && typeof s.teardown !== 'function') {
252
+ throw new TypeError(P + L + ' "' + s.name + '": teardown must be a function when present (got ' + typeof s.teardown + ')');
253
+ }
254
+ }
255
+ }
256
+ return {N: N, k: k, flushMs: flushMs, allowNoGc: allowNoGc, allowEmpty: allowEmpty};
257
+ }
258
+
80
259
  // ---------------------------------------------------------------------------
81
260
  // Core measurement
82
261
  // ---------------------------------------------------------------------------
@@ -87,7 +266,9 @@ async function meterOnce(scenario, iters, flushMs) {
87
266
  await gc2();
88
267
 
89
268
  const statsBefore = scenario.statsOf ? scenario.statsOf(state) : null;
90
- const heapBefore = process.memoryUsage().heapUsed;
269
+ const mu0 = process.memoryUsage();
270
+ const heapBefore = mu0.heapUsed;
271
+ const abBefore = mu0.arrayBuffers;
91
272
 
92
273
  const gcc = makeGcCounter();
93
274
  scenario.hot(state, iters);
@@ -96,8 +277,16 @@ async function meterOnce(scenario, iters, flushMs) {
96
277
  const major = gcc.c.major;
97
278
  gcc.close();
98
279
 
280
+ // Old-gen activity = major + incremental. incremental is read AFTER
281
+ // gcc.close() on purpose: the measurement window (makeGcCounter -> close)
282
+ // stays byte-identical to v1.4.0, and the observer callback has already
283
+ // tallied every 'gc' entry delivered during the flush sleep. decisions/0003.
284
+ const oldGen = major + gcc.c.incremental;
285
+
99
286
  await gc2();
100
- const heapAfter = process.memoryUsage().heapUsed;
287
+ const mu1 = process.memoryUsage();
288
+ const heapAfter = mu1.heapUsed;
289
+ const abAfter = mu1.arrayBuffers;
101
290
  const statsAfter = scenario.statsOf ? scenario.statsOf(state) : null;
102
291
  if (scenario.teardown) scenario.teardown(state);
103
292
 
@@ -111,7 +300,12 @@ async function meterOnce(scenario, iters, flushMs) {
111
300
  }
112
301
  }
113
302
 
114
- return {minor: minor, major: major, retainedKB: (heapAfter - heapBefore) / 1024, counters: counters};
303
+ return {
304
+ minor: minor, major: major, oldGen: oldGen,
305
+ retainedKB: (heapAfter - heapBefore) / 1024,
306
+ arrayBuffersKB: (abAfter - abBefore) / 1024,
307
+ counters: counters
308
+ };
115
309
  }
116
310
 
117
311
  // ---------------------------------------------------------------------------
@@ -136,25 +330,31 @@ async function meterOnce(scenario, iters, flushMs) {
136
330
  * allocation via scavenge scaling.
137
331
  *
138
332
  * @param {Scenario} scenario
139
- * @param {{ N?: number, k?: number, flushMs?: number }} [options]
140
- * flushMs: how long to wait after the hot loop before reading the GC
141
- * observer buffer. Default 100ms (or PERF_GATE_FLUSH_MS env var).
142
- * Bump if you see zero scavenges on scenarios that should allocate --
143
- * noisy CI runners may need 250-500ms.
333
+ * @param {{ N?: number, k?: number, flushMs?: number, allowNoGc?: boolean }} [options]
334
+ * N integer >= 1 (default 200000), k integer >= 2 (default 8), flushMs a
335
+ * finite number >= 0 (0 is legal and honored). flushMs is how long to wait
336
+ * after the hot loop before reading the GC observer buffer. Default 100ms
337
+ * (or PERF_GATE_FLUSH_MS env var). Bump if you see zero scavenges on
338
+ * scenarios that should allocate -- noisy CI runners may need 250-500ms.
339
+ * allowNoGc: run without --expose-gc; the retained signal is dropped and
340
+ * the result carries retainedReliable: false. Bad values throw.
144
341
  * @returns {Promise<MeasureResult>}
145
342
  */
146
343
  export async function measure(scenario, options) {
147
- const N = (options && options.N) || 200000;
148
- const k = (options && options.k) || 8;
149
- const flushMs = (options && options.flushMs) || DEFAULT_FLUSH_MS;
150
- const lo = await meterOnce(scenario, N, flushMs);
151
- const hi = await meterOnce(scenario, k * N, flushMs);
344
+ const o = validateGate('measure', options, scenario);
345
+ const gcOk = typeof globalThis.gc === 'function';
346
+ if (!gcOk && !o.allowNoGc) throw new Error(NO_GC_MSG);
347
+ const lo = await meterOnce(scenario, o.N, o.flushMs);
348
+ const hi = await meterOnce(scenario, o.k * o.N, o.flushMs);
152
349
  return {
153
- name: scenario.name, N: N, k: k,
350
+ name: scenario.name, N: o.N, k: o.k,
154
351
  minorLo: lo.minor, minorHi: hi.minor,
155
352
  majorLo: lo.major, majorHi: hi.major,
353
+ oldGenLo: lo.oldGen, oldGenHi: hi.oldGen,
156
354
  retainedKB_lo: lo.retainedKB, retainedKB_hi: hi.retainedKB,
157
- counters_lo: lo.counters, counters_hi: hi.counters
355
+ arrayBuffersKB_lo: lo.arrayBuffersKB, arrayBuffersKB_hi: hi.arrayBuffersKB,
356
+ counters_lo: lo.counters, counters_hi: hi.counters,
357
+ retainedReliable: gcOk
158
358
  };
159
359
  }
160
360
 
@@ -162,22 +362,48 @@ export async function measure(scenario, options) {
162
362
  // Built-in controls
163
363
  // ---------------------------------------------------------------------------
164
364
 
165
- const __posKeep = [];
365
+ // The positive control must defeat TWO V8 adaptations at once:
366
+ // 1. escape analysis / scalar replacement -- an object that never escapes
367
+ // is never allocated, so the control must store it somewhere real;
368
+ // 2. allocation-site pretenuring -- a site whose objects keep SURVIVING is
369
+ // promoted to old space, and the scavenge signal dies (PG-01).
370
+ // A 64-slot ring satisfies both: the store escapes, and every object is
371
+ // overwritten within 64 iterations, so the site's survival ratio stays ~0.
372
+ // Measured, fresh process, N=200000 k=8, --expose-gc --max-semi-space-size=4:
373
+ // minorLo=2 minorHi=21 majorHi=0 retainedKB_hi~11 (stock sink: 2 -> 1, ~100MB).
374
+ // See decisions/0001-positive-control.md.
375
+
376
+ const CONTROL_RING_SIZE = 64;
377
+ const CONTROL_RING_MASK = CONTROL_RING_SIZE - 1;
378
+ const __posRing = new Array(CONTROL_RING_SIZE).fill(null);
166
379
 
167
- /** @internal -- reference to keep the positive-control sink alive. */
380
+ /**
381
+ * @internal -- the read that keeps the positive-control ring alive.
382
+ * Touches every slot so V8 cannot sink or eliminate the stores in
383
+ * controlPositive.hot, and returns the live occupancy. The sink is bounded
384
+ * by construction: after any number of iterations at most 64 objects (~3KB)
385
+ * are retained, so one measurement can never poison the next (PG-10).
386
+ * @returns {number} occupied ring slots, 0..64
387
+ */
168
388
  export function _controlKeepAlive() {
169
- return __posKeep.length;
389
+ let live = 0;
390
+ for (let i = 0; i < CONTROL_RING_SIZE; i++) {
391
+ const o = __posRing[i];
392
+ if (o !== null && o.w >= o.x) live++;
393
+ }
394
+ return live;
170
395
  }
171
396
 
172
- /** Positive control: allocates a heap object per iteration. */
397
+ /** Positive control: allocates one short-lived heap object per iteration. */
173
398
  export const controlPositive = {
174
- name: 'CONTROL+ (allocates {x,y,z,w} per iter)',
399
+ name: 'CONTROL+ ({x,y,z,w} per iter into a 64-slot ring)',
175
400
  setup: function () {
176
401
  return {};
177
402
  },
178
403
  hot: function (_s, n) {
179
- for (let i = 0; i < n; i++) __posKeep.push({x: i, y: i + 1, z: i + 2, w: i + 3});
180
- if (__posKeep.length > 3000000) __posKeep.length = 0;
404
+ for (let i = 0; i < n; i++) {
405
+ __posRing[i & CONTROL_RING_MASK] = {x: i, y: i + 1, z: i + 2, w: i + 3};
406
+ }
181
407
  }
182
408
  };
183
409
 
@@ -194,6 +420,153 @@ export const controlNegative = {
194
420
  }
195
421
  };
196
422
 
423
+ // controlLarge -- the detector control for the EXTERNAL/arrayBuffers signal
424
+ // (signal 5). The census (decisions/0003) proved the two things that fix this
425
+ // shape: (1) 256KB-string LO churn -- the planned controlLarge candidate --
426
+ // trips NEITHER new signal on Node v26.3.1 (oldGen 0, arrayBuffers 0), so it
427
+ // is not a control; (2) the oldGen positive control at suite defaults
428
+ // (Float64Array churn) costs ~2730ms and jitters 2744..2944, i.e. a per-run
429
+ // oldGen floor is noise amplification (Axis C's own rejection). A mask-gated
430
+ // ACCUMULATING 64KB-ArrayBuffer pool, by contrast, is measured DETERMINISTIC
431
+ // at 832KB arrayBuffers (6/6 reps), oldGen 0, ~200ms at the pinned
432
+ // {N:200000,k:8} window below -- 13x the 64KB gate, so the same measurement
433
+ // both validates the detector and demonstrates the gate catches it. That is
434
+ // the clean, cheap, always-on detector for the external lane; the oldGen
435
+ // lane's detector is the negative-control old-gen clause (no false positive)
436
+ // plus torture T3's must-catch C2 fixture (true positive). Axis D landed on an
437
+ // adapted D1: always-on placement, arrayBuffers clause.
438
+ //
439
+ // RESIDUE (decisions/0003 "controlLarge residue"): a retained ArrayBuffer pool
440
+ // raises V8's scheduled-scavenge accounting, which then fires PERIODIC
441
+ // scavenges INSIDE the next sequential measurement window -- mechanism proven
442
+ // H-a: the spurious entries' startTime lands in-window, not late delivery.
443
+ // This is not uncollected garbage, no gc clears it (forcing collections made
444
+ // it WORSE), and NO pool size is immune (256KB still poisoned 2/50). The
445
+ // residue is forward-only, so the fix is ORDERING: controlLarge is measured
446
+ // AFTER every scavenge-gated measurement. See measureLargeControl().
447
+ const CONTROL_LARGE_MASK = 131071; // one 64KB ArrayBuffer every 131072 iters
448
+ const CONTROL_LARGE_BYTES = 65536;
449
+ const CONTROL_LARGE_N = 200000; // pinned so the control's evidence does
450
+ const CONTROL_LARGE_K = 8; // not get cheaper with a consumer's N.
451
+
452
+ /**
453
+ * Large-object / external control: retains a bounded, mask-gated pool of 64KB
454
+ * ArrayBuffers so the arrayBuffers delta at k*N is large and repeatable
455
+ * (~832KB, 13x the default gate) at a fixed footprint regardless of n. Trips
456
+ * signal 5. Cleanup is owned two ways: teardown() drops the pool, and the
457
+ * detector call sites isolate its scheduled-scavenge residue by ORDERING --
458
+ * controlLarge is measured LAST, so nothing scavenge-gated follows it (the
459
+ * residue is never drained; draining makes it worse). decisions/0003.
460
+ */
461
+ export const controlLarge = {
462
+ name: 'CONTROL_LARGE (64KB ArrayBuffer every 131072 iters, accumulating pool)',
463
+ setup: function () {
464
+ return {pool: []};
465
+ },
466
+ hot: function (s, n) {
467
+ const p = s.pool;
468
+ for (let i = 0; i < n; i++) {
469
+ if ((i & CONTROL_LARGE_MASK) === 0) p.push(new ArrayBuffer(CONTROL_LARGE_BYTES));
470
+ }
471
+ },
472
+ // Drop every backing store so the pool is collectable the instant the pass
473
+ // ends. Necessary but not sufficient alone (meterOnce runs teardown after
474
+ // its final gc2); the next measurement is isolated by ORDERING -- measuring
475
+ // controlLarge LAST, not by draining the residue (draining makes it worse).
476
+ // decisions/0003 "controlLarge residue".
477
+ teardown: function (s) {
478
+ const p = s.pool;
479
+ for (let i = 0; i < p.length; i++) p[i] = null;
480
+ s.pool = null;
481
+ }
482
+ };
483
+
484
+ /**
485
+ * Measure controlLarge at its pinned window. The ONE place both detector call
486
+ * sites (zgcSuite, runGate) go through -- no diverging measurement logic.
487
+ *
488
+ * controlLarge leaves a scheduled-scavenge residue (H-a, decisions/0003
489
+ * "controlLarge residue") that poisons the NEXT scavenge-gated measurement.
490
+ * Forcing collections does NOT clear it -- measured, it made poisoning WORSE
491
+ * (drain 8/20/40 -> 3/8/9 caught of 10 cold processes). The residue is
492
+ * forward-only, so the fix is ORDERING, not draining: this control is measured
493
+ * AFTER every scavenge-gated measurement. runGate measures it last (below);
494
+ * zgcSuite measures it in the detector test, whose node:test-block boundary
495
+ * dissipates the residue before the first scenario test (verified 15/15).
496
+ *
497
+ * @param {Scenario} largeCtrl
498
+ * @param {number} flushMs
499
+ * @param {boolean} allowNoGc
500
+ * @returns {Promise<MeasureResult>}
501
+ */
502
+ async function measureLargeControl(largeCtrl, flushMs, allowNoGc) {
503
+ return measure(largeCtrl,
504
+ {N: CONTROL_LARGE_N, k: CONTROL_LARGE_K, flushMs: flushMs, allowNoGc: allowNoGc});
505
+ }
506
+
507
+ // ---------------------------------------------------------------------------
508
+ // Detector validation
509
+ // ---------------------------------------------------------------------------
510
+
511
+ // Detector-validation floors, DECOUPLED from the consumer's scenario
512
+ // thresholds on purpose: the evidence the instrument owes does not get
513
+ // cheaper because a consumer raised maxScavenges. decisions/0001.
514
+ const CONTROL_FLOOR = 6; // positive control: scavenges at k*N
515
+ const CONTROL_SCALE = 2; // positive control: minorHi / minorLo
516
+ const CONTROL_NEG_CEIL = 2; // negative control: hard ceiling
517
+ // Large-control floor: floor(observed 832KB / 3) -- a 3x margin under the
518
+ // deterministic reading, and 4x over the default maxArrayBuffersKB, so the
519
+ // same measurement both validates the detector and demonstrates the gate would
520
+ // catch it. decisions/0003.
521
+ const CONTROL_LARGE_FLOOR = 277; // arrayBuffers KB at k*N
522
+
523
+ /**
524
+ * The ONE detector-validation predicate. zgcSuite and runGate both call it;
525
+ * neither forks it. Comparisons are written so a NaN/undefined count fails
526
+ * closed (`!(x >= f)` / `!(x <= c)`).
527
+ *
528
+ * @param {MeasureResult} pos positive-control measurement
529
+ * @param {MeasureResult} neg negative-control measurement
530
+ * @param {MeasureResult} large controlLarge measurement (external lane)
531
+ * @param {number} maxScav suite scavenge threshold (tightens neg only)
532
+ * @returns {{ ok: boolean, reason: string | null }}
533
+ */
534
+ function validateDetector(pos, neg, large, maxScav) {
535
+ const flags = ' Run with --expose-gc --max-semi-space-size=4.';
536
+ if (!(pos.minorHi >= CONTROL_FLOOR)) {
537
+ return {ok: false, reason: 'positive control forced ' + pos.minorHi +
538
+ ' scavenges at ' + pos.k + 'N (need >=' + CONTROL_FLOOR + ').' + flags};
539
+ }
540
+ if (pos.minorLo > 0 && !(pos.minorHi >= CONTROL_SCALE * pos.minorLo)) {
541
+ return {ok: false, reason: 'positive control did not scale: ' +
542
+ pos.minorLo + ' at N, ' + pos.minorHi + ' at ' + pos.k +
543
+ 'N (need >=' + (CONTROL_SCALE * pos.minorLo) + ').' + flags};
544
+ }
545
+ const negCeil = maxScav < CONTROL_NEG_CEIL ? maxScav : CONTROL_NEG_CEIL;
546
+ if (!(neg.minorHi <= negCeil)) {
547
+ return {ok: false, reason: 'negative control forced ' + neg.minorHi +
548
+ ' scavenges (need <=' + negCeil + ') -- noisy process, or a prior ' +
549
+ 'measurement poisoned it.' + flags};
550
+ }
551
+ // Old-gen floor on the NEGATIVE control: the census (decisions/0003) shows
552
+ // C5 pure arithmetic fires 0 old-gen collections across 100 reps, so a
553
+ // nonzero here means a noisy instrument, not a clean hot path -- refuse to
554
+ // judge the oldgen lane on it. `!(<=)` fails closed on NaN/undefined.
555
+ if (!(neg.oldGenHi <= 0)) {
556
+ return {ok: false, reason: 'negative control forced ' + neg.oldGenHi +
557
+ ' old-gen collections (need 0) -- noisy process, the oldgen lane ' +
558
+ 'cannot be trusted.' + flags};
559
+ }
560
+ if (!(large.arrayBuffersKB_hi >= CONTROL_LARGE_FLOOR)) {
561
+ const got = typeof large.arrayBuffersKB_hi === 'number'
562
+ ? large.arrayBuffersKB_hi.toFixed(0) : String(large.arrayBuffersKB_hi);
563
+ return {ok: false, reason: 'large control forced ' + got +
564
+ 'KB arrayBuffers at ' + large.k + 'N (need >=' + CONTROL_LARGE_FLOOR +
565
+ 'KB) -- the external signal is blind.' + flags};
566
+ }
567
+ return {ok: true, reason: null};
568
+ }
569
+
197
570
  // ---------------------------------------------------------------------------
198
571
  // Verdict
199
572
  // ---------------------------------------------------------------------------
@@ -201,31 +574,89 @@ export const controlNegative = {
201
574
  /**
202
575
  * Evaluate a measurement against thresholds.
203
576
  *
577
+ * Thresholds are validated at config time (bad values throw); measured
578
+ * signals fail closed with named reasons, in this contract format:
579
+ * '<signal>: not a number (fail closed)' -- scavenges / retained / counter
580
+ * '<key>: no such counter measured (statsOf keys: ...)' -- typo'd counter key
581
+ * 'counters: thresholds set (...) but the scenario measured no counters
582
+ * -- add statsOf (fail closed)' -- counters_hi === null
583
+ *
204
584
  * @param {MeasureResult} r
205
585
  * @param {object} [thresholds]
206
- * @param {number} [thresholds.maxScavenges=2]
207
- * @param {number} [thresholds.maxRetainedKB=64]
586
+ * @param {number} [thresholds.maxScavenges=2] finite >= 0, else throws
587
+ * @param {number} [thresholds.maxRetainedKB=64] finite >= 0, else throws
208
588
  * @param {Record<string, number>} [thresholds.counters]
209
- * Per-counter maximum allowed delta. E.g. `{ poolGrowths: 0 }`.
589
+ * Per-counter maximum allowed delta. E.g. `{ poolGrowths: 0 }`. Each
590
+ * maximum must be a finite number, else throws.
210
591
  * @returns {{ pass: boolean, reasons: string[] }}
211
592
  */
212
593
  export function verdict(r, thresholds) {
594
+ validateGate('verdict', thresholds, null);
595
+ if (thresholds && thresholds.maxRetainedKB !== undefined && r && r.retainedReliable === false) {
596
+ throw new RangeError('lite-perf-gate: verdict: ' + NO_RETAINED);
597
+ }
598
+ if (thresholds && thresholds.maxArrayBuffersKB !== undefined && r && r.retainedReliable === false) {
599
+ throw new RangeError('lite-perf-gate: verdict: ' + NO_ARRAYBUFFERS);
600
+ }
213
601
  const maxScav = (thresholds && thresholds.maxScavenges !== undefined) ? thresholds.maxScavenges : 2;
214
602
  const maxRetKB = (thresholds && thresholds.maxRetainedKB !== undefined) ? thresholds.maxRetainedKB : 64;
603
+ const maxOldGen = (thresholds && thresholds.maxOldGen !== undefined) ? thresholds.maxOldGen : 0;
604
+ const maxAbKB = (thresholds && thresholds.maxArrayBuffersKB !== undefined) ? thresholds.maxArrayBuffersKB : 64;
215
605
  const ct = (thresholds && thresholds.counters) || null;
216
606
  const reasons = [];
217
607
 
218
- if (r.minorHi > maxScav) {
608
+ if (typeof r.minorHi !== 'number' || r.minorHi !== r.minorHi) {
609
+ reasons.push('scavenges: not a number (fail closed)');
610
+ } else if (r.minorHi > maxScav) {
219
611
  reasons.push('scavenges: ' + r.minorHi + ' > ' + maxScav + ' (transient allocation)');
220
612
  }
221
- if (r.retainedKB_hi > maxRetKB) {
222
- reasons.push('retained: ' + r.retainedKB_hi.toFixed(0) + 'KB > ' + maxRetKB + 'KB');
613
+ if (r.retainedReliable !== false) {
614
+ if (typeof r.retainedKB_hi !== 'number' || r.retainedKB_hi !== r.retainedKB_hi) {
615
+ reasons.push('retained: not a number (fail closed)');
616
+ } else if (r.retainedKB_hi > maxRetKB) {
617
+ reasons.push('retained: ' + r.retainedKB_hi.toFixed(0) + 'KB > ' + maxRetKB + 'KB');
618
+ }
619
+ }
620
+ // Old-gen lane (major + incremental). GATED on presence for back-compat:
621
+ // hand-built results (suiteGate's per-budget call) omit oldGenHi and must
622
+ // not gain a reason (decision 0002 policy 2). Reliable without --expose-gc:
623
+ // it comes from the observer, not memoryUsage, so it is NOT skipped when
624
+ // retainedReliable is false.
625
+ if (r.oldGenHi !== undefined) {
626
+ if (typeof r.oldGenHi !== 'number' || r.oldGenHi !== r.oldGenHi) {
627
+ reasons.push('oldgen: not a number (fail closed)');
628
+ } else if (r.oldGenHi > maxOldGen) {
629
+ reasons.push('oldgen: ' + r.oldGenHi + ' > ' + maxOldGen +
630
+ ' (old-gen GC activity -- diagnose with @zakkster/lite-gc-profiler)');
631
+ }
632
+ }
633
+ // External/arrayBuffers lane. GATED on presence, and -- like retained --
634
+ // skipped when retainedReliable is false, because the delta is bracketed
635
+ // by the same gc2() calls that no-op without --expose-gc.
636
+ if (r.arrayBuffersKB_hi !== undefined && r.retainedReliable !== false) {
637
+ if (typeof r.arrayBuffersKB_hi !== 'number' || r.arrayBuffersKB_hi !== r.arrayBuffersKB_hi) {
638
+ reasons.push('arrayBuffers: not a number (fail closed)');
639
+ } else if (r.arrayBuffersKB_hi > maxAbKB) {
640
+ reasons.push('arrayBuffers: ' + r.arrayBuffersKB_hi.toFixed(0) + 'KB > ' + maxAbKB +
641
+ 'KB (external memory -- diagnose with @zakkster/lite-gc-profiler)');
642
+ }
223
643
  }
224
- if (ct !== null && r.counters_hi !== null) {
225
- for (const key in ct) {
226
- const actual = r.counters_hi[key];
227
- if (actual !== undefined && actual > ct[key]) {
228
- reasons.push(key + ': ' + actual + ' > ' + ct[key]);
644
+ if (ct !== null) {
645
+ if (r.counters_hi === null || r.counters_hi === undefined) {
646
+ reasons.push('counters: thresholds set (' + Object.keys(ct).join(', ') +
647
+ ') but the scenario measured no counters -- add statsOf (fail closed)');
648
+ } else {
649
+ const ks = Object.keys(r.counters_hi);
650
+ for (const key in ct) {
651
+ const actual = r.counters_hi[key];
652
+ if (actual === undefined) {
653
+ reasons.push(key + ': no such counter measured (statsOf keys: ' +
654
+ (ks.length ? ks.join(', ') : 'none') + ')');
655
+ } else if (typeof actual !== 'number' || !isFinite(actual)) {
656
+ reasons.push(key + ': not a number (fail closed)');
657
+ } else if (actual > ct[key]) {
658
+ reasons.push(key + ': ' + actual + ' > ' + ct[key]);
659
+ }
229
660
  }
230
661
  }
231
662
  }
@@ -246,6 +677,12 @@ export function formatResult(r) {
246
677
  '\n scavenges N:' + String(r.minorLo).padStart(3) +
247
678
  ' ' + r.k + 'N:' + String(r.minorHi).padStart(3) +
248
679
  ' retained ' + r.retainedKB_hi.toFixed(0) + 'KB';
680
+ if (typeof r.oldGenHi === 'number' && r.oldGenHi > 0) {
681
+ s += ' oldgen ' + r.oldGenHi;
682
+ }
683
+ if (typeof r.arrayBuffersKB_hi === 'number' && r.arrayBuffersKB_hi > 0.5) {
684
+ s += ' arrayBuffers ' + r.arrayBuffersKB_hi.toFixed(0) + 'KB';
685
+ }
249
686
  if (r.counters_hi !== null) {
250
687
  for (const key in r.counters_hi) {
251
688
  s += ' ' + key + ' \u0394' + String(r.counters_hi[key]).padStart(6);
@@ -288,32 +725,49 @@ export function formatResult(r) {
288
725
  * @param {Scenario} [config.negativeControl] Override negative control.
289
726
  * @param {Scenario[]} [config.mustFail]
290
727
  * Scenarios that MUST trip the gate (injected allocation self-tests).
728
+ * @param {number} [config.flushMs]
729
+ * Wait (ms) after the hot loop before reading the GC observer buffer.
730
+ * Default 100 (or PERF_GATE_FLUSH_MS env var). 0 is legal and honored.
731
+ * @param {boolean} [config.allowEmpty=false]
732
+ * Permit an empty scenarios array for a controls-only detector smoke run
733
+ * -- the ONLY reason this option exists.
734
+ * @param {boolean} [config.allowNoGc=false]
735
+ * Run without --expose-gc; the retained rule is not applied and an
736
+ * explicit maxRetainedKB throws.
291
737
  */
292
738
  export function zgcSuite(config) {
293
- const scenarios = config.scenarios;
294
- const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
739
+ const o = validateGate('zgcSuite', config, null);
740
+ const cf = config || {};
741
+ const scenarios = cf.scenarios;
742
+ const opts = {N: o.N, k: o.k, flushMs: o.flushMs, allowNoGc: o.allowNoGc};
295
743
  const thresholds = {
296
- maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
297
- maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
298
- counters: config.counters || null
744
+ maxScavenges: cf.maxScavenges !== undefined ? cf.maxScavenges : 2,
745
+ maxRetainedKB: o.allowNoGc ? undefined : (cf.maxRetainedKB !== undefined ? cf.maxRetainedKB : 64),
746
+ maxOldGen: cf.maxOldGen !== undefined ? cf.maxOldGen : 0,
747
+ maxArrayBuffersKB: o.allowNoGc ? undefined : (cf.maxArrayBuffersKB !== undefined ? cf.maxArrayBuffersKB : 64),
748
+ counters: cf.counters || null
299
749
  };
300
- const posCtrl = config.positiveControl || controlPositive;
301
- const negCtrl = config.negativeControl || controlNegative;
302
- const mustFail = config.mustFail || [];
750
+ const posCtrl = cf.positiveControl || controlPositive;
751
+ const negCtrl = cf.negativeControl || controlNegative;
752
+ const largeCtrl = cf.largeControl || controlLarge;
753
+ const mustFail = cf.mustFail || [];
303
754
  const maxScav = thresholds.maxScavenges;
304
755
 
305
- test('perf-gate: detector sees a known allocation (positive control)', async function () {
306
- const r = await measure(posCtrl, opts);
756
+ test(scenarios.length === 0
757
+ ? 'perf-gate: detector validation only (allowEmpty, 0 scenarios)'
758
+ : 'perf-gate: detector validation (positive + negative + large controls)', async function () {
759
+ const pos = await measure(posCtrl, opts);
307
760
  _controlKeepAlive();
308
- assert.ok(r.minorHi > maxScav + 3,
309
- 'positive control should force scavenges, saw ' + r.minorHi +
310
- ' (need >' + (maxScav + 3) + '). Run with --expose-gc --max-semi-space-size=4.');
311
- });
312
-
313
- test('perf-gate: detector reads ~0 for a non-allocating loop (negative control)', async function () {
314
- const r = await measure(negCtrl, opts);
315
- assert.ok(r.minorHi <= maxScav,
316
- 'no-op control forced ' + r.minorHi + ' scavenges (need <=' + maxScav + ')');
761
+ const neg = await measure(negCtrl, opts);
762
+ // controlLarge is measured LAST in this detector test; its
763
+ // scheduled-scavenge residue (never drained -- draining makes it worse)
764
+ // is isolated by that ordering plus the node:test block boundary, so
765
+ // the scenario test blocks that follow are not poisoned (H-a,
766
+ // decisions/0003 "controlLarge residue").
767
+ const large = await measureLargeControl(largeCtrl, o.flushMs, o.allowNoGc);
768
+ const v = validateDetector(pos, neg, large, maxScav);
769
+ assert.ok(v.ok, 'DETECTOR VALIDATION FAILED: ' + v.reason +
770
+ '\n ' + formatResult(pos) + '\n ' + formatResult(neg) + '\n ' + formatResult(large));
317
771
  });
318
772
 
319
773
  for (let i = 0; i < scenarios.length; i++) {
@@ -346,61 +800,87 @@ export function zgcSuite(config) {
346
800
  // ---------------------------------------------------------------------------
347
801
 
348
802
  /**
349
- * Run a gate check and print a human-readable report. Exit codes:
350
- * 0 = pass, 1 = allocation detected, 2 = detector validation failed.
803
+ * Run a gate check and print a human-readable report.
804
+ *
805
+ * Returns `{ passed, code, results }`. `code`: 0 pass, 1 scenario or
806
+ * must-fail failure, 2 detector validation failed; `passed === (code === 0)`.
807
+ * runGate never touches `process.exitCode` -- map `result.code` to your exit
808
+ * code: `process.exit((await runGate(cfg)).code)`.
351
809
  *
352
810
  * @param {object} config Same shape as zgcSuite.
353
- * @returns {Promise<{ passed: boolean, results: MeasureResult[] }>}
811
+ * @returns {Promise<{ passed: boolean, code: 0 | 1 | 2, results: MeasureResult[] }>}
354
812
  */
355
813
  export async function runGate(config) {
356
- const scenarios = config.scenarios;
357
- const opts = {N: config.N || 200000, k: config.k || 8, flushMs: config.flushMs};
814
+ const o = validateGate('runGate', config, null);
815
+ const cf = config || {};
816
+ const scenarios = cf.scenarios;
817
+ const opts = {N: o.N, k: o.k, flushMs: o.flushMs, allowNoGc: o.allowNoGc};
358
818
  const thresholds = {
359
- maxScavenges: config.maxScavenges !== undefined ? config.maxScavenges : 2,
360
- maxRetainedKB: config.maxRetainedKB !== undefined ? config.maxRetainedKB : 64,
361
- counters: config.counters || null
819
+ maxScavenges: cf.maxScavenges !== undefined ? cf.maxScavenges : 2,
820
+ maxRetainedKB: o.allowNoGc ? undefined : (cf.maxRetainedKB !== undefined ? cf.maxRetainedKB : 64),
821
+ maxOldGen: cf.maxOldGen !== undefined ? cf.maxOldGen : 0,
822
+ maxArrayBuffersKB: o.allowNoGc ? undefined : (cf.maxArrayBuffersKB !== undefined ? cf.maxArrayBuffersKB : 64),
823
+ counters: cf.counters || null
362
824
  };
363
- const posCtrl = config.positiveControl || controlPositive;
364
- const negCtrl = config.negativeControl || controlNegative;
365
- const mustFail = config.mustFail || [];
825
+ const posCtrl = cf.positiveControl || controlPositive;
826
+ const negCtrl = cf.negativeControl || controlNegative;
827
+ const largeCtrl = cf.largeControl || controlLarge;
828
+ const mustFail = cf.mustFail || [];
366
829
  const maxScav = thresholds.maxScavenges;
367
830
  const N = opts.N;
368
831
  const k = opts.k;
369
832
 
370
833
  console.log('Zero-GC gate \u2014 scavenge scaling (N=' + N + ', ' + k + 'N=' + (k * N) + ')\n');
371
834
 
835
+ // Measure every scavenge-gated pass FIRST, while the process is clean, and
836
+ // controlLarge LAST. controlLarge's retained external memory raises V8's
837
+ // scheduled-scavenge accounting, which poisons the NEXT measurement's minor
838
+ // count (H-a, decisions/0003 "controlLarge residue"); no gc drains it, so
839
+ // ordering is the fix -- nothing scavenge-gated may follow it. Detector
840
+ // validation therefore runs after measurement, still returning code 2 and
841
+ // discarding results if the instrument is broken.
372
842
  const pos = await measure(posCtrl, opts);
373
843
  const neg = await measure(negCtrl, opts);
844
+
845
+ const results = [];
846
+ for (let i = 0; i < scenarios.length; i++) {
847
+ results.push(await measure(scenarios[i], opts));
848
+ }
849
+
850
+ const mfResults = [];
851
+ for (let j = 0; j < mustFail.length; j++) {
852
+ mfResults.push(await measure(mustFail[j], opts));
853
+ }
854
+
855
+ const large = await measureLargeControl(largeCtrl, o.flushMs, o.allowNoGc);
856
+ _controlKeepAlive();
857
+
374
858
  console.log('== controls ==');
375
859
  console.log(formatResult(pos));
376
860
  console.log(formatResult(neg));
377
- _controlKeepAlive();
861
+ console.log(formatResult(large));
378
862
 
379
- if (pos.minorHi <= maxScav + 3 || neg.minorHi > maxScav) {
380
- console.log('\n!! DETECTOR VALIDATION FAILED: positive ' + pos.minorHi +
381
- ' (need >' + (maxScav + 3) + '), negative ' + neg.minorHi +
382
- ' (need <=' + maxScav + ').');
383
- return {passed: false, results: []};
863
+ const dv = validateDetector(pos, neg, large, maxScav);
864
+ if (!dv.ok) {
865
+ console.log('\n!! DETECTOR VALIDATION FAILED: ' + dv.reason);
866
+ return {passed: false, code: 2, results: []};
384
867
  }
385
868
  console.log('\ndetector validated: positive forced ' + pos.minorHi +
386
- ' scavenges, negative forced ' + neg.minorHi + '.\n');
869
+ ' scavenges at ' + k + 'N (' + pos.minorLo + ' at N, floor ' +
870
+ CONTROL_FLOOR + '), negative forced ' + neg.minorHi + '.\n');
387
871
 
388
872
  console.log('== scenarios ==');
389
- const results = [];
390
- for (let i = 0; i < scenarios.length; i++) {
391
- const r = await measure(scenarios[i], opts);
392
- results.push(r);
393
- console.log(formatResult(r));
873
+ for (let i = 0; i < results.length; i++) {
874
+ console.log(formatResult(results[i]));
394
875
  }
395
876
 
396
877
  let mustFailOK = true;
397
- if (mustFail.length > 0) {
878
+ if (mfResults.length > 0) {
398
879
  console.log('\n== must-fail (gate self-test) ==');
399
- for (let j = 0; j < mustFail.length; j++) {
400
- const mf = await measure(mustFail[j], opts);
401
- const mfv = verdict(mf, thresholds);
880
+ for (let j = 0; j < mfResults.length; j++) {
881
+ const mfv = verdict(mfResults[j], thresholds);
402
882
  if (mfv.pass) mustFailOK = false;
403
- console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(mf));
883
+ console.log((mfv.pass ? 'MISSED ' : 'CAUGHT ') + formatResult(mfResults[j]));
404
884
  }
405
885
  }
406
886
 
@@ -420,7 +900,11 @@ export async function runGate(config) {
420
900
 
421
901
  const passed = failures.length === 0 && mustFailOK;
422
902
  if (passed) {
423
- console.log('\nZERO-GC GATE: PASS \u2014 ' + results.length + '/' + results.length + ' scenarios.');
903
+ if (results.length === 0) {
904
+ console.log('\nZERO-GC GATE: PASS \u2014 detector only (allowEmpty, 0 scenarios gated).');
905
+ } else {
906
+ console.log('\nZERO-GC GATE: PASS \u2014 ' + results.length + '/' + results.length + ' scenarios.');
907
+ }
424
908
  } else {
425
909
  const why = failures.map(function (f) {
426
910
  return f.name;
@@ -428,7 +912,8 @@ export async function runGate(config) {
428
912
  if (!mustFailOK) why.push('must-fail scenario was not caught');
429
913
  console.log('\nZERO-GC GATE: FAIL \u2014 ' + why.join('; '));
430
914
  }
431
- return {passed: passed, results: results};
915
+ const code = passed ? 0 : 1;
916
+ return {passed: passed, code: code, results: results};
432
917
  }
433
918
 
434
919
  // ---------------------------------------------------------------------------
@@ -450,19 +935,27 @@ export async function runGate(config) {
450
935
 
451
936
  const SPP_OP_CONT = 0x0F01;
452
937
 
453
- const SUITE_REDUCES = {count: 1, sum: 1, max: 1, mean: 1, last: 1};
454
- const SUITE_SLOTS = {t: 1, a: 2, b: 3};
938
+ const SUITE_REDUCES = {__proto__: null, count: 1, sum: 1, max: 1, mean: 1, last: 1};
939
+ const SUITE_SLOTS = {__proto__: null, t: 1, a: 2, b: 3};
455
940
 
456
941
  function suiteMatcher(b, i) {
457
942
  if (b.packed !== undefined) {
458
943
  if (!Number.isInteger(b.packed) || b.packed < 0 || b.packed > 0xFFFFFFFF) {
459
944
  throw new RangeError('suiteGate: budget[' + i + '] packed must be a u32');
460
945
  }
946
+ if ((b.packed & 0xFFFF) === SPP_OP_CONT) {
947
+ throw new RangeError('suiteGate: budget[' + i + '] targets CONT (0x0F01): ' +
948
+ 'CONT records are never budget targets (SPP v1)');
949
+ }
461
950
  return {exact: b.packed >>> 0, op: -1};
462
951
  }
463
952
  if (b.op === undefined || !Number.isInteger(b.op) || b.op < 0 || b.op > 0xFFFF) {
464
953
  throw new RangeError('suiteGate: budget[' + i + '] needs a u16 op (with optional stream), or packed');
465
954
  }
955
+ if (b.op === SPP_OP_CONT) {
956
+ throw new RangeError('suiteGate: budget[' + i + '] targets CONT (0x0F01): ' +
957
+ 'CONT records are never budget targets (SPP v1)');
958
+ }
466
959
  if (b.stream !== undefined) {
467
960
  if (!Number.isInteger(b.stream) || b.stream < 0 || b.stream > 0xFFFF) {
468
961
  throw new RangeError('suiteGate: budget[' + i + '] stream must be a u16');
@@ -510,7 +1003,8 @@ export function suiteGate(config) {
510
1003
  const match = new Array(n);
511
1004
  const slot = new Array(n);
512
1005
  const reduce = new Array(n);
513
- const seen = {};
1006
+ const minCnt = new Array(n);
1007
+ const seen = Object.create(null);
514
1008
  for (let i = 0; i < n; i++) {
515
1009
  const b = budgets[i];
516
1010
  if (!b || typeof b.name !== 'string' || b.name.length === 0) {
@@ -529,9 +1023,18 @@ export function suiteGate(config) {
529
1023
  if (SUITE_REDUCES[rd] === undefined) {
530
1024
  throw new RangeError('suiteGate: budget "' + b.name + '" reduce must be count, sum, max, mean, or last');
531
1025
  }
1026
+ let mc = 0;
1027
+ if (b.minCount !== undefined) {
1028
+ if (!Number.isInteger(b.minCount) || b.minCount < 0) {
1029
+ throw new RangeError('suiteGate: budget "' + b.name +
1030
+ '" minCount must be an integer >= 0 (got ' + String(b.minCount) + ')');
1031
+ }
1032
+ mc = b.minCount;
1033
+ }
532
1034
  match[i] = suiteMatcher(b, i);
533
1035
  slot[i] = SUITE_SLOTS[sl];
534
1036
  reduce[i] = rd;
1037
+ minCnt[i] = mc;
535
1038
  }
536
1039
 
537
1040
  const count = new Float64Array(n);
@@ -554,14 +1057,58 @@ export function suiteGate(config) {
554
1057
  }
555
1058
  }
556
1059
 
1060
+ // Cold thrower (D-A / D-A'): the loop and visitChecked hold the index, so
1061
+ // visit()'s body stays byte-identical to the pre-door build and pays no index tax.
1062
+ function badRecord(idx, p) {
1063
+ throw new RangeError('suiteGate: record ' + idx + ': packed ' + String(p) +
1064
+ ' is not a u32 -- an SPP record is 4 numbers [packed, t, a, b]');
1065
+ }
1066
+ // D-B (forEach lane, EVERY invocation): a slot that is not a number is a
1067
+ // corrupt record. A native Array/TypedArray forEach binds (value, index,
1068
+ // array) onto (packed, t, a, b); a non-native sink can also emit a non-
1069
+ // number slot at any index (null/'3'/true finitely coerce and pass a
1070
+ // budget silently -- 'null is not zero'). Checked on every record, not
1071
+ // just record 0.
1072
+ function badSlot(idx, t, a, b) {
1073
+ const bad = typeof t !== 'number' ? 't (' + typeof t + ')'
1074
+ : typeof a !== 'number' ? 'a (' + typeof a + ')'
1075
+ : 'b (' + typeof b + ')';
1076
+ throw new RangeError('suiteGate: record ' + idx + ': slot ' + bad +
1077
+ ' is not a number -- forEach must invoke cb(packed, t, a, b) with ' +
1078
+ 'four numbers (a native Array/TypedArray forEach binds ' +
1079
+ '(value, index, array) instead)');
1080
+ }
1081
+ let seenRec = 0;
1082
+ function visitChecked(packed, t, a, b) {
1083
+ if (packed !== packed >>> 0) badRecord(seenRec, packed);
1084
+ if (typeof t !== 'number' || typeof a !== 'number' || typeof b !== 'number') {
1085
+ badSlot(seenRec, t, a, b);
1086
+ }
1087
+ seenRec++;
1088
+ visit(packed, t, a, b);
1089
+ }
1090
+
1091
+ // D-C (cold dispatch): a typed-array view that is not a same-realm
1092
+ // Float64Array dies before one record is read, naming its constructor.
1093
+ // A cross-realm Float64Array fails `instanceof` and lands here too --
1094
+ // ArrayBuffer.isView is realm-agnostic, so it is caught, not routed to the
1095
+ // arity-collision forEach path.
1096
+ if (ArrayBuffer.isView(source) && !(source instanceof Float64Array)) {
1097
+ throw new RangeError('suiteGate: record 0: source is a ' +
1098
+ source.constructor.name + ' view from another realm, or a ' +
1099
+ 'non-Float64Array view -- pass a same-realm Float64Array SPP slab ' +
1100
+ '(a foreign or wrong-typed view forEach binds (value, index, array))');
1101
+ }
557
1102
  if (source && typeof source.forEach === 'function' && !(source instanceof Float64Array)) {
558
- source.forEach(visit);
1103
+ source.forEach(visitChecked);
559
1104
  } else if (source instanceof Float64Array) {
560
1105
  if (source.length % 4 !== 0) {
561
1106
  throw new RangeError('suiteGate: slab length must be divisible by 4');
562
1107
  }
563
1108
  for (let r = 0; r < source.length; r += 4) {
564
- visit(source[r], source[r + 1], source[r + 2], source[r + 3]);
1109
+ const p = source[r];
1110
+ if (p !== p >>> 0) badRecord(r >> 2, p); // D-A, the measured door
1111
+ visit(p, source[r + 1], source[r + 2], source[r + 3]);
565
1112
  }
566
1113
  } else {
567
1114
  throw new TypeError('suiteGate: source must be a Float64Array slab or a forEach record source');
@@ -579,19 +1126,36 @@ export function suiteGate(config) {
579
1126
  else if (reduce[i] === 'mean') value = count[i] > 0 ? sum[i] / count[i] : 0;
580
1127
  else value = count[i] > 0 ? last[i] : 0;
581
1128
 
582
- const counters = {};
1129
+ const counters = {__proto__: null};
583
1130
  counters[b.name] = value;
584
- const ct = {};
1131
+ const ct = {__proto__: null};
585
1132
  ct[b.name] = b.max;
586
1133
  const v = verdict(
587
1134
  {name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0, counters_hi: counters},
588
1135
  {counters: ct}
589
1136
  );
590
- if (!v.pass) pass = false;
591
- for (let ri = 0; ri < v.reasons.length; ri++) reasons.push(v.reasons[ri]);
1137
+ const rs = v.reasons.slice(); // cold; per budget, not per record
1138
+ let bPass = v.pass;
1139
+ const mc = minCnt[i];
1140
+ if (mc > 0) {
1141
+ // minCount is a MINIMUM on count; verdict() is max-only, so express
1142
+ // it as a maximum of 0 on the SHORTFALL (mc - count) in a SECOND,
1143
+ // separate verdict() call -- the fixed internal key 'shortfall'
1144
+ // lives in its own namespace and cannot alias a user budget name.
1145
+ const mv = verdict(
1146
+ {name: b.name, minorHi: 0, majorHi: 0, retainedKB_hi: 0,
1147
+ counters_hi: {__proto__: null, shortfall: mc - count[i]}},
1148
+ {counters: {__proto__: null, shortfall: 0}});
1149
+ if (!mv.pass) {
1150
+ bPass = false;
1151
+ rs.push(b.name + ': matched ' + count[i] + ' < minCount ' + mc);
1152
+ }
1153
+ }
1154
+ if (!bPass) pass = false;
1155
+ for (let ri = 0; ri < rs.length; ri++) reasons.push(rs[ri]);
592
1156
  perBudget.push({
593
1157
  name: b.name, value: value, count: count[i], max: b.max,
594
- pass: v.pass, reasons: v.reasons
1158
+ minCount: mc, pass: bPass, reasons: rs
595
1159
  });
596
1160
  }
597
1161
 
@@ -625,7 +1189,9 @@ function ndjsonMeasure(r, meta, lines) {
625
1189
  type: 'measure', name: r.name, N: r.N, k: r.k,
626
1190
  minorLo: r.minorLo, minorHi: r.minorHi,
627
1191
  majorLo: r.majorLo, majorHi: r.majorHi,
1192
+ oldGenLo: r.oldGenLo, oldGenHi: r.oldGenHi,
628
1193
  retainedKB_lo: r.retainedKB_lo, retainedKB_hi: r.retainedKB_hi,
1194
+ arrayBuffersKB_lo: r.arrayBuffersKB_lo, arrayBuffersKB_hi: r.arrayBuffersKB_hi,
629
1195
  counters_lo: r.counters_lo, counters_hi: r.counters_hi
630
1196
  })));
631
1197
  }