diff --git a/CLAUDE.md b/CLAUDE.md index 24019c23c5..8b50540d57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1322 +**Current Version:** 0.5.1323 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index 170d2dc93e..08eccbd8ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1322" +version = "0.5.1323" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1322" +version = "0.5.1323" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1322" +version = "0.5.1323" [[package]] name = "perry-ui-tvos" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1322" +version = "0.5.1323" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 1696c6fba4..770bcf18eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1322" +version = "0.5.1323" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" diff --git a/benchmarks/gc_ratchet/README.md b/benchmarks/gc_ratchet/README.md index 1f9d698f34..31876ef26a 100644 --- a/benchmarks/gc_ratchet/README.md +++ b/benchmarks/gc_ratchet/README.md @@ -314,15 +314,80 @@ python3 benchmarks/gc_ratchet/gc_ratchet.py measure \ python3 benchmarks/gc_ratchet/gc_ratchet.py check --current /tmp/current.json ``` +## What `heap_used_bytes` actually contains (#7559) + +**A retention row is not evidence of a collector regression until it has been +classified.** Two properties of the measurement point make this metric move for +reasons that have nothing to do with what the collector retained: + +1. **The measurement forces the conservative native-stack scan.** Every probe + reads `process.memoryUsage()` immediately after an explicit `gc()`, and an + explicit `gc()` is the one site in Perry that forces that scan + (`ManualGcScanGuard`, #4977 — the production default is `Auto`, which skips + it). So the reading is taken under a root set nothing else in the language + uses, and it includes whatever the native stack happened to look like a heap + pointer to at that instant. +2. **`js_arena_stats` sums block *offsets*, not live bytes.** A block's + bump pointer never moves backwards, and a block holding one marked object + cannot be reset — so a single stale stack word costs a whole **1 MiB nursery + block**, an amplification of roughly 26,000x. (This is the nursery's version + of the old-generation accounting bug #7437/#7443 fixed by subtracting swept + holes.) + +Measured across the 74 commits between the 2026-08-05 pin (`5e236e6e2`) and +v0.5.1321, both endpoints built identically and both reproducing the pinned +artifact byte-for-byte on all twelve probes: + +| | probes moved | direction | +|---|---|---| +| `heap_used_bytes` (what the gate compares) | **5 of 12**, always by whole blocks | 3 down, 2 up | +| retention with the scan off (what the collector kept) | **2 of 12** | both **down** | + +`05_closure_capture`'s `+16.44%` breach is the extreme case: its precise +retention was **5,329,880 bytes at both endpoints, to the byte**, while its +false-root residue went from one 1 MiB block to two. `02_survivor_promotion`'s +`+2.77%` is the same shape one survivor block down (256 KiB), and its precise +retention *fell* by 1,600 bytes. Neither is a collector regression. + +The probe's own live set is not what is being reported either: sweeping +`05_closure_capture`'s `BATCHES` from 690 to 710 — a workload whose live set is +approximately zero at the measurement point for every value — moves +`heap_used_bytes` between 6,501,264 and 7,426,960 in a 1 MiB sawtooth, because +what is left over is the un-reset tail blocks' bump pointers. + +### `classify` — split residue from retention in one command + +```bash +PERRY_RUNTIME_DIR=$PWD/target/release PERRY_NO_AUTO_OPTIMIZE=1 \ +python3 benchmarks/gc_ratchet/gc_ratchet.py classify --perry target/release/perry +``` + +It runs every probe twice — once as the gate does, once with +`PERRY_CONSERVATIVE_STACK_SCAN=off` — and prints the split, plus the census of +which conservative-scan sites actually fired. It refuses to tabulate a probe +whose *output* changes when the scan is disabled (the scan was load-bearing for +that probe's correctness, so its precise number is not evidence), and it refuses +to report a precise reading that is not bit-identical across repeats. The +conservative reading is allowed to vary and its spread is reported instead — +that spread on `12_large_live_set` is why #7554 had to stop gating the cell. + +A row whose `excess` moved and whose `precise` did not is a false-root artifact. +A row whose `precise` moved is a real retention change and the rest of this +document applies to it. + ## When the gate goes red 1. **Read the table.** The failing rows name the probe and the metric. Retention up means something is being kept alive that used to be collected. `copied_objects` down means objects that used to be evacuated no longer are. `freed_bytes` down means the same allocation sequence reclaimed less. -2. **Reproduce locally** with the ad-hoc commands above. Retention and GC +2. **Classify a retention row before believing it** — `gc_ratchet.py classify`, + above. This step is not optional: #7559 was a `+16.44%` retention breach with + every collector counter at `+0.00%`, and the answer was that nothing was + retained that had not been retained before. +3. **Reproduce locally** with the ad-hoc commands above. Retention and GC counters do not need a quiet box — they are load-independent. -3. **Fix it, or accept it.** If the shift is intentional and reviewed, re-pin: +4. **Fix it, or accept it.** If the shift is intentional and reviewed, re-pin: ```bash PERRY_BIN=... PERRY_RUNTIME_DIR=... \ @@ -348,7 +413,7 @@ must trigger at least one minor collection or the harness refuses to pin it. | Path | Purpose | |---|---| | `probes/*.ts` | the workloads | -| `gc_ratchet.py` | measure / assemble / check / validate | +| `gc_ratchet.py` | measure / classify / assemble / check / validate | | `tolerances.json` | every band, with the variance it was derived from | | `baseline/gc-ratchet-v1.json` | the pinned artifact | | `run_gc_ratchet_baseline.sh` | quiet-host driver (`--check` / `--pin`) | diff --git a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json index 34232fe49e..013986de64 100644 --- a/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json +++ b/benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json @@ -100,7 +100,17 @@ "still measured, still compared, and still printed, it just cannot turn the", "job red. Every entry carries evidence that is checked, not merely stored --", "at least 21 runs (the same number every band above is justified by) and a", - "spread that is actually non-zero, so a cell cannot be excluded on a hunch." + "spread that is actually non-zero, so a cell cannot be excluded on a hunch.", + "", + "#7559 -- A heap_used_bytes band is NOT a statement about how much the", + "collector retained. The reading is taken after the probe's own gc(), the", + "one site that forces the conservative native-stack scan, and js_arena_stats", + "sums arena block OFFSETS, so one stale stack word pins a whole 1 MiB block.", + "Across the 74 commits from the 2026-08-05 pin to v0.5.1321 this metric moved", + "on five probes, always by whole blocks, while the same probes' scan-off", + "retention was byte-identical on ten of twelve and fell on the other two.", + "Run `gc_ratchet.py classify` before treating a breach here as a collector", + "regression; see benchmarks/gc_ratchet/README.md." ], "shared_ci": { "heap_used_bytes": { diff --git a/benchmarks/gc_ratchet/gc_ratchet.py b/benchmarks/gc_ratchet/gc_ratchet.py index 9d24228a4b..07ccc61896 100644 --- a/benchmarks/gc_ratchet/gc_ratchet.py +++ b/benchmarks/gc_ratchet/gc_ratchet.py @@ -28,6 +28,22 @@ allocation sequence and collector policy, independent of CPU speed, core count, and machine load. + ★ **Deterministic is not the same as semantic (#7559).** An explicit + ``gc()`` is the one site in Perry that *forces* the conservative + native-stack scan (``ManualGcScanGuard``, #4977; production resolves to + ``SkipDisabled``), so this reading is taken under a root set nothing else in + the language uses, and it includes whatever the native stack happened to + look like a heap pointer to. ``js_arena_stats`` then sums each block's + **bump-pointer offset**, and a block cannot be reset while it holds one + marked object — so one stale stack word costs a whole 1 MiB nursery block. + Measured across the 74 commits between the 2026-08-05 pin and v0.5.1321: + the precise (scan-off) retention was byte-identical on 10 of 12 probes and + fell on the other two, while this number moved on five, always by whole + blocks. ``05_closure_capture``'s +16.44% breach was precisely that: precise + retention 5,329,880 at *both* endpoints, false-root residue 1 -> 2 blocks. + Run ``gc_ratchet.py classify`` before treating a retention row as a + collector regression. + ``gc`` ``minor_cycles``, ``copied_objects``, ``copied_bytes``, ``promoted_objects``, ``promoted_bytes``, ``freed_bytes``, ``step_cycles`` Parsed from ``PERRY_GC_DIAG=1`` output in a separate, untimed pass. @@ -81,8 +97,15 @@ GCMETRIC_RE = re.compile(r"^#gcmetric\s+([a-z0-9_]+)=(-?[0-9]+)\s*$") COPY_MINOR_RE = re.compile(r"^\[gc-copy-minor\]\s+ran\s+(.*)$") +SCAN_FALLBACK_RE = re.compile( + r"^\[gc-scan-fallback\]\s+site=(\w+)\s+automatic=(true|false)\s+count=(\d+)\s*$" +) GC_STEP_PREFIX = "[gc-step]" +#: Runtime knob that turns the conservative native-stack scan off. See +#: ``classify`` for why this harness cares. +SCAN_MODE_ENV = "PERRY_CONSERVATIVE_STACK_SCAN" + RETENTION_METRICS = ("heap_used_bytes", "heap_total_bytes") GC_METRICS = ( "minor_cycles", @@ -302,6 +325,10 @@ def run_once( _, status, usage = os.wait4(process.pid, 0) wall_ms = (time.monotonic() - started) * 1000.0 returncode = os.waitstatus_to_exitcode(status) + # os.wait4 reaped the child behind Popen's back, so Popen still thinks + # it is running and its finalizer emits a ResourceWarning per probe run. + # Tell it the outcome instead: this is bookkeeping, not a second wait. + process.returncode = returncode out_file.seek(0) err_file.seek(0) stdout, stderr = out_file.read(), err_file.read() @@ -353,6 +380,27 @@ def parse_gc_diag(stderr: str) -> dict[str, int]: return {metric: int(counters.get(metric, 0)) for metric in GC_METRICS} +def parse_scan_fallbacks(stderr: str) -> dict[str, dict[str, Any]]: + """Which conservative native-stack scans a traced run actually performed. + + ``[gc-scan-fallback] site= automatic= count=`` is printed + once per scan, with ``count`` running per site, so the largest ``count`` for + a site is how often it fired. ``automatic`` separates the scans a program + pays for without asking (allocation-point old-gen reclaim, the nursery-churn + slack valve, emergency reclaim) from the ones user code requested by calling + ``gc()`` — which is the distinction ``classify`` exists to surface. + """ + sites: dict[str, dict[str, Any]] = {} + for line in stderr.splitlines(): + match = SCAN_FALLBACK_RE.match(line.strip()) + if not match: + continue + name, automatic, count = match.group(1), match.group(2) == "true", int(match.group(3)) + entry = sites.setdefault(name, {"automatic": automatic, "count": 0}) + entry["count"] = max(entry["count"], count) + return sites + + def distribution(values: Sequence[float]) -> dict[str, Any]: """Raw samples plus the statistics the gate reasons about. @@ -533,6 +581,165 @@ def measure( } +# --------------------------------------------------------------------------- +# Classifying a retention breach (#7559) +# --------------------------------------------------------------------------- + + +def classify( + *, perry: Path, probes_dir: Path, repeats: int = 3, warmup: int = 1 +) -> dict[str, Any]: + """Split each probe's retention into real retention and false-root residue. + + WHY THIS EXISTS + --------------- + ``heap_used_bytes`` is read from ``process.memoryUsage()`` immediately after + the probe's own explicit ``gc()``, and an explicit ``gc()`` is the one place + in Perry that *forces* the conservative native-stack scan (``#4977``'s + ``ManualGcScanGuard``; the production default is ``Auto``, which skips it). + So every probe's headline retention number is measured under a root set + nothing else in the language uses, and it includes whatever the native stack + happened to look like a heap pointer to at that instant. + + That residue is not small and it is not proportional. ``js_arena_stats`` + sums each arena block's **bump-pointer offset**, and a block cannot be reset + while it holds one marked object, so a single stale stack word costs a whole + 1 MiB nursery block. Measured on ``05_closure_capture`` (#7559): the false + residue moved 1 MiB across a 74-commit window in which the same probe's + precise retention was byte-identical — 5,329,880 at both endpoints — which + is a +16.44% "regression" with every collector counter at +0.00%. + + So: ``conservative`` is what the gate compares, ``precise`` is what the + collector actually retained, and ``excess`` is the difference. A retention + breach whose ``excess`` moved and whose ``precise`` did not is a false-root + artifact, not a collector regression. + + WHAT IT ASSERTS + --------------- + Turning the scan off must not change what a probe computes. If it does, the + scan was load-bearing for that probe's correctness and its precise number is + not evidence about anything — so a stdout difference is an error, not a row. + + The *precise* reading must additionally be bit-identical across repeats: it + is the number this tool asks the reader to believe, so a run-to-run spread + in it is an error rather than a row. The *conservative* reading is allowed + to vary and its spread is reported instead — on ``12_large_live_set`` that + spread is the whole reason #7554 had to stop gating the cell, and hiding it + behind an exception would remove the evidence. + """ + if repeats < 1: + raise RatchetError("classify needs at least one repeat per scan mode") + + sources = probe_sources(probes_dir) + rows: list[dict[str, Any]] = [] + with tempfile.TemporaryDirectory(prefix="gc-ratchet-classify-") as tmp: + out_dir = Path(tmp) + for source in sources: + name = source.stem + binary = compile_probe(perry, source, out_dir) + for _ in range(warmup): + run_once([str(binary)]) + + modes: dict[str, list[dict[str, int]]] = {} + stdouts: dict[str, str] = {} + for label, env in (("conservative", None), ("precise", {SCAN_MODE_ENV: "off"})): + seen: list[dict[str, int]] = [] + for _ in range(repeats): + run = run_once([str(binary)], extra_env=env) + if run["returncode"] != 0: + raise RatchetError( + f"{name}: probe exited {run['returncode']} with " + f"{SCAN_MODE_ENV}={'off' if env else ''}\n{run['stderr']}" + ) + emitted = parse_gcmetrics(run["stderr"]) + for metric in RETENTION_METRICS: + if emitted.get(metric, 0) <= 0: + raise RatchetError(f"{name}: probe emitted no {metric} ({label})") + seen.append({metric: emitted[metric] for metric in RETENTION_METRICS}) + stdouts.setdefault(label, run["stdout"]) + modes[label] = seen + + precise_samples = [sample["heap_used_bytes"] for sample in modes["precise"]] + if len(set(precise_samples)) != 1: + raise RatchetError( + f"{name}: precise retention is not bit-identical across {repeats} runs " + f"({precise_samples}); it is the number this tool asks the reader to " + f"believe, so it may not be reported as a spread" + ) + + if stdouts["conservative"] != stdouts["precise"]: + raise RatchetError( + f"{name}: probe output changes when the conservative stack scan is " + f"disabled, so the scan is load-bearing for this probe and its precise " + f"retention is not evidence about the collector" + ) + + sites = parse_scan_fallbacks( + run_once([str(binary)], extra_env={"PERRY_GC_DIAG": "1"})["stderr"] + ) + conservative_samples = [sample["heap_used_bytes"] for sample in modes["conservative"]] + conservative = int(statistics.median(conservative_samples)) + precise = precise_samples[0] + rows.append( + { + "probe": name, + "heap_used_bytes": conservative, + "heap_used_samples": conservative_samples, + "heap_used_spread_bytes": max(conservative_samples) - min(conservative_samples), + "heap_used_precise_bytes": precise, + "false_root_excess_bytes": conservative - precise, + "false_root_excess_pct": _clean( + 100.0 * (conservative - precise) / conservative if conservative else 0.0 + ), + "heap_total_bytes": modes["conservative"][0]["heap_total_bytes"], + "heap_total_precise_bytes": modes["precise"][0]["heap_total_bytes"], + "scan_fallback_sites": sites, + "automatic_scan_sites": sorted( + site for site, entry in sites.items() if entry["automatic"] + ), + } + ) + + return { + "schema_version": SCHEMA_VERSION, + "kind": "gc-ratchet-classification", + "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "platform": platform_key(), + "host": host_description(), + "run_config": {"repeats": repeats, "warmup": warmup, "scan_mode_env": SCAN_MODE_ENV}, + "probes": rows, + } + + +def render_classification(payload: Mapping[str, Any]) -> str: + lines = [ + "## GC ratchet retention classification", + "", + "`conservative` is what `heap_used_bytes` gates on: measured after the probe's", + f"own `gc()`, which forces the conservative native-stack scan. `precise` is the", + f"same reading with `{SCAN_MODE_ENV}=off`, i.e. the retention the", + "collector's own roots account for. `excess` is false-root residue, and it is", + "quantised to whole arena blocks because `heap_used_bytes` sums block offsets:", + "one stale stack word pins a whole 1 MiB nursery block.", + "", + "| Probe | conservative | spread | precise | excess | excess % | scan sites |", + "|-------|-------------:|-------:|--------:|-------:|---------:|------------|", + ] + for row in payload["probes"]: + sites = ", ".join( + f"{site}×{entry['count']}{'' if entry['automatic'] else ' (explicit gc())'}" + for site, entry in sorted(row["scan_fallback_sites"].items()) + ) + lines.append( + f"| `{row['probe']}` | {row['heap_used_bytes']:,} | " + f"{row['heap_used_spread_bytes']:,} | " + f"{row['heap_used_precise_bytes']:,} | {row['false_root_excess_bytes']:,} | " + f"{row['false_root_excess_pct']:.2f}% | {sites or 'none'} |" + ) + lines.append("") + return "\n".join(lines) + + # --------------------------------------------------------------------------- # Tolerances # --------------------------------------------------------------------------- @@ -1175,6 +1382,23 @@ def cmd_measure(args: argparse.Namespace) -> int: return 0 +def cmd_classify(args: argparse.Namespace) -> int: + perry = Path(args.perry).resolve() + if not perry.exists(): + raise RatchetError(f"perry binary not found at {perry}") + payload = classify( + perry=perry, + probes_dir=Path(args.probes_dir).resolve(), + repeats=args.repeats, + warmup=args.warmup, + ) + if args.output: + _write(Path(args.output), payload) + print(f"gc-ratchet: wrote classification to {args.output}") + print(render_classification(payload)) + return 0 + + def cmd_assemble(args: argparse.Namespace) -> int: artifact = assemble( measurement=_load(Path(args.measurement)), @@ -1244,6 +1468,17 @@ def build_parser() -> argparse.ArgumentParser: measure_cmd.add_argument("--output", required=True) measure_cmd.set_defaults(func=cmd_measure) + classify_cmd = sub.add_parser( + "classify", + help="split each probe's retention into real retention and false-root residue", + ) + classify_cmd.add_argument("--perry", required=True) + classify_cmd.add_argument("--probes-dir", default=str(PROBES_DIR)) + classify_cmd.add_argument("--repeats", type=int, default=3) + classify_cmd.add_argument("--warmup", type=int, default=1) + classify_cmd.add_argument("--output", default=None) + classify_cmd.set_defaults(func=cmd_classify) + assemble_cmd = sub.add_parser("assemble", help="build the pinned baseline artifact") assemble_cmd.add_argument("--measurement", required=True) assemble_cmd.add_argument("--tolerances", default=str(DEFAULT_TOLERANCES)) diff --git a/benchmarks/gc_ratchet/tolerances.json b/benchmarks/gc_ratchet/tolerances.json index 7e3da89f94..bd8301c256 100644 --- a/benchmarks/gc_ratchet/tolerances.json +++ b/benchmarks/gc_ratchet/tolerances.json @@ -26,7 +26,17 @@ "still measured, still compared, and still printed, it just cannot turn the", "job red. Every entry carries evidence that is checked, not merely stored --", "at least 21 runs (the same number every band above is justified by) and a", - "spread that is actually non-zero, so a cell cannot be excluded on a hunch." + "spread that is actually non-zero, so a cell cannot be excluded on a hunch.", + "", + "#7559 -- A heap_used_bytes band is NOT a statement about how much the", + "collector retained. The reading is taken after the probe's own gc(), the", + "one site that forces the conservative native-stack scan, and js_arena_stats", + "sums arena block OFFSETS, so one stale stack word pins a whole 1 MiB block.", + "Across the 74 commits from the 2026-08-05 pin to v0.5.1321 this metric moved", + "on five probes, always by whole blocks, while the same probes' scan-off", + "retention was byte-identical on ten of twelve and fell on the other two.", + "Run `gc_ratchet.py classify` before treating a breach here as a collector", + "regression; see benchmarks/gc_ratchet/README.md." ], "shared_ci": { diff --git a/changelog.d/7571-gc-ratchet-classify-retention.md b/changelog.d/7571-gc-ratchet-classify-retention.md new file mode 100644 index 0000000000..eb62f5e26a --- /dev/null +++ b/changelog.d/7571-gc-ratchet-classify-retention.md @@ -0,0 +1,85 @@ +### `gc-ratchet`: `05_closure_capture`'s +16.44% was false-root residue, and there is now a command that says so (#7559) + +`05_closure_capture` reported `heap_used_bytes` **+16.44% with every collector +counter at +0.00%** the moment #7557 made the ratchet measure again. Nothing was +kept alive that had not been kept alive before: measured with the conservative +native-stack scan disabled, that probe retains **5,329,880 bytes at both +endpoints, to the byte**. What moved was one falsely-retained 1 MiB nursery +block. + +**Why the measurement produces that.** Two properties of the measurement point, +neither of which has anything to do with the collector's retention: + +1. Every probe reads `process.memoryUsage()` immediately after an explicit + `gc()`, and an explicit `gc()` is the **one** site in Perry that *forces* the + conservative native-stack scan (`ManualGcScanGuard`, #4977; production + resolves to `SkipDisabled`). `PERRY_GC_DIAG` confirms the suite's entire + conservative-scan census is the probes' own `gc()` — `site=manual_collect + automatic=false` on all twelve — plus one automatic `old_reclaim_alloc_point` + on `12_large_live_set`. The gated number is therefore taken under a root set + nothing else in the language uses. +2. `js_arena_stats` sums each arena block's bump-pointer **offset**, and a block + holding one marked object cannot be reset. One stale stack word costs a whole + 1 MiB block — roughly 26,000x amplification. This is the nursery's version of + the old-generation accounting problem #7437/#7443 fixed for `OLD_ARENA` by + subtracting swept holes. + +`PERRY_GC_DIAG` counts it at the measurement collection: general blocks marked +live on `05_closure_capture` are **5 with the scan off at both endpoints**, 6 +with it on at the pinned commit, 7 at v0.5.1321. Both minors are bit-identical +across the arms (`copied_objects` 1422 + 1614 = 3036, exactly the pinned value), +and with the scan off both arms free exactly 8,124,192 bytes at the final +mark-sweep and retain exactly 425 forwarded stubs. + +**The whole window.** Across the 74 commits from the 2026-08-05 pin +(`5e236e6e2`) to v0.5.1321 (`b5a2954ec`) — both arms built +`--release -p perry -p perry-runtime-static -p perry-stdlib-static` with a cold +object cache, and the baseline arm reproducing the pinned artifact byte-for-byte +on all twelve probes — `heap_used_bytes` moved on **five** probes, always by +whole blocks, three down and two up; the same probes' scan-off retention was +byte-identical on **ten of twelve** and **fell** on the other two. +`02_survivor_promotion`'s +2.77% is the same shape one survivor block down +(262,160 B), and its precise retention fell by 1,600 bytes. + +Nor is the metric reporting the probe's live set, which is ~zero at the +measurement point for every workload size: sweeping `05_closure_capture`'s +`BATCHES` from 690 to 710 with one compiler walks it between 6,501,264 and +7,426,960 in a 1 MiB sawtooth, because what is left over is the un-reset tail +blocks' bump pointers (`arena_reset_empty_blocks` never resets the current block +or the four before it). + +**What landed.** `gc_ratchet.py classify` runs every probe under both scan modes +and prints `conservative` / `precise` / `excess` plus the census of which +conservative-scan sites fired. A row whose `excess` moved and whose `precise` +did not is a false-root artifact; a row whose `precise` moved is a real +retention change. It refuses to tabulate a probe whose *stdout* changes when the +scan is disabled (the scan was load-bearing for that probe's correctness, so its +precise number is not evidence about the collector), and refuses to report a +precise reading that is not bit-identical across repeats — while *allowing* the +conservative reading to vary and reporting its spread instead, because that +spread on `12_large_live_set` is exactly why #7554 had to stop gating the cell. +All twelve probes are verified to produce byte-identical stdout under both +modes. + +The finding is recorded in `gc_ratchet.py`'s module docstring, in +`benchmarks/gc_ratchet/README.md` (a new section on what `heap_used_bytes` +actually contains, and a mandatory classify step in "When the gate goes red"), +and in `tolerances.json`'s `_readme`. Six new tests cover it, including an +end-to-end `classify` run against a stub compiler that plays the #7559 shape and +one that fails if the runtime renames `PERRY_CONSERVATIVE_STACK_SCAN` — a rename +would otherwise make every `precise` column silently equal its `conservative` +one, i.e. a classifier that classifies nothing. + +`run_once` now hands `Popen` the exit status `os.wait4` collected behind its +back, so a harness run stops emitting a `ResourceWarning` per probe. + +**Deliberately unchanged:** no band is widened and nothing is re-pinned. +`05_closure_capture` and `02_survivor_promotion` stay red until someone decides, +which is what the ratchet is for. Widening the band is not an available answer: +`heap_used_bytes`'s churn quantum is one whole block, which is also the unit +`test_one_falsely_retained_nursery_block_fails` asserts must go red — a quantity +whose noise quantum equals its signal quantum cannot be gated, so the fix is to +measure a different quantity, not to loosen the band on this one. The artifact's +embedded tolerances copy is synced to the file (prose only; every +`pct`/`abs`/`direction`/`gating` tuple asserted identical before the rewrite) +because `evaluate` reads the artifact's copy, not the file. diff --git a/tests/test_gc_ratchet.py b/tests/test_gc_ratchet.py index 9db69aa8b9..2bb7ff119f 100644 --- a/tests/test_gc_ratchet.py +++ b/tests/test_gc_ratchet.py @@ -13,6 +13,9 @@ import copy import json +import stat +import sys +import tempfile import unittest from pathlib import Path @@ -23,14 +26,18 @@ GC_METRICS, MIN_EXCLUSION_RUNS, PROFILES, + SCAN_MODE_ENV, RatchetError, + classify, distribution, evaluate, gated_anywhere, parse_gc_diag, parse_gcmetrics, + parse_scan_fallbacks, probe_overrides_from_json, render, + render_classification, tolerances_from_json, validate_artifact, ) @@ -657,5 +664,191 @@ def test_memory_and_timing_spread_is_still_allowed(self): validate_artifact(_baseline(probes)) +# --------------------------------------------------------------------------- +# classify (#7559) +# --------------------------------------------------------------------------- + +#: A stand-in for `perry`. `compile_probe` invokes it as +#: ` -o `; it writes a Python script that plays a +#: probe: fixed stdout, `#gcmetric` lines on stderr, and a `heap_used_bytes` +#: that depends on the conservative-scan knob exactly the way a real probe's +#: does. That is what makes `classify` testable without a compiler. +_STUB_PERRY = """#!{python} +import os, stat, sys +source = sys.argv[1] +out = sys.argv[sys.argv.index("-o") + 1] +body = open(os.path.join(os.path.dirname(os.path.abspath(source)) or ".", source)).read() +with open(out, "w") as handle: + handle.write("#!{python}\\n" + body) +os.chmod(out, os.stat(out).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) +""" + +#: The stub probe. `precise` is the retention the collector's roots account +#: for; the conservative scan adds one whole 1 MiB block on top, which is the +#: #7559 shape. +_STUB_PROBE = """ +import os, sys +precise = {precise} +excess = 0 if os.environ.get("PERRY_CONSERVATIVE_STACK_SCAN") == "off" else {excess} +sys.stdout.write("probe:stub\\nchecksum:{checksum}\\n") +sys.stderr.write("#gcmetric heap_used_bytes=%d\\n" % (precise + excess)) +sys.stderr.write("#gcmetric heap_total_bytes=20971520\\n") +sys.stderr.write("#gcmetric rss_bytes=30000000\\n") +if os.environ.get("PERRY_GC_DIAG"): + sys.stderr.write("[gc-scan-fallback] site=manual_collect automatic=false count=1\\n") +""" + + +class ScanFallbackParsingTests(unittest.TestCase): + def test_parses_sites_and_keeps_the_highest_running_count(self): + stderr = "\n".join( + [ + "[gc-copy-minor] eligible=true fallback=none", + "[gc-scan-fallback] site=manual_collect automatic=false count=1", + "[gc-scan-fallback] site=manual_collect automatic=false count=2", + "[gc-scan-fallback] site=old_reclaim_alloc_point automatic=true count=1", + "not a diag line", + ] + ) + self.assertEqual( + parse_scan_fallbacks(stderr), + { + "manual_collect": {"automatic": False, "count": 2}, + "old_reclaim_alloc_point": {"automatic": True, "count": 1}, + }, + ) + + def test_a_run_with_no_conservative_scan_reports_no_sites(self): + self.assertEqual(parse_scan_fallbacks("[gc-step] pre_in_use=1 post_in_use=1"), {}) + + +class ClassifyTests(unittest.TestCase): + """`classify` splits a retention reading into real retention and residue. + + The gate compares `heap_used_bytes`, which is read after the probe's own + `gc()` — the one site in Perry that *forces* the conservative native-stack + scan. #7559 was a +16.44% breach on `05_closure_capture` whose precise + retention was byte-identical (5,329,880) at both endpoints: one extra stale + stack word, amplified to a whole 1 MiB block because `heap_used_bytes` sums + arena block offsets. These tests pin the tool that makes that difference a + one-command answer instead of two compiler builds. + """ + + def _fixture(self, tmp, *, precise=5_329_880, excess=1_048_576, checksum=1, probes=("05_stub",)): + root = Path(tmp) + perry = root / "stub-perry" + perry.write_text(_STUB_PERRY.format(python=sys.executable), encoding="utf-8") + perry.chmod(perry.stat().st_mode | stat.S_IEXEC) + probes_dir = root / "probes" + probes_dir.mkdir() + for name in probes: + (probes_dir / f"{name}.ts").write_text( + _STUB_PROBE.format(precise=precise, excess=excess, checksum=checksum), + encoding="utf-8", + ) + return perry, probes_dir + + def test_reports_the_false_root_excess_and_the_scan_site(self): + with tempfile.TemporaryDirectory() as tmp: + perry, probes_dir = self._fixture(tmp) + payload = classify(perry=perry, probes_dir=probes_dir, repeats=3, warmup=0) + (row,) = payload["probes"] + self.assertEqual(row["heap_used_bytes"], 5_329_880 + 1_048_576) + self.assertEqual(row["heap_used_precise_bytes"], 5_329_880) + self.assertEqual(row["false_root_excess_bytes"], 1_048_576) + self.assertEqual(row["scan_fallback_sites"]["manual_collect"]["automatic"], False) + self.assertEqual(row["automatic_scan_sites"], []) + + def test_a_probe_with_no_residue_reports_zero(self): + with tempfile.TemporaryDirectory() as tmp: + perry, probes_dir = self._fixture(tmp, excess=0) + payload = classify(perry=perry, probes_dir=probes_dir, repeats=3, warmup=0) + self.assertEqual(payload["probes"][0]["false_root_excess_bytes"], 0) + self.assertEqual(payload["probes"][0]["false_root_excess_pct"], 0) + + def test_a_non_deterministic_precise_reading_is_an_error(self): + # The precise number is the one this tool asks the reader to believe. + # Reporting it as a spread would make "the collector retained the same + # bytes" a claim nobody checked. + with tempfile.TemporaryDirectory() as tmp: + perry, probes_dir = self._fixture(tmp) + (probes_dir / "05_stub.ts").write_text( + "import os, random, sys\n" + 'sys.stdout.write("probe:stub\\nchecksum:1\\n")\n' + 'sys.stderr.write("#gcmetric heap_used_bytes=%d\\n" % (5000000 + random.randrange(1, 99)))\n' + 'sys.stderr.write("#gcmetric heap_total_bytes=20971520\\n")\n' + 'sys.stderr.write("#gcmetric rss_bytes=30000000\\n")\n', + encoding="utf-8", + ) + with self.assertRaises(RatchetError) as caught: + classify(perry=perry, probes_dir=probes_dir, repeats=5, warmup=0) + self.assertIn("not bit-identical", str(caught.exception)) + + def test_a_probe_whose_output_depends_on_the_scan_is_an_error(self): + # If disabling the scan changes what the probe computes, the scan was + # load-bearing for its correctness and its precise retention is not + # evidence about the collector. That must not be quietly tabulated. + with tempfile.TemporaryDirectory() as tmp: + perry, probes_dir = self._fixture(tmp) + (probes_dir / "05_stub.ts").write_text( + "import os, sys\n" + 'off = os.environ.get("PERRY_CONSERVATIVE_STACK_SCAN") == "off"\n' + 'sys.stdout.write("probe:stub\\nchecksum:%d\\n" % (0 if off else 1))\n' + 'sys.stderr.write("#gcmetric heap_used_bytes=5000000\\n")\n' + 'sys.stderr.write("#gcmetric heap_total_bytes=20971520\\n")\n' + 'sys.stderr.write("#gcmetric rss_bytes=30000000\\n")\n', + encoding="utf-8", + ) + with self.assertRaises(RatchetError) as caught: + classify(perry=perry, probes_dir=probes_dir, repeats=2, warmup=0) + self.assertIn("load-bearing", str(caught.exception)) + + def test_the_conservative_reading_may_vary_and_its_spread_is_reported(self): + # 12_large_live_set's conservative reading is genuinely unstable — that + # spread is why #7554 had to stop gating the cell. Raising on it would + # delete the evidence instead of reporting it. + with tempfile.TemporaryDirectory() as tmp: + perry, probes_dir = self._fixture(tmp) + (probes_dir / "05_stub.ts").write_text( + "import os, sys\n" + 'off = os.environ.get("PERRY_CONSERVATIVE_STACK_SCAN") == "off"\n' + "state = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'n')\n" + "n = 0\n" + "if not off:\n" + " try:\n" + " n = int(open(state).read())\n" + " except Exception:\n" + " n = 0\n" + " open(state, 'w').write(str(n + 1))\n" + 'sys.stdout.write("probe:stub\\nchecksum:1\\n")\n' + 'sys.stderr.write("#gcmetric heap_used_bytes=%d\\n" % (5000000 + n * 1000))\n' + 'sys.stderr.write("#gcmetric heap_total_bytes=20971520\\n")\n' + 'sys.stderr.write("#gcmetric rss_bytes=30000000\\n")\n', + encoding="utf-8", + ) + payload = classify(perry=perry, probes_dir=probes_dir, repeats=3, warmup=0) + row = payload["probes"][0] + self.assertEqual(row["heap_used_spread_bytes"], 2000) + self.assertEqual(row["heap_used_precise_bytes"], 5_000_000) + + def test_rendered_table_names_the_explicit_gc_site(self): + with tempfile.TemporaryDirectory() as tmp: + perry, probes_dir = self._fixture(tmp) + payload = classify(perry=perry, probes_dir=probes_dir, repeats=2, warmup=0) + report = render_classification(payload) + self.assertIn("explicit gc()", report) + self.assertIn("1,048,576", report) + + def test_scan_mode_env_is_the_documented_knob(self): + # The whole tool rests on this being the knob that disables the scan. + # A rename in the runtime must break a test, not silently make every + # `precise` column equal to its `conservative` one. + self.assertEqual(SCAN_MODE_ENV, "PERRY_CONSERVATIVE_STACK_SCAN") + runtime = ( + REPO_ROOT / "crates" / "perry-runtime" / "src" / "gc" / "roots" / "scan_mode.rs" + ).read_text(encoding="utf-8") + self.assertIn(f'std::env::var("{SCAN_MODE_ENV}")', runtime) + + if __name__ == "__main__": unittest.main()