@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/CHANGELOG.md +337 -0
- package/PerfGate.d.ts +86 -3
- package/PerfGate.js +666 -100
- package/llms.txt +69 -10
- package/package.json +8 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,342 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.0] - 2026-09-13
|
|
4
|
+
|
|
5
|
+
> One release, three roadmap sessions. The registry latest was 1.3.0;
|
|
6
|
+
> sessions P2, P3 and P4 were developed against interim targets 1.4.0,
|
|
7
|
+
> 1.5.0 and 1.6.0 -- none of which was ever published -- and ship
|
|
8
|
+
> together here as 1.4.0. Session records under decisions/ keep their
|
|
9
|
+
> original target numbers as history.
|
|
10
|
+
|
|
11
|
+
### suiteGate record doors, inherited-key hardening, CONT rejection, minCount (session P4)
|
|
12
|
+
|
|
13
|
+
#### Added
|
|
14
|
+
|
|
15
|
+
- **`minCount`, the presence assertion (the ONE surface addition).** An
|
|
16
|
+
optional per-budget integer >= 0. Without it, a budget whose `op` is a
|
|
17
|
+
typo matches zero records, reduces to 0, and passes forever -- the
|
|
18
|
+
`count: 0` was reported and nothing ever looked at it. With
|
|
19
|
+
`minCount: n`, a budget that matched fewer than `n` records FAILS with
|
|
20
|
+
`'<name>: matched <count> < minCount <n>'`. It delegates through a
|
|
21
|
+
SECOND `verdict()` call carrying the shortfall (`minCount - count`) as a
|
|
22
|
+
maximum of 0 -- one comparison authority, and `verdict()` stays
|
|
23
|
+
max-only and unaware a minimum exists (decisions/0004 D-G).
|
|
24
|
+
`SuiteBudgetResult` gains `minCount` (0 when omitted); the NDJSON budget
|
|
25
|
+
line is deliberately UNCHANGED (schema frozen).
|
|
26
|
+
- **Back-compat, stated and pinned:** WITHOUT `minCount`, a zero-match
|
|
27
|
+
budget still reduces to 0, still reports `count: 0`, and still PASSES
|
|
28
|
+
(v1.1 behavior). `minCount: 0` is provably inert (result deep-equal to
|
|
29
|
+
omitting it). Presence assertions are opt-in because silence is
|
|
30
|
+
legitimately a pass for budgets like 'no gc pause over 8ms'.
|
|
31
|
+
- **Torture T4 activates: the suiteGate reduction gate.** `measureOps`
|
|
32
|
+
over `suiteGate` on a preallocated 1M-record slab with 8 budgets, gated
|
|
33
|
+
on per-record retained bytes (the 1M-vs-1K `bytesPerOp` difference),
|
|
34
|
+
`checkOps(maxBytesPerOp)`, `checkNoGc(maxMajor 0, maxArrayBuffersGrowth
|
|
35
|
+
0)`, and an `nsPerRecord < 25` catastrophe ceiling. Runs BEFORE T5 so no
|
|
36
|
+
`measureOps` is ever in flight inside T5's `GcProfiler` window. A
|
|
37
|
+
`TORTURE_CONTROL=allocating-visit` lane (test-code only) drives a
|
|
38
|
+
forEach shim that RETAINS one object per record and MUST fail T4.
|
|
39
|
+
|
|
40
|
+
#### Changed
|
|
41
|
+
|
|
42
|
+
- **PG-06: wrong-shaped record sources now THROW, naming the record
|
|
43
|
+
index.** The same over-budget record used to reach three different
|
|
44
|
+
verdicts by container: FAIL as a `Float64Array` slab (correct), PASS as
|
|
45
|
+
a `Float32Array` (native `forEach` binds `(value, index, array)` ->
|
|
46
|
+
reduced value `-Infinity`, count 1), PASS as an array of tuples
|
|
47
|
+
(count 0). Now: a slab record whose packed header is not a u32 throws a
|
|
48
|
+
`RangeError` naming the record index (D-A, in the slab driver loop);
|
|
49
|
+
a non-`Float64Array` (or cross-realm) typed-array source throws at
|
|
50
|
+
dispatch naming its constructor (D-C); a `forEach` source whose ANY
|
|
51
|
+
invocation is not four numbers throws naming the invocation index and
|
|
52
|
+
the failing slot (D-B, checked on every record -- a `null`/`'3'`/`true`
|
|
53
|
+
slot at a later record would finitely coerce and pass a budget silently
|
|
54
|
+
otherwise). A NaN slot is a number and passes D-B by design: it fails
|
|
55
|
+
closed at D-D only when the reducer PROPAGATES it (`sum`/`mean`, or
|
|
56
|
+
`max`/`last` at the extremum); under `max`/`last`/`count` a NaN
|
|
57
|
+
coexisting with finite records is silently excluded and the budget can
|
|
58
|
+
pass (count inflated, minCount satisfied), identically on both lanes --
|
|
59
|
+
a documented residue (decisions/0004).
|
|
60
|
+
- **PG-06 / D-D: a reduced value that is not finite FAILS closed.**
|
|
61
|
+
`verdict()`'s counter lane predicate widened by ONE line from
|
|
62
|
+
`actual !== actual` to `!isFinite(actual)`, so `Infinity` and
|
|
63
|
+
`-Infinity` now read `'<name>: not a number (fail closed)'`. This closes
|
|
64
|
+
the `-Infinity` fail-open the P2 reviewer documented as
|
|
65
|
+
unreachable-until-P4. Public side effect: a `statsOf` counter delta of
|
|
66
|
+
`Infinity` now reads `'k: not a number (fail closed)'` instead of
|
|
67
|
+
`'k: Infinity > 0'` -- still a failure, only the wording changed. The
|
|
68
|
+
other four verdict lanes keep the NaN-only predicate (decisions/0004
|
|
69
|
+
D-D scope note).
|
|
70
|
+
- **PG-12: inherited prototype keys no longer corrupt validation.**
|
|
71
|
+
`SUITE_SLOTS` / `SUITE_REDUCES` are null-proto, so `slot: 'toString'`
|
|
72
|
+
throws `slot must be` and `reduce: 'constructor'` throws `reduce must
|
|
73
|
+
be`; the duplicate-name map (`seen`) and the per-budget `counters`/`ct`
|
|
74
|
+
objects are null-proto, so a budget legitimately NAMED `toString`,
|
|
75
|
+
`hasOwnProperty`, `__proto__`, or `constructor` is legal end to end,
|
|
76
|
+
through `toNDJSON` too (verified by round-trip, incl. a name with a
|
|
77
|
+
quote and a newline).
|
|
78
|
+
- **PG-13: a budget targeting CONT (0x0F01) now throws at config,** both
|
|
79
|
+
the `{op: 0x0F01}` and the packed low-16-bits forms:
|
|
80
|
+
`'CONT records are never budget targets (SPP v1)'`. `visit` skips CONT
|
|
81
|
+
by protocol, so such a budget passed vacuously forever. The v1.1
|
|
82
|
+
self-test that PINNED that vacuous pass is rewritten this release,
|
|
83
|
+
deliberately; the CONT RECORD-in-slab protocol behavior is unchanged.
|
|
84
|
+
- **`visit`'s body is byte-identical to v1.5.0.** The D-A per-record door
|
|
85
|
+
lives in the slab driver loop (`if (p !== p >>> 0) badRecord(r >> 2, p)`),
|
|
86
|
+
which already holds the index; the `forEach` lane gets a separate
|
|
87
|
+
`visitChecked` wrapper. `meterOnce`, `gc2`, `zgcSuite`, `runGate` are
|
|
88
|
+
unchanged; `verdict()` changed exactly one line.
|
|
89
|
+
- **T4 cost (darwin 25.6.0, Node v26.3.1, `--expose-gc
|
|
90
|
+
--max-semi-space-size=4`):** nsPerRecord p50 15.5310 (v1.5.0) ->
|
|
91
|
+
15.7637 (v1.6.0), delta +0.2327 ns/record; min delta +0.0661. The
|
|
92
|
+
v1.5.0 back-to-back noise band was 15.3637 / 15.5310 / 18.0343
|
|
93
|
+
(min/p50/max), a 2.6706 ns spread -- the door's delta is far inside it,
|
|
94
|
+
so D-A ships per record. Per-record retained bytes measured 0.00000
|
|
95
|
+
(min, noise to 0.00123), well under the 0.01 gate (decisions/0004).
|
|
96
|
+
|
|
97
|
+
### Bypass signals -- oldGen + arrayBuffers lanes, census-driven (session P3)
|
|
98
|
+
|
|
99
|
+
#### Added
|
|
100
|
+
|
|
101
|
+
- **Two new gate signals, chosen from a GC-kind census, close the
|
|
102
|
+
allocation bypasses (PG-02, PG-03).** The gate now measures FIVE
|
|
103
|
+
signals: scavenges, custom counters, retained heap, plus (4) **old-gen
|
|
104
|
+
activity** (`major + incremental`, threshold `maxOldGen` default 0) and
|
|
105
|
+
(5) **external/arrayBuffers growth** (`memoryUsage().arrayBuffers`
|
|
106
|
+
delta, threshold `maxArrayBuffersKB` default 64). Both are GATE signals,
|
|
107
|
+
not a profiler: each over-budget reason ends with "diagnose with
|
|
108
|
+
@zakkster/lite-gc-profiler". The boundary law holds -- no GC-kind
|
|
109
|
+
breakdown on results beyond the two composed counters.
|
|
110
|
+
- **The GC-kind census (decisions/0003).** Before any signal froze, a raw
|
|
111
|
+
`PerformanceObserver` census (`test/probes/census-0003.mjs`, repo-only)
|
|
112
|
+
logged kind + flags + timing across the bypass corpus, three corpus
|
|
113
|
+
runs plus a 100-rep x 2 ambient distribution. It falsified the naive
|
|
114
|
+
design: on Node v26.3.1, C1's 600KB-string LO churn (PG-02) fires
|
|
115
|
+
`oldGen=0` in-window and a flat settle, so it is a DOCUMENTED HOLE, not
|
|
116
|
+
a catch (Axis A4). C2's Float64Array churn fires `oldGen=2` (caught).
|
|
117
|
+
C3's 16MB pool grows `arrayBuffers` 16384KB while retained reads <64KB
|
|
118
|
+
(caught). Ambient old-gen and arrayBuffers were 0 across all 200 reps,
|
|
119
|
+
so the defaults (0 / 64) cause zero spurious failures.
|
|
120
|
+
- **`controlLarge` + external detector integrity.** A bounded, mask-gated
|
|
121
|
+
retained 64KB-ArrayBuffer pool (deterministic 832KB arrayBuffers at the
|
|
122
|
+
pinned k*N, 13x the gate) validates signal 5 on every `zgcSuite`/`runGate`
|
|
123
|
+
run, as the third argument to the ONE `validateDetector(pos, neg, large,
|
|
124
|
+
maxScav)` predicate (no fork). A `largeControl` config override and a
|
|
125
|
+
negative-control old-gen clause (the arithmetic control must fire 0
|
|
126
|
+
old-gen) complete the detector. Because a retained-external control
|
|
127
|
+
leaves a V8 scheduled-scavenge residue that poisons the next
|
|
128
|
+
scavenge-gated measurement (mechanism proven in decisions/0003, not
|
|
129
|
+
drainable), `controlLarge` is measured LAST in `runGate` and last in
|
|
130
|
+
`zgcSuite`'s detector block; nothing scavenge-gated follows it.
|
|
131
|
+
- **`MeasureResult` gains `oldGenLo/oldGenHi` and
|
|
132
|
+
`arrayBuffersKB_lo/arrayBuffersKB_hi`** (additive -- no existing key
|
|
133
|
+
changes). `toNDJSON`'s measure line carries them. `formatResult` shows
|
|
134
|
+
old-gen and arrayBuffers only when nonzero.
|
|
135
|
+
- **Torture T3** activates: the PG-02/PG-03 bypasses as permanent
|
|
136
|
+
bare-child fixtures at pinned windows, all judged at default thresholds,
|
|
137
|
+
plus a `TORTURE_CONTROL=zero-signal` lane (test-code only) that zeroes
|
|
138
|
+
the two new fields in-child and MUST make T3 fail.
|
|
139
|
+
|
|
140
|
+
#### Changed
|
|
141
|
+
|
|
142
|
+
- **New defaults CAN fail a previously-green suite** -- exactly when a hot
|
|
143
|
+
path fires an old-gen collection (`maxOldGen: 0`) or grows external
|
|
144
|
+
memory past 64KB (`maxArrayBuffersKB: 64`). That is the release: the
|
|
145
|
+
gate now sees PG-03a and PG-03b. The 0/200 ambient census reps are the
|
|
146
|
+
evidence a genuinely zero-alloc path trips neither.
|
|
147
|
+
- **PG-02 (600KB-string LO churn) is recorded as an uncatchable hole on
|
|
148
|
+
Node v26.3.1**, not silently "fixed": it fires no countable GC event any
|
|
149
|
+
lane can gate, and T3's t3a asserts its measured signature so a future
|
|
150
|
+
Node that closes the hole will flip the fixture.
|
|
151
|
+
- **`allowNoGc: true` with an explicit `maxArrayBuffersKB` throws** (new
|
|
152
|
+
`NO_ARRAYBUFFERS` door, mirroring `maxRetainedKB`); the arrayBuffers
|
|
153
|
+
lane is not applied at all when `retainedReliable === false`. The
|
|
154
|
+
old-gen lane comes from the observer, not `memoryUsage`, so it gates
|
|
155
|
+
even under `allowNoGc`.
|
|
156
|
+
- **The measurement window and `suiteGate` are byte-identical to v1.4.0.**
|
|
157
|
+
The observer callback gained two compare-and-increment branches
|
|
158
|
+
(incremental, weakcb -- outside the hot body); `incremental` is read
|
|
159
|
+
after `gcc.close()`, and the arrayBuffers captures reuse the existing
|
|
160
|
+
before/after `memoryUsage()` points. `gc2` and `suiteGate` are
|
|
161
|
+
untouched (P4 owns suiteGate).
|
|
162
|
+
|
|
163
|
+
### Fail-closed doors -- thresholds, options, instrument, process contract (session P2)
|
|
164
|
+
|
|
165
|
+
#### Changed
|
|
166
|
+
|
|
167
|
+
- **Thresholds throw instead of passing everything (PG-04).**
|
|
168
|
+
`maxScavenges` and `maxRetainedKB` must be finite numbers >= 0 and
|
|
169
|
+
every `counters` maximum must be a finite number; NaN, Infinity,
|
|
170
|
+
negative counts and non-numbers now raise TypeError/RangeError at
|
|
171
|
+
config time, prefixed `lite-perf-gate: <where>: ` and naming the
|
|
172
|
+
field and the value. Reproduction inverted:
|
|
173
|
+
`verdict(r, {maxScavenges: NaN, maxRetainedKB: NaN})` used to pass a
|
|
174
|
+
result carrying 999 scavenges and 9999KB retained.
|
|
175
|
+
- **A NaN measurement fails instead of passing (PG-04).** A gated signal
|
|
176
|
+
that is not a finite number now produces
|
|
177
|
+
`'<signal>: not a number (fail closed)'` -- `scavenges`, `retained`,
|
|
178
|
+
or the counter's own key. A NaN measurement is evidence of a broken
|
|
179
|
+
instrument, never of a clean hot path.
|
|
180
|
+
- **A counter threshold that matches nothing fails (PG-05).** A typo'd
|
|
181
|
+
key produces `'<key>: no such counter measured (statsOf keys: ...)'`;
|
|
182
|
+
counter thresholds set against a scenario with no `statsOf` produce
|
|
183
|
+
one `'counters: ... (fail closed)'` reason. Previously
|
|
184
|
+
`{poolGrowth: 0}` against a measured `poolGrowths: 99` passed with
|
|
185
|
+
zero reasons, forever, silently.
|
|
186
|
+
- **Missing `--expose-gc` throws (PG-11).** `measure()` -- and so
|
|
187
|
+
`zgcSuite`/`runGate` -- refuses at entry when `globalThis.gc` is not
|
|
188
|
+
a function, with the exact run command in the message. Previously
|
|
189
|
+
`gc2()` no-opped and the retained signal judged uncollected garbage
|
|
190
|
+
(a plain-node run produced a spurious `retained: 2901KB > 64KB`).
|
|
191
|
+
- **Empty scenario lists throw (PG-08).** `zgcSuite`/`runGate` reject a
|
|
192
|
+
missing, non-array or empty `scenarios`; pass `allowEmpty: true` for a
|
|
193
|
+
controls-only detector smoke run. `runGate`'s old `PASS -- 0/0
|
|
194
|
+
scenarios` rubber stamp is now `detector only (allowEmpty, 0
|
|
195
|
+
scenarios gated)`, and zgcSuite's control test renames itself
|
|
196
|
+
`detector validation only (allowEmpty, 0 scenarios)`.
|
|
197
|
+
- **`measure()` options are validated, and `flushMs: 0` is honored
|
|
198
|
+
(PG-09).** `N` integer >= 1, `k` integer >= 2, `flushMs` finite >= 0,
|
|
199
|
+
`allowNoGc`/`allowEmpty` strictly boolean (a truthy non-`true` value
|
|
200
|
+
throws). Every `(options && options.X) || DEFAULT` resolution is now
|
|
201
|
+
`!== undefined`, so `flushMs: 0` reaches the sleep instead of
|
|
202
|
+
silently meaning 100ms -- the one backward-incompatible behavior
|
|
203
|
+
change in this release. `{N: -1}` used to skip every loop and make
|
|
204
|
+
the POSITIVE CONTROL report zero allocation; `{k: 0.5}` used to
|
|
205
|
+
invert the two-scale design; `{flushMs: -50}` used to collapse the
|
|
206
|
+
observer window to ~1ms with a Node TimeoutNegativeWarning.
|
|
207
|
+
- **Scenario shapes are validated at the same door.** `name` a non-empty
|
|
208
|
+
string, `setup`/`hot` functions, `statsOf`/`teardown` functions when
|
|
209
|
+
present -- across `scenarios[]`, `mustFail[]`, `positiveControl` and
|
|
210
|
+
`negativeControl`, each named by its position in the message.
|
|
211
|
+
- **`runGate`'s exit-code claim is corrected (PG-14).** The old
|
|
212
|
+
docstring -- and the 1.2.0 entry below -- promised "exit codes 0/1/2"
|
|
213
|
+
and set none. `runGate` never touches `process.exitCode`: runner
|
|
214
|
+
semantics stay in the runner layer. It now RETURNS `code` and the
|
|
215
|
+
docs say "map result.code to your exit code".
|
|
216
|
+
|
|
217
|
+
#### Added
|
|
218
|
+
|
|
219
|
+
- **`code: 0 | 1 | 2` on GateResult.** 0 pass, 1 a failing scenario or
|
|
220
|
+
an uncaught `mustFail`, 2 detector validation failed.
|
|
221
|
+
`passed === (code === 0)`, asserted by test. Additive: `passed` and
|
|
222
|
+
`results` are unchanged.
|
|
223
|
+
- **`allowNoGc`** (measure options + GateConfig). Runs without
|
|
224
|
+
`--expose-gc`; the result carries `retainedReliable: false`, the
|
|
225
|
+
retained rule is not applied at all (the default 64KB threshold
|
|
226
|
+
included), `retainedKB_lo/_hi` remain as diagnostics, and scavenges
|
|
227
|
+
and counters still gate. Combining it with an explicit
|
|
228
|
+
`maxRetainedKB` throws: retained is ungateable without the flag.
|
|
229
|
+
- **`allowEmpty`** (GateConfig). The only sanctioned empty gate: a
|
|
230
|
+
controls-only detector smoke run, which is exactly what the torture
|
|
231
|
+
suite's T1a child is.
|
|
232
|
+
- **`retainedReliable`** on MeasureResult. `false` only when `measure()`
|
|
233
|
+
ran without `globalThis.gc`; an absent field means reliable.
|
|
234
|
+
- **Torture tier T2 -- fail-closed doors** (`test/torture/t2-doors.mjs`):
|
|
235
|
+
sixteen door cases hit from OUTSIDE the library, each with its passing
|
|
236
|
+
twin one valid step away, plus two children spawned WITHOUT
|
|
237
|
+
`--expose-gc` (one asserting the throw carries the run command, one
|
|
238
|
+
asserting the `allowNoGc` retained behavior). Third
|
|
239
|
+
control-for-the-control: `TORTURE_CONTROL=no-doors npm run torture`
|
|
240
|
+
routes the NaN cases through a legacy fail-open shim and T2 MUST fail.
|
|
241
|
+
- **Six self-tests** (28 -> 34): threshold doors, NaN measurements,
|
|
242
|
+
unknown/absent counters, measure option doors including a timing proof
|
|
243
|
+
that `flushMs: 0` is honored, the empty-gate doors, and `runGate`
|
|
244
|
+
codes 0/1/2 in bare children with `process.exitCode` asserted
|
|
245
|
+
untouched in all three.
|
|
246
|
+
- **`decisions/0002-fail-closed.md`** -- the six policies, the rejected
|
|
247
|
+
`allowNoGc` shapes, and the standing rejection of an `inconclusive`
|
|
248
|
+
third verdict.
|
|
249
|
+
|
|
250
|
+
#### Notes
|
|
251
|
+
|
|
252
|
+
- Hot bodies are untouched: `meterOnce`'s measurement window (between
|
|
253
|
+
`makeGcCounter()` and `gcc.close()`) and `suiteGate`'s `visit` have a
|
|
254
|
+
byte-identical diff. Every door is config-time or verdict-time.
|
|
255
|
+
- suiteGate sources that reduce to NaN now fail their budget closed
|
|
256
|
+
instead of passing silently -- a down payment on PG-06; the record
|
|
257
|
+
doors themselves are P4.
|
|
258
|
+
|
|
259
|
+
## [1.3.0] - 2026-09-13
|
|
260
|
+
|
|
261
|
+
### Changed
|
|
262
|
+
|
|
263
|
+
- **Positive control is a bounded 64-slot ring, not a grow-forever sink.**
|
|
264
|
+
The stock control stored every `{x,y,z,w}` into a module-level array to
|
|
265
|
+
defeat escape analysis -- but 100% survival trained V8's allocation-site
|
|
266
|
+
pretenuring, so the site was promoted to old space and the scavenge signal
|
|
267
|
+
died. At LIBRARY DEFAULTS in a fresh process the count went DOWN as the
|
|
268
|
+
work went UP and `runGate` refused with DETECTOR VALIDATION FAILED (PG-01).
|
|
269
|
+
The control now writes `__posRing[i & 63] = {x,y,z,w}`: the store still
|
|
270
|
+
escapes, but every object is overwritten within 64 iterations, so the
|
|
271
|
+
site's survival ratio stays ~0. Measured, fresh process, N=200000 k=8,
|
|
272
|
+
`--expose-gc --max-semi-space-size=4`:
|
|
273
|
+
|
|
274
|
+
| control | minorLo | minorHi | majorHi | retainedKB_hi |
|
|
275
|
+
| -------------------------- | ------- | ------- | ------- | ------------- |
|
|
276
|
+
| stock grow-forever sink | 2 | 1 | 1 | ~100784 |
|
|
277
|
+
| 64-slot ring (this release)| 2 | 21 | 0 | ~11 |
|
|
278
|
+
|
|
279
|
+
See `decisions/0001-positive-control.md`.
|
|
280
|
+
- **Footprint is bounded by construction.** One `measure(controlPositive)`
|
|
281
|
+
at defaults grew the post-gc heap ~113MB with the stock sink and poisoned
|
|
282
|
+
every later measurement in the same process (a subsequent negative control
|
|
283
|
+
then forced 6 scavenges against a ceiling of 2, PG-10). At most 64 objects
|
|
284
|
+
(~3KB) are retained now; post-gc growth is sub-megabyte.
|
|
285
|
+
- **`_controlKeepAlive()` returns ring occupancy (0..64), not a push count.**
|
|
286
|
+
Its purpose -- a read that touches every slot so V8 cannot sink the stores
|
|
287
|
+
-- is unchanged; it no longer returns a monotonically growing number.
|
|
288
|
+
- **Detector-validation margin is decoupled from the consumer's budget.**
|
|
289
|
+
The old predicate `pos.minorHi > maxScavenges + 3` coupled the evidence
|
|
290
|
+
the instrument owes to the budget the user set for their own code
|
|
291
|
+
(`maxScavenges: 40` silently demanded a 43-scavenge control). One shared
|
|
292
|
+
predicate `validateDetector(pos, neg, maxScav)` now governs both zgcSuite
|
|
293
|
+
and runGate, with its own constants: `CONTROL_FLOOR = 6` (positive control
|
|
294
|
+
scavenges at k*N), `CONTROL_SCALE = 2` (minorHi >= 2*minorLo when
|
|
295
|
+
minorLo > 0), `CONTROL_NEG_CEIL = 2` (negative ceiling, tightened by
|
|
296
|
+
`min(maxScavenges, 2)`). Every clause is written `!(x >= limit)` so a NaN
|
|
297
|
+
count fails closed. A consumer at the default `maxScavenges: 2` sees the
|
|
298
|
+
identical effective floor.
|
|
299
|
+
- **One detector-validation test replaces two.** zgcSuite measures positive
|
|
300
|
+
then negative in one test so the negative doubles as the in-suite
|
|
301
|
+
poisoning regression.
|
|
302
|
+
|
|
303
|
+
### Added
|
|
304
|
+
|
|
305
|
+
- **Torture suite** (`npm run torture`, `test/torture.mjs` +
|
|
306
|
+
`test/torture/`): T1 detector matrix (bare child processes at library
|
|
307
|
+
defaults -- the PG-01 reproduction inverted, ring scaling at N in
|
|
308
|
+
{50000, 200000}, and the PG-10 poisoning regression) and T5 footprint +
|
|
309
|
+
4096-cycle soak (bounded control footprint, lite-leak registration count
|
|
310
|
+
back to 0, flat heap band, `checkNoGc` pass over the profiler window).
|
|
311
|
+
T2/T3/T4 register as named skipped tiers for P2/P3/P4. Two env-gated
|
|
312
|
+
controls-for-the-controls prove the tiers can fail:
|
|
313
|
+
`TORTURE_CONTROL=stock-control` fails T1, `TORTURE_CONTROL=leaky-soak`
|
|
314
|
+
fails T5.
|
|
315
|
+
- **Two child-process self-tests** (PG-15): zgcSuite is green end-to-end at
|
|
316
|
+
library defaults, and goes red (DETECTOR VALIDATION FAILED / need >=6)
|
|
317
|
+
when the positive control is sabotaged with the stock grow-forever shape.
|
|
318
|
+
26 -> 28 self-tests.
|
|
319
|
+
- **devDependencies** `@zakkster/lite-gc-profiler ^1.16.0` and
|
|
320
|
+
`@zakkster/lite-leak ^1.10.0` -- dev-only, for the torture suite; the
|
|
321
|
+
library still ships zero runtime dependencies and `files[]` is unchanged.
|
|
322
|
+
- **`.gitignore`** for `/node_modules/`, `/package-lock.json`, `.DS_Store`
|
|
323
|
+
(the lockfile is not committed in this suite).
|
|
324
|
+
|
|
325
|
+
### Notes
|
|
326
|
+
|
|
327
|
+
- Every number above was measured on a single machine: darwin, Node
|
|
328
|
+
v26.3.1, `--expose-gc --max-semi-space-size=4`, reproduced twice. There
|
|
329
|
+
is no nvm here; the multi-Node matrix is a CI intent, not a claim this
|
|
330
|
+
release verifies. `CONTROL_FLOOR = 6` absorbs the large-semi-space
|
|
331
|
+
direction (a default 16MB young generation buys ~5-6 scavenges instead of
|
|
332
|
+
21); scavenge count is a function of bytes allocated per semi-space byte,
|
|
333
|
+
not of CPU speed.
|
|
334
|
+
- No verdict changes (P2), no new signals such as maxMajors or external
|
|
335
|
+
deltas (P3), no suiteGate work (P4), no README rewrite (P5). The
|
|
336
|
+
measurement window in `meterOnce` and suiteGate's visit are byte-identical
|
|
337
|
+
to 1.2.2.
|
|
338
|
+
- The demo `<title>` no longer embeds a version; it went stale every release.
|
|
339
|
+
|
|
3
340
|
## [1.2.2] - 2026-09-13
|
|
4
341
|
|
|
5
342
|
- **Version truth**: `VERSION` reads `1.2.2` and agrees with
|
package/PerfGate.d.ts
CHANGED
|
@@ -38,12 +38,33 @@ export interface MeasureResult {
|
|
|
38
38
|
minorHi: number;
|
|
39
39
|
majorLo: number;
|
|
40
40
|
majorHi: number;
|
|
41
|
+
/** Old-gen activity (major + incremental) at N. */
|
|
42
|
+
oldGenLo: number;
|
|
43
|
+
/** Old-gen activity (major + incremental) at k*N. GATE signal (default max 0). */
|
|
44
|
+
oldGenHi: number;
|
|
41
45
|
retainedKB_lo: number;
|
|
42
46
|
retainedKB_hi: number;
|
|
47
|
+
/** External/backing-store (memoryUsage().arrayBuffers) delta in KB at N. */
|
|
48
|
+
arrayBuffersKB_lo: number;
|
|
49
|
+
/** External delta in KB at k*N. GATE signal (default max 64). */
|
|
50
|
+
arrayBuffersKB_hi: number;
|
|
43
51
|
/** Custom counter deltas at N, or null if no statsOf. */
|
|
44
52
|
counters_lo: Record<string, number> | null;
|
|
45
53
|
/** Custom counter deltas at k*N, or null if no statsOf. */
|
|
46
54
|
counters_hi: Record<string, number> | null;
|
|
55
|
+
/** False when measured without --expose-gc (allowNoGc). Absent means reliable. */
|
|
56
|
+
retainedReliable?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MeasureOptions {
|
|
60
|
+
/** Iteration count (low). Integer >= 1. Default 200000. */
|
|
61
|
+
N?: number;
|
|
62
|
+
/** Scale factor (high = k*N). Integer >= 2. Default 8. */
|
|
63
|
+
k?: number;
|
|
64
|
+
/** Wait (ms) after the hot loop. Finite >= 0 (0 is legal and honored). Default 100. */
|
|
65
|
+
flushMs?: number;
|
|
66
|
+
/** Run without --expose-gc; retained is dropped and retainedReliable is false. */
|
|
67
|
+
allowNoGc?: boolean;
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
export interface Thresholds {
|
|
@@ -51,6 +72,16 @@ export interface Thresholds {
|
|
|
51
72
|
maxScavenges?: number;
|
|
52
73
|
/** Max retained heap growth in KB. Default 64. */
|
|
53
74
|
maxRetainedKB?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Max allowed old-gen activity (major + incremental) at k*N. Default 0.
|
|
77
|
+
* When it trips, diagnose the cause with @zakkster/lite-gc-profiler.
|
|
78
|
+
*/
|
|
79
|
+
maxOldGen?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Max allowed external/arrayBuffers growth in KB at k*N. Default 64.
|
|
82
|
+
* Ungated under allowNoGc; an explicit value with allowNoGc throws.
|
|
83
|
+
*/
|
|
84
|
+
maxArrayBuffersKB?: number;
|
|
54
85
|
/** Per-counter maximum allowed delta. */
|
|
55
86
|
counters?: Record<string, number>;
|
|
56
87
|
}
|
|
@@ -71,33 +102,55 @@ export interface GateConfig {
|
|
|
71
102
|
maxScavenges?: number;
|
|
72
103
|
/** Max retained heap growth in KB. Default 64. */
|
|
73
104
|
maxRetainedKB?: number;
|
|
105
|
+
/** Max old-gen activity (major + incremental) at k*N. Default 0. */
|
|
106
|
+
maxOldGen?: number;
|
|
107
|
+
/** Max external/arrayBuffers growth in KB at k*N. Default 64 (undefined under allowNoGc). */
|
|
108
|
+
maxArrayBuffersKB?: number;
|
|
74
109
|
/** Counter thresholds for statsOf deltas. */
|
|
75
110
|
counters?: Record<string, number>;
|
|
76
111
|
/** Override positive control. */
|
|
77
112
|
positiveControl?: Scenario;
|
|
78
113
|
/** Override negative control. */
|
|
79
114
|
negativeControl?: Scenario;
|
|
115
|
+
/** Override the large/external detector control (controlLarge). */
|
|
116
|
+
largeControl?: Scenario;
|
|
80
117
|
/** Scenarios that MUST trip the gate (injected allocation self-tests). */
|
|
81
118
|
mustFail?: Scenario[];
|
|
82
119
|
/**
|
|
83
120
|
* Wait (ms) after the hot loop before reading the GC observer buffer.
|
|
84
121
|
* Default 100 (or PERF_GATE_FLUSH_MS env var). Bump to 250-500 on
|
|
85
122
|
* noisy CI runners where event-loop stalls > 100ms may drop entries.
|
|
123
|
+
* 0 is legal and honored.
|
|
86
124
|
*/
|
|
87
125
|
flushMs?: number;
|
|
126
|
+
/**
|
|
127
|
+
* Permit an empty scenarios array: the ONLY sanctioned empty gate, a
|
|
128
|
+
* controls-only detector smoke run. Otherwise an empty/missing/non-array
|
|
129
|
+
* scenarios throws.
|
|
130
|
+
*/
|
|
131
|
+
allowEmpty?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Run without --expose-gc. The retained rule is not applied at all (the
|
|
134
|
+
* default 64KB threshold included) and combining it with an explicit
|
|
135
|
+
* maxRetainedKB throws. Scavenges and counters still gate.
|
|
136
|
+
*/
|
|
137
|
+
allowNoGc?: boolean;
|
|
88
138
|
}
|
|
89
139
|
|
|
90
140
|
export interface GateResult {
|
|
91
141
|
passed: boolean;
|
|
142
|
+
/** 0 pass, 1 scenario or must-fail failure, 2 detector validation failed. passed === (code === 0). */
|
|
143
|
+
code: 0 | 1 | 2;
|
|
92
144
|
results: MeasureResult[];
|
|
93
145
|
}
|
|
94
146
|
|
|
95
147
|
/**
|
|
96
148
|
* Measure a scenario at N and k*N iterations. Returns raw measurements.
|
|
149
|
+
* Bad options throw; missing --expose-gc throws unless allowNoGc is set.
|
|
97
150
|
*/
|
|
98
151
|
export function measure(
|
|
99
152
|
scenario: Scenario,
|
|
100
|
-
options?:
|
|
153
|
+
options?: MeasureOptions
|
|
101
154
|
): Promise<MeasureResult>;
|
|
102
155
|
|
|
103
156
|
/**
|
|
@@ -114,6 +167,13 @@ export const controlPositive: Scenario;
|
|
|
114
167
|
/** Built-in negative control (pure arithmetic, zero allocation). */
|
|
115
168
|
export const controlNegative: Scenario;
|
|
116
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Built-in large/external control: a bounded, mask-gated retained pool of 64KB
|
|
172
|
+
* ArrayBuffers that trips the arrayBuffers signal. Validates the external
|
|
173
|
+
* detector in zgcSuite/runGate (decisions/0003).
|
|
174
|
+
*/
|
|
175
|
+
export const controlLarge: Scenario;
|
|
176
|
+
|
|
117
177
|
/**
|
|
118
178
|
* Register node:test cases: detector validation, scenario gating, and
|
|
119
179
|
* must-fail self-tests. The primary public API.
|
|
@@ -123,7 +183,8 @@ export const controlNegative: Scenario;
|
|
|
123
183
|
export function zgcSuite(config: GateConfig): void;
|
|
124
184
|
|
|
125
185
|
/**
|
|
126
|
-
* Standalone human-readable gate report. Returns
|
|
186
|
+
* Standalone human-readable gate report. Returns `{ passed, code, results }`;
|
|
187
|
+
* never touches process.exitCode -- map result.code to your exit code.
|
|
127
188
|
*/
|
|
128
189
|
export function runGate(config: GateConfig): Promise<GateResult>;
|
|
129
190
|
|
|
@@ -153,6 +214,13 @@ export interface SuiteBudget {
|
|
|
153
214
|
reduce?: 'count' | 'sum' | 'max' | 'mean' | 'last';
|
|
154
215
|
/** Inclusive budget: verdict() flags value > max. */
|
|
155
216
|
max: number;
|
|
217
|
+
/**
|
|
218
|
+
* Optional presence assertion. Integer >= 0 (else RangeError at config).
|
|
219
|
+
* Fails the budget when fewer records matched than minCount, with reason
|
|
220
|
+
* '<name>: matched <count> < minCount <n>'. Omitted or 0 is inert: a
|
|
221
|
+
* zero-match budget still reduces to 0 and passes (v1.1 back-compat).
|
|
222
|
+
*/
|
|
223
|
+
minCount?: number;
|
|
156
224
|
}
|
|
157
225
|
|
|
158
226
|
export interface SuiteBudgetResult {
|
|
@@ -160,6 +228,8 @@ export interface SuiteBudgetResult {
|
|
|
160
228
|
value: number;
|
|
161
229
|
count: number;
|
|
162
230
|
max: number;
|
|
231
|
+
/** The effective minCount (0 when the budget omitted it). */
|
|
232
|
+
minCount: number;
|
|
163
233
|
pass: boolean;
|
|
164
234
|
reasons: string[];
|
|
165
235
|
}
|
|
@@ -175,7 +245,20 @@ export interface SuiteGateResult {
|
|
|
175
245
|
* Evaluate SPP stream records against numeric budgets. Pure reduction with
|
|
176
246
|
* per-budget delegation to verdict(); no measurement, no process exit
|
|
177
247
|
* codes, no record emission. Wide-record CONT payloads are not budget
|
|
178
|
-
* targets
|
|
248
|
+
* targets (base-record t/a/b slots only).
|
|
249
|
+
*
|
|
250
|
+
* The source contract (v1.4.0 record doors, decisions/0004):
|
|
251
|
+
* THROWS (data-integrity refusal): a slab record whose packed header is not
|
|
252
|
+
* a u32 (RangeError naming the record index); a non-Float64Array or
|
|
253
|
+
* cross-realm typed-array source (its forEach binds (value, index, array));
|
|
254
|
+
* a forEach source whose ANY invocation is not four numbers (checked every
|
|
255
|
+
* record, naming the invocation index and slot); slot/reduce
|
|
256
|
+
* inherited-prototype keys; a budget targeting CONT (0x0F01), op or packed
|
|
257
|
+
* form; a bad minCount.
|
|
258
|
+
* FAILS a budget (verdict): a reduced value that is not finite ->
|
|
259
|
+
* '<name>: not a number (fail closed)'; count < minCount ->
|
|
260
|
+
* '<name>: matched <count> < minCount <n>'. Budget names may be any non-empty
|
|
261
|
+
* string, including 'toString' / '__proto__' / 'constructor'.
|
|
179
262
|
*/
|
|
180
263
|
export function suiteGate(config: {
|
|
181
264
|
source: Float64Array | SppRecordSource;
|