From 8f5e046dd90aa4e92c77d5dee768d38a0d5caa09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 18:01:57 +0200 Subject: [PATCH 01/20] fix(runtime): gate feature-only product helpers Keep the no-default-features product build free of dead-code warnings while retaining every helper where its regex feature or unit tests need it. Dispositions: - newborn_parent_needs_barrier: cfg(test or regex-engine); feature + tests use it. - REGEXP_PROTOTYPE_PTR_SLOT: cfg(test or regex-engine); feature GC root backing. - REGEXP_PROTOTYPE_TEST_CLOSURE_SLOT: cfg(test or regex-engine); feature root backing. - REGEXP_PROTOTYPE_TEST_INDEX_SLOT: cfg(test or regex-engine); feature scalar backing. - REGEXP_PROTOTYPE_PTR: cfg(test or regex-engine); feature fast path/root scanner. - REGEXP_PROTOTYPE_TEST_CLOSURE: cfg(test or regex-engine); feature fast path/scanner. - REGEXP_PROTOTYPE_TEST_WALKS: cfg(test or regex-engine); feature/test diagnostic. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 31a9d087929af27b2a86de8b02fa97b6f1d33572) --- changelog.d/9949-product-warnings-dead-code.md | 3 +++ crates/perry-runtime/src/gc/barrier_store.rs | 1 + crates/perry-runtime/src/object/regex_proto_thunks.rs | 6 ++++++ 3 files changed, 10 insertions(+) create mode 100644 changelog.d/9949-product-warnings-dead-code.md diff --git a/changelog.d/9949-product-warnings-dead-code.md b/changelog.d/9949-product-warnings-dead-code.md new file mode 100644 index 0000000000..3469ac0bcd --- /dev/null +++ b/changelog.d/9949-product-warnings-dead-code.md @@ -0,0 +1,3 @@ +Fix the no-default-features product build under `-D warnings` by compiling +RegExp-only GC barrier and prototype-site state only for tests or when the +`regex-engine` feature is enabled. diff --git a/crates/perry-runtime/src/gc/barrier_store.rs b/crates/perry-runtime/src/gc/barrier_store.rs index 2b43c80240..3891f84e50 100644 --- a/crates/perry-runtime/src/gc/barrier_store.rs +++ b/crates/perry-runtime/src/gc/barrier_store.rs @@ -370,6 +370,7 @@ pub(super) fn barrier_remembering_active() -> bool { /// validated) — the same contract `emit_parent_may_need_remembering_check` /// places on its caller. #[inline] +#[cfg(any(test, feature = "regex-engine"))] pub(crate) unsafe fn newborn_parent_needs_barrier(parent_addr: usize) -> bool { if !super::barrier::incremental_mark_barrier_globally_idle() { return true; diff --git a/crates/perry-runtime/src/object/regex_proto_thunks.rs b/crates/perry-runtime/src/object/regex_proto_thunks.rs index 389499db5d..58bbc07d59 100644 --- a/crates/perry-runtime/src/object/regex_proto_thunks.rs +++ b/crates/perry-runtime/src/object/regex_proto_thunks.rs @@ -322,19 +322,24 @@ crate::perry_thread_local! { /// towers, which both marks it and rewrites it when the collector moves the /// object. A recorded address that is not scanned is a stale pointer the /// first time the prototype moves — the #9539/#9445 shape. + #[cfg(any(test, feature = "regex-engine"))] static REGEXP_PROTOTYPE_PTR_SLOT: std::sync::atomic::AtomicI64 = const { std::sync::atomic::AtomicI64::new(0) }; /// The canonical `test` closure, NaN-boxed. Also a root, visited as a /// nanbox word so the collector rewrites the pointer inside it. + #[cfg(any(test, feature = "regex-engine"))] static REGEXP_PROTOTYPE_TEST_CLOSURE_SLOT: std::sync::atomic::AtomicU64 = const { std::sync::atomic::AtomicU64::new(0) }; /// The field index its own `test` occupies. Not an address, so not a root. + #[cfg(any(test, feature = "regex-engine"))] static REGEXP_PROTOTYPE_TEST_INDEX_SLOT: std::sync::atomic::AtomicU32 = const { std::sync::atomic::AtomicU32::new(u32::MAX) }; } +#[cfg(any(test, feature = "regex-engine"))] pub(crate) static REGEXP_PROTOTYPE_PTR: super::RealmAtomicI64 = super::RealmAtomicI64::new(®EXP_PROTOTYPE_PTR_SLOT); +#[cfg(any(test, feature = "regex-engine"))] pub(crate) static REGEXP_PROTOTYPE_TEST_CLOSURE: super::RealmAtomicU64 = super::RealmAtomicU64::new(®EXP_PROTOTYPE_TEST_CLOSURE_SLOT); @@ -342,6 +347,7 @@ pub(crate) static REGEXP_PROTOTYPE_TEST_CLOSURE: super::RealmAtomicU64 = /// The fast path does none: the only walk is the one-time recording below, so /// this must read **1 per realm**, not one per call. It is the counter that /// says the fast path is actually the path being taken. +#[cfg(any(test, feature = "regex-engine"))] pub(crate) static REGEXP_PROTOTYPE_TEST_WALKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); From 8a0c2e019c7b74a6788c6787ddb0bfa0005edd15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 18:04:25 +0200 Subject: [PATCH 02/20] chore(changelog): name the fragment after its PR (#9970) Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit d5115dfc1f5c8ac976cfe4a64e1ec9c17f1c08d3) --- ...t-warnings-dead-code.md => 9970-product-warnings-dead-code.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9949-product-warnings-dead-code.md => 9970-product-warnings-dead-code.md} (100%) diff --git a/changelog.d/9949-product-warnings-dead-code.md b/changelog.d/9970-product-warnings-dead-code.md similarity index 100% rename from changelog.d/9949-product-warnings-dead-code.md rename to changelog.d/9970-product-warnings-dead-code.md From 48bb4ec08a018d4a33c9d13b24fe0837c01409fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 17:43:13 +0200 Subject: [PATCH 03/20] fix(tooling): fail loudly on empty lint extraction The extractor only admitted commands beginning with `python3 scripts/`, `./scripts/`, or `cargo fmt`. The public-baseline commands instead begin with an inline PYTHONPATH assignment and `python3 benchmarks/`, so both were filtered out; the YAML block's comments and step metadata were not the cause. Recognize every executable family currently used by lint, retain workflow step names in the derived list, and reject any run step that produces no commands. Keep the two GitHub-context commands on a stale-checked explicit skip list, and self-test both comment-led extraction and the loud failure. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 75885c059957527f4f756b9a1422a56ab84d82b9) --- changelog.d/9949-lint-gates-extractor-loud.md | 1 + scripts/run_lint_gates.sh | 213 +++++++++++++++--- 2 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 changelog.d/9949-lint-gates-extractor-loud.md diff --git a/changelog.d/9949-lint-gates-extractor-loud.md b/changelog.d/9949-lint-gates-extractor-loud.md new file mode 100644 index 0000000000..22d7a636eb --- /dev/null +++ b/changelog.d/9949-lint-gates-extractor-loud.md @@ -0,0 +1 @@ +`scripts/run_lint_gates.sh` now names every derived command by workflow step and refuses to run or list a lint job when any `run:` step yields no command. Its extractor accepts the public-baseline step's environment-prefixed test and benchmark checker, keeps the two GitHub-only commands on an explicit printed skip list, and includes an end-to-end self-test for comment-led blocks and loud zero-command failures. diff --git a/scripts/run_lint_gates.sh b/scripts/run_lint_gates.sh index 6770060836..5cdafd76dc 100755 --- a/scripts/run_lint_gates.sh +++ b/scripts/run_lint_gates.sh @@ -3,7 +3,7 @@ # # WHY THIS EXISTS # -# `lint` invokes ~48 separate gate commands. Reviewers (human and agent) reach +# `lint` invokes dozens of separate gate commands. Reviewers (human and agent) reach # for the handful that look topically relevant to the diff in front of them and # merge on that, which is how five separate gates went red on `main` in a single # day (2026-08-17): `gc_runtime_root_holders` after #8270, `-D warnings` after @@ -15,7 +15,8 @@ # copied, so it cannot drift from what CI actually does. If the workflow gains a # gate, this picks it up on the next run. # -# TWO TIERS. The script tier is the `lint` job's ~48 script/fmt commands. The +# TWO TIERS. The script tier mirrors the `lint` job's locally executable +# `run:` commands. The # COMPILE tier mirrors the separate `warnings` and `check` jobs -- `cargo check # --workspace --all-targets` under `-D warnings`, and `cargo clippy --workspace` # -- both over the same host-compatible package scope CI uses, derived from @@ -33,6 +34,7 @@ # Usage: # scripts/run_lint_gates.sh # every gate; non-zero if any fails # scripts/run_lint_gates.sh --list # print what would run, run nothing +# scripts/run_lint_gates.sh --self-test # prove extraction succeeds and fails loudly # BASE_SHA=origin/main scripts/run_lint_gates.sh # # Not a substitute for `cargo test` — this is the lint tier only. @@ -45,13 +47,45 @@ cd "$ROOT" : "${BASE_SHA:=origin/main}" export BASE_SHA +if [[ "${1:-}" == "--self-test" ]]; then + if ! _self_ok="$(RUN_LINT_GATES_FIXTURE=comment-led bash "$0" --list 2>&1)"; then + echo "run_lint_gates self-test FAILED: comment-led run block was rejected" >&2 + printf '%s\n' "$_self_ok" >&2 + exit 1 + fi + for _expected in \ + "PYTHONPATH=. python3 tests/test_public_baseline.py" \ + "python3 benchmarks/ci_public_baseline_check.py"; do + if [[ "$_self_ok" != *"$_expected"* ]]; then + echo "run_lint_gates self-test FAILED: comment-led run block lost: $_expected" >&2 + exit 1 + fi + done + + if _self_bad="$(RUN_LINT_GATES_FIXTURE=empty bash "$0" --list 2>&1)"; then + echo "run_lint_gates self-test FAILED: empty run step exited zero" >&2 + exit 1 + fi + if [[ "$_self_bad" != *"Synthetic empty run step"* ]]; then + echo "run_lint_gates self-test FAILED: empty-step error omitted its step name" >&2 + printf '%s\n' "$_self_bad" >&2 + exit 1 + fi + + echo "run_lint_gates self-test: OK (comment-led commands derived; empty step rejected by name)" + exit 0 +fi + # bash 3.2 (macOS) has no `mapfile`; read the list portably. CMDS=() -while IFS= read -r _line; do - [ -n "$_line" ] && CMDS+=("$_line") -done < <(python3 - <<'PY' +CMD_STEPS=() +SKIP_REASONS=() +RUN_STEPS=0 +if ! _extracted="$(python3 - <<'PY' import re +import shlex import sys +import os try: import yaml @@ -59,13 +93,80 @@ except ImportError: # pragma: no cover - keeps the script usable without pyyaml sys.stderr.write("run_lint_gates: pyyaml is required to read the workflow\n") sys.exit(3) -workflow = yaml.safe_load(open(".github/workflows/test.yml")) +fixture = os.environ.get("RUN_LINT_GATES_FIXTURE") +if fixture == "comment-led": + workflow = {"jobs": {"lint": {"steps": [{ + "name": "Synthetic comment-led run step", + "run": """# Leading comments must not hide the commands below. +PYTHONPATH=. python3 tests/test_public_baseline.py +# Another comment between commands. +python3 benchmarks/ci_public_baseline_check.py +""", + }]}}} +elif fixture == "empty": + workflow = {"jobs": {"lint": {"steps": [{ + "name": "Synthetic empty run step", + "run": "# A run block with no derivable command must be fatal.\n", + }]}}} +elif fixture: + sys.stderr.write(f"run_lint_gates: unknown self-test fixture: {fixture}\n") + sys.exit(3) +else: + with open(".github/workflows/test.yml", encoding="utf-8") as workflow_file: + workflow = yaml.safe_load(workflow_file) + steps = workflow["jobs"]["lint"].get("steps") or [] -seen = set() -for step in steps: + +# These are the only lint commands that require values supplied by GitHub. +# Keep the step name and command signature explicit: a new expression cannot +# silently become a third skip, and a stale skip entry fails extraction. +ci_only = { + "changeset": { + "step": "Require a changelog.d/ fragment for crates/ changes", + "needles": ("check_changeset_fragment.sh", "github.repository", "github.event.pull_request.number"), + "reason": "needs GitHub API repository/PR context", + }, + "shard-count": { + "step": "CI plan policy self-test + docs table freshness", + "needles": ("ci_cargo_test_shard.py", "--validate", "needs.plan.outputs.plan"), + "reason": "needs the CI plan's shard count", + }, +} +matched_skips = set() +records = [] +errors = [] +run_steps = 0 +command_names = {"python3", "cargo", "rustup", "node", "bash"} + +def is_gate_command(line): + """Recognize a top-level executable line, allowing leading env assignments.""" + if not line or line.startswith("#") or "<<" in line: + return False + # Scratch-file producers are inputs to a later assertion, not standalone + # gates. Preserve the existing exception for self-tests/checks. + if ">" in line and "--self-test" not in line and "--check" not in line: + return False + try: + words = shlex.split(line) + except ValueError: + return False + while words and re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*=.*", words[0]): + words.pop(0) + if not words: + return False + executable = words[0] + return ( + executable in command_names + or executable.startswith("./scripts/") + or executable.startswith("./tests/") + ) + +for index, step in enumerate(steps, start=1): run = step.get("run") - if not run: + if run is None: continue + run_steps += 1 + step_name = step.get("name") or f"" # Join backslash continuations FIRST. Without this a multi-line gate is # extracted as its first line only and then RUN that way -- truncated, with # a trailing backslash -- which is #8929: the ci_cargo_test_shard.py step @@ -73,21 +174,13 @@ for step in steps: # "git cat-file ... || git fetch ..." prelude is a SEPARATE logical command # from the "python3 scripts/..." gate on the line below it. joined = re.sub(r"\\\n[ \t]*", " ", run) + step_records = [] for line in joined.split("\n"): line = line.strip() - if not re.match(r"^(python3 scripts/|\./scripts/|cargo fmt)", line): + if not is_gate_command(line): continue - # Steps that only redirect into a scratch file prove nothing locally. - if ">" in line and "--self-test" not in line and "--check" not in line: - continue - if line in seen: - continue - seen.add(line) # A GitHub Actions expression is substituted in CI and never locally, - # so running such a gate here passes an empty value and fails for - # reasons that say nothing about the tree. Report it as SKIPPED and - # name it -- a gate silently dropped from the list would be worse than - # one that is permanently red. + # so only the two commands named above may be skipped locally. # # Built by concatenation on purpose, and NOT written literally: this # heredoc sits inside a process substitution, and bash 3.2 (macOS) @@ -96,42 +189,94 @@ for step in steps: # with "unexpected EOF while looking for matching quote". gha_expr = "$" + "{" + "{" if gha_expr in line: - print("#skip# " + line) + matches = [ + key for key, rule in ci_only.items() + if step_name == rule["step"] and all(needle in line for needle in rule["needles"]) + ] + if len(matches) != 1: + errors.append(f"step '{step_name}' has an unapproved CI-only command: {line}") + continue + key = matches[0] + matched_skips.add(key) + step_records.append(("skip", step_name, line, ci_only[key]["reason"])) else: - print(line) + step_records.append(("run", step_name, line, "")) + + if not step_records: + errors.append(f"step '{step_name}' has a run: block but yielded zero commands") + records.extend(step_records) + +if not fixture: + for key, rule in ci_only.items(): + if key not in matched_skips: + errors.append(f"explicit CI-only skip '{rule['step']}' no longer matches the workflow") + +if errors: + for error in errors: + sys.stderr.write(f"run_lint_gates: extraction error: {error}\n") + sys.exit(4) + +print(f"meta\t{run_steps}\t\t") +for kind, step_name, command, reason in records: + print("\t".join((kind, step_name, command, reason))) PY -) +)"; then + exit 4 +fi + +while IFS=$'\t' read -r _kind _step _command _reason; do + case "$_kind" in + meta) + RUN_STEPS="$_step" + ;; + run) + CMDS+=("$_command") + CMD_STEPS+=("$_step") + SKIP_REASONS+=("") + ;; + skip) + CMDS+=("#skip# $_command") + CMD_STEPS+=("$_step") + SKIP_REASONS+=("$_reason") + ;; + esac +done <<< "$_extracted" if [[ "${1:-}" == "--list" ]]; then - for _c in "${CMDS[@]}"; do + for _i in "${!CMDS[@]}"; do + _c="${CMDS[$_i]}" + _step="${CMD_STEPS[$_i]}" if [[ "$_c" == "#skip# "* ]]; then - printf 'SKIP (CI-only expression, not run locally): %s\n' "${_c#\#skip\# }" + printf '[%s]\n SKIP (CI-only: %s): %s\n' \ + "$_step" "${SKIP_REASONS[$_i]}" "${_c#\#skip\# }" else - printf '%s\n' "$_c" + printf '[%s]\n %s\n' "$_step" "$_c" fi done - echo "(${#CMDS[@]} gate commands, derived from .github/workflows/test.yml)" + echo "(${#CMDS[@]} gate commands from ${RUN_STEPS} run steps, derived from .github/workflows/test.yml)" exit 0 fi -echo "run_lint_gates: ${#CMDS[@]} gate commands derived from the lint job" +echo "run_lint_gates: ${#CMDS[@]} gate commands derived from ${RUN_STEPS} lint run steps" echo failed=() skipped=0 -for cmd in "${CMDS[@]}"; do +for _i in "${!CMDS[@]}"; do + cmd="${CMDS[$_i]}" + step="${CMD_STEPS[$_i]}" if [[ "$cmd" == "#skip# "* ]]; then skipped=$((skipped + 1)) - printf ' skip %s\n' "${cmd#\#skip\# }" - printf ' (carries a CI-only expression; substituted by GitHub Actions, not here)\n' + printf ' skip [%s] %s\n' "$step" "${cmd#\#skip\# }" + printf ' (%s)\n' "${SKIP_REASONS[$_i]}" continue fi if out="$(eval "$cmd" 2>&1)"; then - printf ' ok %s\n' "$cmd" + printf ' ok [%s] %s\n' "$step" "$cmd" else - printf ' FAIL %s\n' "$cmd" + printf ' FAIL [%s] %s\n' "$step" "$cmd" printf '%s\n' "$out" | tail -6 | sed 's/^/ /' - failed+=("$cmd") + failed+=("[$step] $cmd") fi done From 398b2fef7566198ef63f24a75601d60c9d1ed708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 17:46:04 +0200 Subject: [PATCH 04/20] chore(changelog): name the lint-gates fragment after its PR (#9969) Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 942e5f484289b0a61f4d4a5f42bc4eadf2396590) --- ...-gates-extractor-loud.md => 9969-lint-gates-extractor-loud.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/{9949-lint-gates-extractor-loud.md => 9969-lint-gates-extractor-loud.md} (100%) diff --git a/changelog.d/9949-lint-gates-extractor-loud.md b/changelog.d/9969-lint-gates-extractor-loud.md similarity index 100% rename from changelog.d/9949-lint-gates-extractor-loud.md rename to changelog.d/9969-lint-gates-extractor-loud.md From 06744cf78e4c19c1f7fe69df6c34a8aa216ab8be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 18:05:16 +0200 Subject: [PATCH 05/20] fix(tooling): replay CI compile gates in lint driver Derive the warnings and check job gates from test.yml, including the product-only warnings check, both host-compatible workspace scopes, and the API-docs regeneration and drift assertion. Expand the workflow's package exclusions portably for macOS Bash 3.2. Extend the extractor self-test to require the product check under -D warnings, reject its removal, and prove newly added warnings commands are replayed. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 3e6aeef5423b9bc582761e86edeeb6771cf7be5b) --- scripts/run_lint_gates.sh | 248 ++++++++++++++++++++++++++++++++------ 1 file changed, 211 insertions(+), 37 deletions(-) diff --git a/scripts/run_lint_gates.sh b/scripts/run_lint_gates.sh index 5cdafd76dc..cda802593f 100755 --- a/scripts/run_lint_gates.sh +++ b/scripts/run_lint_gates.sh @@ -16,11 +16,10 @@ # gate, this picks it up on the next run. # # TWO TIERS. The script tier mirrors the `lint` job's locally executable -# `run:` commands. The -# COMPILE tier mirrors the separate `warnings` and `check` jobs -- `cargo check -# --workspace --all-targets` under `-D warnings`, and `cargo clippy --workspace` -# -- both over the same host-compatible package scope CI uses, derived from -# scripts/workspace_architecture.py rather than copied. +# `run:` commands. The COMPILE tier derives every gate command from the +# separate `warnings` and `check` jobs: product and host-compatible check / +# clippy scopes, followed by the API-docs regeneration and drift assertion. +# Host exclusions come from scripts/workspace_architecture.py, as they do in CI. # # The compile tier exists because deriving only from `lint` is not the same as # "what CI runs": on 2026-08-18 #8333 left a test helper unused, `main` went red @@ -72,7 +71,39 @@ if [[ "${1:-}" == "--self-test" ]]; then exit 1 fi - echo "run_lint_gates self-test: OK (comment-led commands derived; empty step rejected by name)" + if ! _self_compile="$(bash "$0" --list 2>&1)"; then + echo "run_lint_gates self-test FAILED: real workflow extraction failed" >&2 + printf '%s\n' "$_self_compile" >&2 + exit 1 + fi + _product_warnings='RUSTFLAGS="-D warnings" cargo check -p perry --bins' + if [[ "$_self_compile" != *"$_product_warnings"* ]]; then + echo "run_lint_gates self-test FAILED: compile tier lost the warnings product check" >&2 + exit 1 + fi + + if _self_missing="$(RUN_LINT_GATES_FIXTURE=warnings-missing-product bash "$0" --list 2>&1)"; then + echo "run_lint_gates self-test FAILED: missing warnings product check exited zero" >&2 + exit 1 + fi + if [[ "$_self_missing" != *"warnings product command"* ]]; then + echo "run_lint_gates self-test FAILED: missing-product error omitted its subject" >&2 + printf '%s\n' "$_self_missing" >&2 + exit 1 + fi + + if ! _self_extra="$(RUN_LINT_GATES_FIXTURE=warnings-extra-command bash "$0" --list 2>&1)"; then + echo "run_lint_gates self-test FAILED: extra warnings command was rejected" >&2 + printf '%s\n' "$_self_extra" >&2 + exit 1 + fi + _extra_warnings='RUSTFLAGS="-D warnings" cargo check -p perry-runtime --lib' + if [[ "$_self_extra" != *"$_extra_warnings"* ]]; then + echo "run_lint_gates self-test FAILED: compile tier omitted an added warnings command" >&2 + exit 1 + fi + + echo "run_lint_gates self-test: OK (lint + compile commands derived; empty/missing steps rejected; added warnings command replayed)" exit 0 fi @@ -81,6 +112,9 @@ CMDS=() CMD_STEPS=() SKIP_REASONS=() RUN_STEPS=0 +COMPILE_CMDS=() +COMPILE_STEPS=() +COMPILE_HOST_SCOPE=() if ! _extracted="$(python3 - <<'PY' import re import shlex @@ -93,27 +127,37 @@ except ImportError: # pragma: no cover - keeps the script usable without pyyaml sys.stderr.write("run_lint_gates: pyyaml is required to read the workflow\n") sys.exit(3) +with open(".github/workflows/test.yml", encoding="utf-8") as workflow_file: + workflow = yaml.safe_load(workflow_file) + fixture = os.environ.get("RUN_LINT_GATES_FIXTURE") if fixture == "comment-led": - workflow = {"jobs": {"lint": {"steps": [{ + workflow["jobs"]["lint"]["steps"] = [{ "name": "Synthetic comment-led run step", "run": """# Leading comments must not hide the commands below. PYTHONPATH=. python3 tests/test_public_baseline.py # Another comment between commands. python3 benchmarks/ci_public_baseline_check.py """, - }]}}} + }] elif fixture == "empty": - workflow = {"jobs": {"lint": {"steps": [{ + workflow["jobs"]["lint"]["steps"] = [{ "name": "Synthetic empty run step", "run": "# A run block with no derivable command must be fatal.\n", - }]}}} + }] +elif fixture == "warnings-missing-product": + workflow["jobs"]["warnings"]["steps"] = [ + step for step in workflow["jobs"]["warnings"]["steps"] + if step.get("name") != "rustc warnings (product)" + ] +elif fixture == "warnings-extra-command": + for step in workflow["jobs"]["warnings"]["steps"]: + if step.get("name") == "rustc warnings (host-compatible, all targets)": + step["run"] += "\ncargo check -p perry-runtime --lib\n" + break elif fixture: sys.stderr.write(f"run_lint_gates: unknown self-test fixture: {fixture}\n") sys.exit(3) -else: - with open(".github/workflows/test.yml", encoding="utf-8") as workflow_file: - workflow = yaml.safe_load(workflow_file) steps = workflow["jobs"]["lint"].get("steps") or [] @@ -211,6 +255,109 @@ if not fixture: if key not in matched_skips: errors.append(f"explicit CI-only skip '{rule['step']}' no longer matches the workflow") +compile_records = [] +for job_name in ("warnings", "check"): + job = workflow.get("jobs", {}).get(job_name) + if not job: + errors.append(f"compile job '{job_name}' is missing from the workflow") + continue + + rustflags = "" + if job_name == "warnings": + rustflags = str((job.get("env") or {}).get("RUSTFLAGS") or "") + if not rustflags: + errors.append("compile job 'warnings' has no RUSTFLAGS value") + + job_record_count = 0 + for index, step in enumerate(job.get("steps") or [], start=1): + run = step.get("run") + if run is None: + continue + step_name = step.get("name") or f"" + joined = re.sub(r"\\\n[ \t]*", " ", run) + + # Toolchain installation is job setup, not one of the gates the local + # compile tier replays. Keep this skip exact and loud if the step grows. + if step_name == "Install Rust toolchain": + setup_lines = [ + line.strip() for line in joined.split("\n") + if line.strip() and not line.strip().startswith("#") + ] + if len(setup_lines) != 1 or not setup_lines[0].startswith("rustup toolchain install "): + errors.append( + f"compile setup step '{job_name} / {step_name}' no longer contains only rustup install" + ) + continue + + step_commands = [] + if "--print-excluded-scope host-compatible" in joined: + # CI constructs an argv array because its runner has Bash 5. The + # local driver supports macOS Bash 3.2, so derive the same argv and + # append the same architecture-produced exclusions portably. + assignments = re.findall(r"(?:^|\n)\s*cargo_args=\(([^)]*)\)", joined) + invocations = re.findall( + r"(?:^|\n)\s*cargo\s+(check|clippy)\s+\"\$\{cargo_args\[@\]\}\"\s*(?=\n|$)", + joined, + ) + architecture_calls = joined.count("--print-excluded-scope host-compatible") + if len(assignments) != 1 or len(invocations) != 1 or architecture_calls != 1: + errors.append( + f"compile step '{job_name} / {step_name}' has an unrecognized host-scope command shape" + ) + else: + try: + cargo_args = shlex.split(assignments[0]) + except ValueError as error: + errors.append( + f"compile step '{job_name} / {step_name}' has invalid cargo_args: {error}" + ) + else: + step_commands.append(( + "cargo " + invocations[0] + " " + shlex.join(cargo_args), + "1", + )) + dynamic_invocation = re.compile( + r"cargo\s+(?:check|clippy)\s+\"\$\{cargo_args\[@\]\}\"" + ) + for raw_line in joined.split("\n"): + line = raw_line.strip() + if dynamic_invocation.fullmatch(line): + continue + if is_gate_command(line): + step_commands.append((line, "0")) + else: + for raw_line in joined.split("\n"): + line = raw_line.strip() + drift = re.fullmatch(r"if ! (git diff --quiet -- .+); then", line) + if drift: + step_commands.append((drift.group(1), "0")) + continue + if is_gate_command(line): + step_commands.append((line, "0")) + + if not step_commands: + errors.append( + f"compile step '{job_name} / {step_name}' yielded zero commands" + ) + continue + + for command, host_scope in step_commands: + if rustflags: + escaped_flags = rustflags.replace("\\", "\\\\").replace('"', '\\"') + command = f'RUSTFLAGS="{escaped_flags}" {command}' + compile_records.append((job_name, step_name, command, host_scope)) + job_record_count += 1 + + if job_record_count == 0: + errors.append(f"compile job '{job_name}' yielded zero commands") + +warnings_product = 'RUSTFLAGS="-D warnings" cargo check -p perry --bins' +if not any(job == "warnings" and command == warnings_product + for job, _step, command, _host_scope in compile_records): + errors.append( + "warnings product command is missing: " + warnings_product + ) + if errors: for error in errors: sys.stderr.write(f"run_lint_gates: extraction error: {error}\n") @@ -219,6 +366,8 @@ if errors: print(f"meta\t{run_steps}\t\t") for kind, step_name, command, reason in records: print("\t".join((kind, step_name, command, reason))) +for job_name, step_name, command, host_scope in compile_records: + print("\t".join(("compile", f"{job_name}: {step_name}", command, host_scope))) PY )"; then exit 4 @@ -239,9 +388,40 @@ while IFS=$'\t' read -r _kind _step _command _reason; do CMD_STEPS+=("$_step") SKIP_REASONS+=("$_reason") ;; + compile) + COMPILE_CMDS+=("$_command") + COMPILE_STEPS+=("$_step") + COMPILE_HOST_SCOPE+=("$_reason") + ;; esac done <<< "$_extracted" +EXCLUDES=() +if [[ "${1:-}" == "--list" || "${SKIP_COMPILE_GATES:-0}" != "1" ]]; then + if ! _excluded_packages="$(python3 scripts/workspace_architecture.py \ + --print-excluded-scope host-compatible)"; then + echo "run_lint_gates: failed to derive host-compatible exclusions" >&2 + exit 4 + fi + while IFS= read -r _pkg; do + [ -n "$_pkg" ] && EXCLUDES+=(--exclude "$_pkg") + done <<< "$_excluded_packages" +fi + +compile_command() { + local _command="$1" + local _host_scope="$2" + local _arg + local _quoted + if [[ "$_host_scope" == "1" ]]; then + for _arg in "${EXCLUDES[@]}"; do + printf -v _quoted '%q' "$_arg" + _command="$_command $_quoted" + done + fi + printf '%s' "$_command" +} + if [[ "${1:-}" == "--list" ]]; then for _i in "${!CMDS[@]}"; do _c="${CMDS[$_i]}" @@ -253,7 +433,11 @@ if [[ "${1:-}" == "--list" ]]; then printf '[%s]\n %s\n' "$_step" "$_c" fi done - echo "(${#CMDS[@]} gate commands from ${RUN_STEPS} run steps, derived from .github/workflows/test.yml)" + for _i in "${!COMPILE_CMDS[@]}"; do + _c="$(compile_command "${COMPILE_CMDS[$_i]}" "${COMPILE_HOST_SCOPE[$_i]}")" + printf '[compile / %s]\n %s\n' "${COMPILE_STEPS[$_i]}" "$_c" + done + echo "(${#CMDS[@]} lint commands from ${RUN_STEPS} run steps + ${#COMPILE_CMDS[@]} compile commands, derived from .github/workflows/test.yml)" exit 0 fi @@ -288,33 +472,23 @@ if [[ "${SKIP_COMPILE_GATES:-0}" == "1" ]]; then echo " skip compile tier (SKIP_COMPILE_GATES=1)" else compile_ran=1 - EXCLUDES=() - while IFS= read -r _pkg; do - [ -n "$_pkg" ] && EXCLUDES+=(--exclude "$_pkg") - done < <(python3 scripts/workspace_architecture.py --print-excluded-scope host-compatible) - echo - echo "run_lint_gates: compile tier (${#EXCLUDES[@]} exclude args from workspace_architecture.py)" - - if out="$(RUSTFLAGS='-D warnings' cargo check --workspace --all-targets "${EXCLUDES[@]}" 2>&1)"; then - printf ' ok warnings: cargo check --workspace --all-targets (-D warnings)\n' - else - printf ' FAIL warnings: cargo check --workspace --all-targets (-D warnings)\n' - printf '%s\n' "$out" | grep -E '^(error|warning)' | head -6 | sed 's/^/ /' - failed+=("warnings: cargo check --workspace --all-targets") - fi - - if out="$(cargo clippy --workspace "${EXCLUDES[@]}" 2>&1)"; then - printf ' ok check: cargo clippy --workspace\n' - else - printf ' FAIL check: cargo clippy --workspace\n' - printf '%s\n' "$out" | grep -E '^(error|warning)' | head -6 | sed 's/^/ /' - failed+=("check: cargo clippy --workspace") - fi + echo "run_lint_gates: ${#COMPILE_CMDS[@]} compile commands derived from warnings + check (${#EXCLUDES[@]} exclude args)" + for _i in "${!COMPILE_CMDS[@]}"; do + cmd="$(compile_command "${COMPILE_CMDS[$_i]}" "${COMPILE_HOST_SCOPE[$_i]}")" + step="${COMPILE_STEPS[$_i]}" + if out="$(eval "$cmd" 2>&1)"; then + printf ' ok [%s] %s\n' "$step" "$cmd" + else + printf ' FAIL [%s] %s\n' "$step" "$cmd" + printf '%s\n' "$out" | tail -6 | sed 's/^/ /' + failed+=("[$step] $cmd") + fi + done fi total=$(( ${#CMDS[@]} - skipped )) -((compile_ran)) && total=$((total + 2)) +((compile_ran)) && total=$((total + ${#COMPILE_CMDS[@]})) suffix="" ((compile_ran)) || suffix=" (compile tier SKIPPED)" ((skipped)) && suffix="${suffix}; ${skipped} CI-only skipped" From 2f6435a87bed97e3fccf8a9c191fc145c420de31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 21:58:45 +0200 Subject: [PATCH 06/20] test(gc): retire obsolete 7254 smoke pin Replace arm 5b's expected verifier panic with positive correctness and liveness checks for the retained-growth workload. Compare Perry's stdout byte-for-byte with Node, require a successful exit, and require copied objects so the evacuation verifier cannot pass vacuously. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 83b90a01046fd7322792ce72af8131ce175dfeb7) --- changelog.d/4644-retained-growth-verifier.md | 2 +- scripts/gc_instrument_smoke.sh | 89 +++++++++----------- 2 files changed, 43 insertions(+), 48 deletions(-) diff --git a/changelog.d/4644-retained-growth-verifier.md b/changelog.d/4644-retained-growth-verifier.md index f67793ceb8..3cd28277b4 100644 --- a/changelog.d/4644-retained-growth-verifier.md +++ b/changelog.d/4644-retained-growth-verifier.md @@ -1 +1 @@ -- Fix a false evacuation-verifier abort when a copying minor encounters a retained, non-moving array-growth alias, such as Solid's effect dependency array. Verification still follows the full forwarding chain and rejects nursery evacuation originals; old-page evacuation retains its strict checks. +- Fix a false evacuation-verifier abort when a copying minor encounters a retained, non-moving array-growth alias, such as Solid's effect dependency array. Verification still follows the full forwarding chain and rejects nursery evacuation originals; old-page evacuation retains its strict checks. The GC instrument smoke now checks #7254's retained-growth workload positively: it must match Node, exit successfully, and copy objects under the rate-1 plus evacuation-verifier pairing. diff --git a/scripts/gc_instrument_smoke.sh b/scripts/gc_instrument_smoke.sh index ae51ecfba0..7659eb7679 100755 --- a/scripts/gc_instrument_smoke.sh +++ b/scripts/gc_instrument_smoke.sh @@ -258,28 +258,11 @@ fi # ---- arm 5: PERRY_GC_SCHEDULE_RATE=1 + PERRY_GC_VERIFY_EVACUATION, #7254's pairing ---- # -# Both knobs are individually exercised above (the rate-1 schedule by arms 2/3, -# PERRY_GC_VERIFY_EVACUATION nowhere in this script) and in -# gc_repsel_matrix.sh (VERIFY_EVACUATION by `verify_evac`/`force_verify`, -# the schedule nowhere in that script either) -- but no CI arm anywhere sets them -# TOGETHER, which is exactly the CLAUDE.md knob-kill-policy hole #7254 found: -# the pair panics 10/10 on `test_gap_repsel_p4a3_ptr_numarray` -# (`gc evacuation verification failed: stale forwarded pointer in ...`) and -# nothing in CI would have said a word. -# -# Deliberately NOT routed through gc_repsel_matrix.sh: a `rate1_verify` arm -# registered there joins EVERY corpus file via `--arms all`, and #7254's own -# sizing sweep (59 files) found a striking concentration of multi-minute-plus -# runs under this exact pairing on the test_gap_gc_* reproducer corpus -- -# RATE=1 forces a full evacuating minor at EVERY back-edge poll, which no other -# matrix arm does, so a corpus built for arms that collect only when a real -# trigger fires is not this pairing's natural home. That population is not -# yet triaged (host contention during the investigation made timeout vs. -# genuine-cost vs. host-noise undecidable) and is out of scope for this fix; -# see #7254 for the follow-up. This arm stays small and bounded instead: the -# same tiny fixture arms 1-3 already use (proves the pairing is non-vacuous -# and produces no false positive on known-good code), plus ONE pinned -# regression witness against the exact file and exact panic #7254 reports. +# The pairing stays bounded here because RATE=1 forces a full evacuating minor +# at every back-edge poll, while registering it in gc_repsel_matrix.sh would run +# that expensive combination over the entire representation corpus. Arm 5a +# proves the paired instruments are live on the small fixture; arm 5b adds a +# correctness oracle for the retained-growth workload that motivated the arm. echo echo "== arm 5: PERRY_GC_SCHEDULE_RATE=1 + PERRY_GC_VERIFY_EVACUATION (#7254's pairing) ==" @@ -311,43 +294,55 @@ if [[ "$fixture_copied" -eq 0 ]]; then fi echo " correct output, exit 0, $fixture_copied objects copied under the verifier (live, no false positive)" -echo "-- 5b: the pairing must still catch #7254's known reproducer --" +echo "-- 5b: #7254's retained-growth workload must be correct under the pairing, and LIVE --" REPRO="$(dirname "$0")/../test-files/test_gap_repsel_p4a3_ptr_numarray.ts" if [[ ! -f "$REPRO" ]]; then echo "FAIL: #7254's reproducer is missing at $REPRO -- arm 5b has no subject." >&2 exit 1 fi PERRY_GC_MOVING_LOOP_POLLS=1 "$PERRY_BIN" compile "$REPRO" -o "$WORK/repro7254" >/dev/null +# #7254 pinned this retained Ptr growth workload as an expected +# evacuation-verifier abort. #4644 admitted retained growth-array aliases in +# copying-minor verification, so this arm now requires Node-correct output and +# live relocation under the pairing. set +e -repro_out="$(env "${rate1_verify_env[@]}" "$WORK/repro7254" 2>&1)" +node --experimental-strip-types "$REPRO" > "$WORK/repro7254.node.out" 2> "$WORK/repro7254.node.err" +node_rc=$? +env "${rate1_verify_env[@]}" "$WORK/repro7254" > "$WORK/repro7254.perry.out" 2> "$WORK/repro7254.perry.err" repro_rc=$? set -e -# PINNED REGRESSION, not a correctness assertion: #7254 is a real, open, -# pre-existing defect (confirmed 3/3 in this investigation, and previously -# 10/10). Asserting it panics -- rather than skipping it -- is what makes -# this arm a GATE instead of documentation: if this ever stops panicking, it -# means either the bug got fixed (delete this block and add the file to a -# normal correctness arm) or the failure mode silently changed shape (which -# needs a look before anyone trusts that as a fix). Either way the gate -# should say something, not stay quiet. -if [[ $repro_rc -eq 0 ]]; then - echo "FAIL: #7254's reproducer no longer panics under the pairing (exit 0)." >&2 - echo " If this is because the underlying stale-forwarded-pointer bug" >&2 - echo " was fixed: great -- delete this pinned-regression block (arm" >&2 - echo " 5b) and let the file run under the matrix's ordinary arms" >&2 - echo " instead. If nothing GC-related changed, this is itself a" >&2 - echo " regression report: something now hides the defect without" >&2 - echo " fixing it (e.g. the verifier stopped seeing the stale slot)." >&2 +if [[ $node_rc -ne 0 ]]; then + echo "FAIL [arm5b]: Node's oracle could not run #7254's workload, exited $node_rc:" >&2 + tail -20 "$WORK/repro7254.node.err" >&2 + exit 1 +fi +if [[ $repro_rc -ne 0 ]]; then + echo "FAIL [arm5b]: #7254's workload failed under the pairing, exited $repro_rc:" >&2 + tail -20 "$WORK/repro7254.perry.err" >&2 exit 1 fi -if ! grep -q 'stale forwarded pointer' <<<"$repro_out"; then - echo "FAIL: #7254's reproducer failed a NEW way under the pairing (exit $repro_rc):" >&2 - echo "$repro_out" | tail -20 >&2 - echo " Expected the pinned 'stale forwarded pointer' verifier panic." >&2 - echo " A different failure mode needs its own triage, not silence." >&2 +if ! cmp -s "$WORK/repro7254.node.out" "$WORK/repro7254.perry.out"; then + echo "FAIL [arm5b]: #7254's workload differs from Node under the pairing:" >&2 + diff -u "$WORK/repro7254.node.out" "$WORK/repro7254.perry.out" >&2 || true + exit 1 +fi +repro_copied="$(awk ' + { + for (i = 1; i <= NF; i++) { + if ($i ~ /^copied_objects=[0-9]+$/) { + split($i, kv, "=") + copied += kv[2] + } + } + } + END { print copied + 0 } +' "$WORK/repro7254.perry.err")" +if [[ "$repro_copied" -eq 0 ]]; then + echo "FAIL [arm5b]: #7254's workload copied ZERO objects under the pairing." >&2 + echo " Correct output without relocation proves nothing about the verifier." >&2 exit 1 fi -echo " reproduced as pinned (exit $repro_rc, stale forwarded pointer) -- #7254 still open, tracked not silent" +echo " correct output vs node, exit 0, $repro_copied objects copied under the verifier (live retained-growth coverage)" # ---- arm 6: the schedule must TERMINATE at the shipped default (#7728) ------ # @@ -482,7 +477,7 @@ echo "PASS: instruments inert when off (0 retirements), live when on" echo " (pressure-only=$pressure_retired, rate-1=$rate1_retired retirements), program correct in all arms." echo " Quarantine clean over $probe_count real probes (allocation-point route)." echo " RATE=1+VERIFY_EVACUATION pairing live and correct on known-good code," -echo " and still pins #7254's open reproducer rather than staying silent about it." +echo " and validates #7254's retained-growth workload against Node with relocation live." echo " The schedule terminates at the shipped default on a realistic poll count" echo " (${scale_elapsed}s of a ${SCHED_BUDGET_S}s budget) while still forcing" echo " $scale_forced collections that moved $scale_moved objects." From 871499b4846e4c56ecc4a807013d582859cba8ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 19:46:20 +0200 Subject: [PATCH 07/20] fix(cache): key segment-view lowering env Include PERRY_SEGVIEW in the build-cache environment fingerprint so switching the lowering cannot reuse a binary built under the other mode. Record PERRY_SEGVIEW_DIAG as diagnostics-only, and cover the real cache miss with a qualifying Intl.Segmenter compile. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 9929ed08f1cdba34abee7b2371e7de7f2ef35aad) --- changelog.d/9971-segview-build-cache-input.md | 6 +++ .../perry/src/commands/compile/build_cache.rs | 19 +++------ crates/perry/tests/native_link_cache.rs | 41 +++++++++++++++++++ 3 files changed, 53 insertions(+), 13 deletions(-) create mode 100644 changelog.d/9971-segview-build-cache-input.md diff --git a/changelog.d/9971-segview-build-cache-input.md b/changelog.d/9971-segview-build-cache-input.md new file mode 100644 index 0000000000..409d2acf24 --- /dev/null +++ b/changelog.d/9971-segview-build-cache-input.md @@ -0,0 +1,6 @@ +### Fixed + +- Register the segment-view lowering switch as a build-cache input so changing + `PERRY_SEGVIEW` cannot reuse a binary emitted under the opposite setting. + `PERRY_SEGVIEW_DIAG` remains diagnostic-only and is explicitly excluded from + the cache key. diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index 43d9730a4f..8693159d55 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -177,6 +177,9 @@ const BUILD_CACHE_ENV_VARS: &[&str] = &[ "PERRY_PTR_NUMARRAY_LOCALS", "PERRY_PTR_SHAPE_LOCALS", "PERRY_PTR_SHAPE_THIS", + // #9893: selects the segment-view lowering, which rewrites qualifying + // for-of loops to call the `js_segments_view_*` runtime entry points. + "PERRY_SEGVIEW", "PERRY_SPECIALIZED_ABI", "PERRY_SPECIALIZED_ABI_MAX", "PERRY_SPEC_PRESERVE_NONE", @@ -232,6 +235,9 @@ const BUILD_CACHE_ENV_EXCLUSIONS: &[&str] = &[ "PERRY_PACKED_LOOP_TRACE", // Entry outlining report output is observational only. "PERRY_OUTLINE_ENTRY_REPORT", + // Segment-view diagnostics only scan the final HIR and print counters; + // their checks inside the rewrite guard `eprintln!` calls only. + "PERRY_SEGVIEW_DIAG", // Only read on an already-fatal dialect-construction failure (a unit that // never parses); it writes a diagnostic IR dump to `/.ll` for // triage and cannot affect the bytes of any build that actually succeeds. @@ -866,19 +872,6 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if std::env::var("PERRY_SEGVIEW_DIAG").is_ok() { return Err("segview-diag".to_string()); } - // #9843: `PERRY_SEGVIEW` is NOT a diagnostic — it changes the emitted - // code. It is not part of the build-cache fingerprint or any object-cache - // key, so without this a cached build can hand back a binary compiled with - // the OTHER setting: compile a file with the tier on, compile it again - // with the tier off, and the second can be served from the first. The - // A/B rig's whole shape is "one compiler binary, two compiles of one - // source differing only in this variable", which is exactly the collision. - // Excluded rather than keyed because the tier is experimental and default - // OFF; a cache key is the right fix when it ships on, and then a stale - // entry cannot silently become the measurement. - if std::env::var("PERRY_SEGVIEW").is_ok() { - return Err("segview-lowering".to_string()); - } if args.verify_native_regions || args.emit_attest || args.emit_sandbox { return Err("sidecar-or-verify".to_string()); } diff --git a/crates/perry/tests/native_link_cache.rs b/crates/perry/tests/native_link_cache.rs index b4a58cef23..a6afb06064 100644 --- a/crates/perry/tests/native_link_cache.rs +++ b/crates/perry/tests/native_link_cache.rs @@ -168,3 +168,44 @@ fn native_compile_skips_link_on_identical_second_build() { assert_codegen_cache(&missing_output, 2, 0, 2, 0, 0); assert!(output.exists()); } + +#[test] +fn segment_view_switch_misses_build_and_object_caches() { + let dir = tempfile::tempdir().expect("tempdir"); + let project = dir.path(); + let output = project.join("app"); + let entry = project.join("main.ts"); + fs::write( + project.join("package.json"), + "{\"name\":\"segview-cache-test\"}\n", + ) + .unwrap(); + fs::write( + &entry, + r#"const segmenter = new Intl.Segmenter("en"); +for (const { segment } of segmenter.segment("ab")) { + console.log(segment); +} +"#, + ) + .unwrap(); + + let disabled = compile_json_with_env(project, &entry, &output, &[("PERRY_SEGVIEW", "0")]); + assert_linked(&disabled); + assert_build_cache_miss(&disabled, "manifest-missing"); + assert_codegen_cache(&disabled, 0, 1, 0, 1, 0); + assert_eq!(run_binary(&output), "a\nb\n"); + + // This must reach codegen and produce a distinct object. Sabotage: + // removing PERRY_SEGVIEW from BUILD_CACHE_ENV_VARS makes it an erroneous + // whole-build cache hit, so the assertions below fail before codegen. + let enabled = compile_json_with_env(project, &entry, &output, &[("PERRY_SEGVIEW", "1")]); + assert_linked(&enabled); + assert_build_cache_miss(&enabled, "env"); + assert_codegen_cache(&enabled, 0, 1, 0, 1, 0); + assert_eq!(run_binary(&output), "a\nb\n"); + + let enabled_warm = compile_json_with_env(project, &entry, &output, &[("PERRY_SEGVIEW", "1")]); + assert_skipped(&enabled_warm); + assert_build_cache_hit(&enabled_warm); +} From 1c0046eb144701477cdc7dafdfc1277ab35c4425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 10:10:16 +0200 Subject: [PATCH 08/20] fix(gc): the tenuring occupancy rule may not claim promote-on-first-copy (#9851) The adaptive tenuring loop takes its one and only survivor-round mortality sample on the FIRST minor of the process -- when the cohort really is immortal (99.1 % survival) -- drops the threshold to 1, and thereby destroys its ability to ever sample again: n=1 across 352 minors. In steady state an aging round filters 26.1 % of each cohort, and the loop cannot see it. `retune_after_scavenge` picks the threshold from `S = 1 + desired / influx`, the largest S whose projected survivor occupancy `(S-1) x influx` fits the desired survivor size. With integer division, any influx above `desired` yields exactly 1 -- there is no rung at 2 or 3. On the compiled claude-code TUI the first drop reads `eden_live_bytes=12075344` against `desired=1048576`. S=1 does not reduce the surviving data; it relocates it, from the survivor space -- where the next minor re-examines it for free -- to the old generation, which only a full can reclaim. The occupancy formula has no term for that. And S=1 is self-sealing: nothing is copied, so `copied_bytes` is 0, so next cycle `prev_copied` is 0, so the survival-rate lock's guard (`prev_copied >= substantial`) is false forever. Both remaining exits -- the occupancy recompute and PROMOTE_LOCK's unlock -- are QUIET-INFLUX exits, which say nothing about lifetime. The loop concludes "long-lived" from a premise about space and then removes its ability to check. Measured, 4 streamed turns in one process, both arms from one binary via the diagnostic knob PERRY_GC_TENURING_SURVIVALS, 3300-character replies: adaptive pinned S=2 minors at S=1 351 of 352 (100 % of promotion) 0 threshold transitions 1 7 mortality samples 1 393 median mortality 0.9 % 26.1 % ...steady turns 2 / 3 / 4 not measurable 26.1 / 26.1 / 26.1 % substantial cohorts < 10 % 1/1 5/358 promoted 1057 MB 792 MB The occupancy rule now stops at the lowest threshold that still PRODUCES that measurement. 2 is forced by the requirement rather than tuned: at S=1 nothing enters the survivor space, at S=2 exactly one cohort does. The clamp is at the USE SITE, not inside `compute_target_survivals`: that pure function has a second caller, `full_seed_promotes_on_first_copy`, which gates the sweep seed on `... != 1`. Clamping the shared function would silently disarm the sweep seed, which is one of the two paths that IS allowed to reach 1. Reaching 1 still belongs to the survival-rate lock and the sweep seed, which measure mortality; both are untouched, so the rule is self-limiting -- on a workload whose cohort genuinely does not die the lock fires after one cohort's copy and takes the loop back to 1. On claude-code it correctly does not: 5 of 358 substantial cohorts sit under the lock's 90 % bar, so the clamp holds rather than oscillating. Tests. `target_formula_matches_projected_occupancy` is byte-identical -- the arithmetic is untouched, and that test is the proof. Four tests move an expected value 1 -> 2 and keep their names, structure and invariants: `drops_immediately_and_rises_debounced` (asymmetric response: immediate drop, debounced rise -- 4 -> 2 shows it as well as 4 -> 1), `steady_heavy_influx_is_a_fixed_point` (fixed-pointness, now at 2), `heavy_influx_lowers_threshold_and_promotes_next_cycle` (its promotion half is untouched: the cohort was copied once, so `next_age` is 2 on cycle 2 and it still tenures exactly when the test says) and `quiet_cycles_restore_power_on_threshold_debounced` (the debounced restore is asserted structurally and survives). Two new tests: the two-phase attributed pair -- occupancy alone holds at the floor and has not taken the lock's route, then a substantial fully-surviving cohort still reaches 1 through the lock -- and a dying-cohort test at claude-code's measured 74 % survival. (cherry picked from commit 925ceb266a70c3f59de4b4f1d1d8b2ed5e5af992) --- ...occupancy-may-not-promote-on-first-copy.md | 60 +++++++ crates/perry-runtime/src/gc/tenuring.rs | 170 +++++++++++++++++- .../src/gc/tests/copying/adaptive_tenuring.rs | 20 ++- 3 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 changelog.d/9851-occupancy-may-not-promote-on-first-copy.md diff --git a/changelog.d/9851-occupancy-may-not-promote-on-first-copy.md b/changelog.d/9851-occupancy-may-not-promote-on-first-copy.md new file mode 100644 index 0000000000..0dbe039495 --- /dev/null +++ b/changelog.d/9851-occupancy-may-not-promote-on-first-copy.md @@ -0,0 +1,60 @@ +### Fixed + +- **The adaptive tenuring loop's occupancy rule can no longer conclude + "promote on first copy" — a claim about lifetime that it has no evidence + for, and which destroys the evidence that would refute it.** + + `retune_after_scavenge` picks a survival threshold from + `S = 1 + desired / influx`: the largest S whose projected survivor occupancy + `(S-1) x influx` fits the desired survivor size. With integer division, any + influx above `desired` yields exactly **1** — there is no rung at 2 or 3. + Measured on the compiled claude-code TUI, the first drop reads + `eden_live_bytes=12075344` against `desired=1048576`. + + S=1 does not reduce the surviving data; it relocates it, from the survivor + space — where the next minor re-examines it for free — to the old generation, + which only a full collection can reclaim. The occupancy formula has no term + for that. And S=1 is **self-sealing**: nothing is copied, so `copied_bytes` is + 0, so next cycle `prev_copied` is 0, so the survival-rate lock's guard + (`prev_copied >= substantial`) is false forever. Both remaining exits — the + occupancy recompute and `PROMOTE_LOCK`'s unlock — are *quiet-influx* exits, + which say nothing about lifetime. + + Measured, 4 streamed turns in one process, both arms from one binary via the + diagnostic knob `PERRY_GC_TENURING_SURVIVALS`, 3300-character replies: + + | | adaptive | pinned S=2 | + |---|---|---| + | minors at S=1 | **351 of 352**, carrying 100 % of promotion | 0 | + | threshold transitions in the whole run | **1** | 7 | + | survivor-round mortality samples | **1** | **393** | + | median mortality | **0.9 %** | **26.1 %** | + | ...in steady turns 2 / 3 / 4 | not measurable | 26.1 / 26.1 / 26.1 % | + | promoted | 1057 MB | 792 MB | + + The loop takes its one and only mortality measurement on the **first minor of + the process** — before any steady state, when the cohort really is immortal — + reads 99.1 % survival, drops to 1, and can never sample again. In steady + state an aging round filters about **a quarter** of each cohort. + + The occupancy rule now stops at the lowest threshold that still *produces* + that measurement. That value is 2 by construction, not by tuning: at S=1 + nothing enters the survivor space, at S=2 exactly one cohort does. The + arithmetic is untouched — `compute_target_survivals` still computes 1, and + its test asserts so byte-identically; only what the loop may do with the + result changes. + + **Reaching 1 still belongs to the two paths that measure mortality** — the + survival-rate lock (a substantial cohort of which >= 90 % came back alive) + and the sweep seed (the mark-sweep's own Eden live/dead split). Both are + untouched, so the rule is self-limiting: on a workload whose cohort genuinely + does not die, the lock fires after one cohort's copy and takes the loop back + to 1. On claude-code it correctly does not — 5 of 358 substantial cohorts sit + under the lock's threshold — so the clamp holds rather than oscillating. + + Two existing tests change their expected value from 1 to 2 and keep their + names, structure and invariants: `drops_immediately_and_rises_debounced` + protects the *asymmetric response* (immediate drop, debounced rise), which + 4 -> 2 demonstrates exactly as well as 4 -> 1; and + `steady_heavy_influx_is_a_fixed_point` protects *fixed-pointness*, which is + unchanged with 2 as the fixed point. diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 1abb9dd99c..b8998490da 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -141,6 +141,52 @@ use super::*; /// Ceiling and power-on value: the previous fixed threshold. pub(super) const GC_TENURING_SURVIVALS_MAX: u8 = GC_COPY_PROMOTION_SURVIVALS; +/// The lowest threshold the **occupancy rule** may select. +/// +/// Not a tuned number: it is the lowest S at which `copied_bytes > 0`, i.e. the +/// lowest value that still PRODUCES the survivor-round measurement. At S=1 +/// nothing enters the survivor space; at S=2 exactly one cohort does. +/// +/// Why the occupancy rule must not reach 1 (#9851). A threshold of 1 is a claim +/// about **lifetime** — "this cohort will not die, promote it on first copy" — +/// and the occupancy rule measures **space**: `(S-1) * influx <= desired` asks +/// only whether one cohort fits in the desired survivor size. When it does not, +/// S=1 does not reduce the surviving data; it relocates it, from the survivor +/// space (where the next minor re-examines it for free) to the old generation +/// (which only a full can reclaim). The formula has no term for that. +/// +/// Worse, S=1 is **self-sealing**: with nothing copied, `copied_bytes` is 0, so +/// next cycle `prev_copied` is 0, so the survival-rate lock's guard +/// (`prev_copied >= substantial`) is false forever. The state destroys the only +/// measurement that could refute it, and both remaining exits — the occupancy +/// recompute and `PROMOTE_LOCK`'s unlock — are *quiet-influx* exits, which say +/// nothing about lifetime. +/// +/// Measured on the compiled claude-code TUI, 4 streamed turns in one process, +/// both arms from one binary via `PERRY_GC_TENURING_SURVIVALS` (3300-char): +/// +/// | | adaptive | pinned S=2 | +/// |---|---|---| +/// | minors at S=1 | 351 of 352, carrying 100 % of promotion | 0 | +/// | survivor-round mortality samples | **1** | **393** | +/// | median mortality | **0.9 %** — the first minor of the process | **26.1 %** | +/// | ...in steady turns 2 / 3 / 4 | not measurable | 26.1 / 26.1 / 26.1 % | +/// | promoted | 1057 MB | 792 MB | +/// +/// The loop takes its one and only mortality sample on the first minor of the +/// process — before any steady state, when the cohort really is immortal — +/// concludes "nothing dies", and can never sample again. In steady state an +/// aging round filters about **a quarter** of the cohort. +/// +/// Reaching 1 still belongs to the two paths that actually MEASURE mortality: +/// the survival-rate lock (`prev_copied` substantial and >=90 % of it came back +/// alive) and the sweep seed (the mark-sweep's own Eden live/dead split). Both +/// are untouched. So the rule is self-limiting: on a workload whose cohort +/// genuinely does not die, the lock fires after one cohort's copy and takes the +/// loop back to 1 — measured 5 of 358 substantial cohorts under that threshold +/// on cc, which is why the clamp sticks there rather than oscillating. +pub(super) const OCCUPANCY_MIN_SURVIVALS: u8 = 2; + /// Consecutive cycles the computed target must exceed the current threshold /// before it is raised (by one step). const RAISE_DEBOUNCE_CYCLES: u8 = 2; @@ -519,7 +565,14 @@ pub(super) fn retune_after_scavenge( return; } - let target = compute_target_survivals(eden_live_bytes, desired); + // #9851: the occupancy rule measures SPACE and may not conclude 1, which is + // a claim about LIFETIME — see `OCCUPANCY_MIN_SURVIVALS`. Deliberately + // clamped HERE and not inside `compute_target_survivals`: that pure function + // has a second caller, `full_seed_promotes_on_first_copy`, which gates the + // sweep seed on `... != 1` ("would occupancy alone already promote on first + // copy?"). Clamping the shared function would silently disarm the sweep + // seed, which is one of the two paths that IS allowed to reach 1. + let target = compute_target_survivals(eden_live_bytes, desired).max(OCCUPANCY_MIN_SURVIVALS); let next = if target < current { RAISE_STREAK.with(|s| s.set(0)); target @@ -880,20 +933,23 @@ mod tests { let desired = desired_survivor_bytes(); assert_eq!(tenuring_survivals(), 4); - // Heavy influx: instant drop to 1. + // Heavy influx: instant drop, no debounce. #9851 changed the FLOOR this + // lands on (2, not 1 — the occupancy rule may not claim a lifetime), not + // the asymmetry this test is named for: 4 -> 2 in one cycle is the same + // "drops immediately" property that 4 -> 1 was. retune_after_scavenge(desired * 2, 0, 0); - assert_eq!(tenuring_survivals(), 1); + assert_eq!(tenuring_survivals(), OCCUPANCY_MIN_SURVIVALS); // One quiet cycle: no rise yet (debounce). retune_after_scavenge(0, 0, 0); - assert_eq!(tenuring_survivals(), 1); + assert_eq!(tenuring_survivals(), OCCUPANCY_MIN_SURVIVALS); // Second quiet cycle: rise by exactly one step, not to the target. retune_after_scavenge(0, 0, 0); - assert_eq!(tenuring_survivals(), 2); + assert_eq!(tenuring_survivals(), 3); // Heavy again: streak resets and threshold drops straight back. retune_after_scavenge(desired * 2, 0, 0); - assert_eq!(tenuring_survivals(), 1); + assert_eq!(tenuring_survivals(), OCCUPANCY_MIN_SURVIVALS); // Sustained quiet recovers to the ceiling two cycles per step. for _ in 0..6 { @@ -911,10 +967,13 @@ mod tests { // every cycle even while the cap scale walks up underneath it. An // influx only marginally above the base desired is a different case: // the growing cap re-classifies it as moderate, which is correct. + // #9851: the fixed point is now the occupancy floor (2) rather than 1. + // Fixed-POINTNESS is what this test protects — no oscillation while the + // cap scale walks up underneath — and that is unchanged. let heavy = gc_scavenge_nursery_cap_bytes(); for _ in 0..10 { retune_after_scavenge(heavy, 0, 0); - assert_eq!(tenuring_survivals(), 1); + assert_eq!(tenuring_survivals(), OCCUPANCY_MIN_SURVIVALS); } assert_eq!( scavenge_nursery_cap_effective_bytes(), @@ -924,6 +983,103 @@ mod tests { reset_for_test(); } + /// #9851, both halves of the rule in one test, in the #7909 two-phase shape + /// so the decline is ATTRIBUTED rather than merely absent. + /// + /// Phase 1 — the occupancy rule alone, on an influx far above `desired`, + /// must stop at 2 and NOT claim promote-on-first-copy. That is the whole + /// change: 2 is the lowest threshold that still puts a cohort through the + /// survivor space, so the loop keeps producing the measurement that could + /// refute it. + /// + /// Phase 2 — the same heap, once a substantial cohort HAS come back fully + /// alive, must still reach 1 through the survival-rate lock. The rule + /// removes an unmeasured conclusion, not the measured one, and this half is + /// what makes it self-limiting rather than a blanket floor. + /// + /// Sabotage: drop the `.max(OCCUPANCY_MIN_SURVIVALS)` in + /// `retune_after_scavenge` and phase 1 fails (the loop reports 1 with no + /// evidence). Drop the lock instead and phase 2 fails. + #[test] + fn occupancy_alone_never_claims_promote_on_first_copy_but_the_lock_still_can() { + reset_for_test(); + let d = desired_survivor_bytes(); + + // Phase 1: influx 16x the desired survivor size — the occupancy formula + // computes 1 (integer division: 1 + desired/influx). No cohort has been + // rated yet, so there is NO lifetime evidence on this heap. + assert_eq!( + compute_target_survivals(16 * d, d), + 1, + "precondition: the occupancy ARITHMETIC still computes 1 — this \ + change clamps what the loop may do with it, not the formula" + ); + for _ in 0..5 { + retune_after_scavenge(16 * d, 0, 0); + assert_eq!( + tenuring_survivals(), + OCCUPANCY_MIN_SURVIVALS, + "occupancy measures SPACE and must not conclude promote-on-first-copy" + ); + } + assert!( + !PROMOTE_LOCK.with(Cell::get), + "and it must not have taken the lock's route to get there" + ); + + // Phase 2: now a substantial cohort goes through the survivor space and + // comes back fully alive. THAT is lifetime evidence, and it must still + // reach 1. + // + // TWO cycles, deliberately: the lock rates `survivor_live_bytes` against + // the PREVIOUS cycle's `copied_bytes` (`PREV_COPIED_BYTES`), so the + // first call is what puts a cohort in the survivor space and the second + // is what reports it coming back alive. Phase 1 above copied nothing, so + // there is nothing to rate until this pair runs — which is precisely the + // blindness the change is about, here as a test mechanic. + retune_after_scavenge(16 * d, 3 * d, 0); + assert_eq!( + tenuring_survivals(), + OCCUPANCY_MIN_SURVIVALS, + "one cohort into the survivor space is not yet evidence about it" + ); + retune_after_scavenge(16 * d, 3 * d, 3 * d); + assert_eq!( + tenuring_survivals(), + 1, + "a substantial intake that fully survives its round must still lock \ + promote-on-first-copy — the measured path is untouched" + ); + assert!(PROMOTE_LOCK.with(Cell::get), "...through the lock"); + reset_for_test(); + } + + /// #9851: a cohort that DIES in its survivor round must keep the loop at the + /// occupancy floor rather than being locked to 1 — the case cc actually is. + /// Measured there: 26.1 % of each cohort dies in one survivor round, in + /// steady state, on 393 samples; the lock needs >=90 % survival, so it + /// correctly stays out and the clamp holds instead of oscillating. + #[test] + fn a_cohort_that_dies_in_its_round_holds_at_the_occupancy_floor() { + reset_for_test(); + let d = desired_survivor_bytes(); + // Heavy influx (occupancy says 1) AND a substantial cohort of which + // ~26 % dies — cc's steady state, in miniature. + for _ in 0..8 { + retune_after_scavenge(16 * d, 4 * d, 3 * d); + } + assert!( + !PROMOTE_LOCK.with(Cell::get), + "74 % survival is below the lock's 90 % bar: the lock must stay out" + ); + assert_eq!( + tenuring_survivals(), + OCCUPANCY_MIN_SURVIVALS, + "so the loop holds at the occupancy floor and keeps aging the cohort" + ); + reset_for_test(); + } + #[test] fn survival_rate_lock_breaks_a_saturated_pipeline() { reset_for_test(); diff --git a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs index 9abfa5a6e2..193cdafba9 100644 --- a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs +++ b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs @@ -41,8 +41,10 @@ fn heavy_influx_lowers_threshold_and_promotes_next_cycle() { let _ = gc_collect_minor(); assert_eq!( crate::gc::tenuring::tenuring_survivals(), - 1, - "a >desired Eden survivor influx must drop the threshold to 1" + crate::gc::tenuring::OCCUPANCY_MIN_SURVIVALS, + "a >desired Eden survivor influx must drop the threshold to the \ + occupancy floor (#9851: the occupancy rule measures space and may not \ + claim promote-on-first-copy, which is a claim about lifetime)" ); let after_first = (js_shadow_slot_get(0) & POINTER_MASK) as usize; assert!( @@ -51,7 +53,11 @@ fn heavy_influx_lowers_threshold_and_promotes_next_cycle() { ); // Cycle 2 promotes the whole cohort instead of re-copying it: this is - // the ping-pong the adaptive threshold exists to break. + // the ping-pong the adaptive threshold exists to break. #9851 did NOT + // weaken this half — the cohort was copied once in cycle 1, so its + // `next_age` here is 2, which still satisfies `next_age >= 2`. The test's + // named invariant ("lowers threshold AND promotes next cycle") is intact; + // only the literal threshold moved. let _ = gc_collect_minor(); for slot in 0..SLOTS { let addr = (js_shadow_slot_get(slot) & POINTER_MASK) as usize; @@ -215,7 +221,13 @@ fn quiet_cycles_restore_power_on_threshold_debounced() { fill_slots_with_heavy_influx(); let _ = gc_collect_minor(); - assert_eq!(crate::gc::tenuring::tenuring_survivals(), 1); + // #9851: the occupancy floor, not 1. What this test protects — a DEBOUNCED + // restore, at most one step per cycle, ending at the power-on threshold — + // is asserted structurally below and is unchanged. + assert_eq!( + crate::gc::tenuring::tenuring_survivals(), + crate::gc::tenuring::OCCUPANCY_MIN_SURVIVALS + ); // Promote the cohort out of the nursery so later cycles are quiet. let _ = gc_collect_minor(); From d28cba1800de4c2b723385ddea1f0898076b0250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 11:53:51 +0200 Subject: [PATCH 09/20] fix(gc): the survival-rate lock rates one fresh cohort, not the whole survivor space Follow-up to the previous commit, and caused by it. #9851's clamp stops the occupancy rule concluding "promote on first copy", and measuring the relinked candidate showed it buys -7 % of promotion where the pinned control buys -26 %: 85 % of promotion still happens at S=1, now reached through the survival-rate lock 8-12 times per four-turn run. That is a consequence of the clamp, not a coincidence. At S=1 nothing is copied, so `prev_copied` is 0 and the lock's guard can never be satisfied -- the previous commit's own argument. Removing the seal hands the lock its guard back, and the lock then reaches 1 by itself. The lock tested prev_copied >= substantial && survivor_live_bytes * 10 >= prev_copied * 9 where `survivor_live_bytes` is every live byte leaving the from-survivor space this cycle, of any age, and `prev_copied` is the previous cycle's whole intake. Those two scopes MATCH: the survivor spaces are a strict semispace pair (to-space reset before the minor, everything copied into it, then flip), so the from-space at cycle N holds exactly what cycle N-1 copied. The ratio is well-formed and cannot exceed 1. The defect is not the arithmetic. The defect is which POPULATION the ratio rates, and that is chosen by the very threshold the lock sets. At S <= 2 the space holds one fresh cohort (age-2 is promoted) and the ratio is one aging round's survival -- 74 % on the compiled claude-code TUI, under the 90 % bar. At S = 3-4 it also holds age-2 and age-3 objects, which have already survived a round and are therefore selected for longevity, so the aggregate clears 90 % while a fresh cohort does not. The rule reads its own setting back as evidence. The clamp is what lets the debounced rise reach 3 and 4, which is why this only became visible once the seal was gone. The copier now accounts the fresh half of each cycle. `eden_copied_bytes` is what this cycle copied out of EDEN into the to-survivor space (no re-copies) -- one cohort's intake. `survivor_first_round_live_bytes` is what came back out of the from-survivor space alive with a stored survival age of 1, i.e. members of exactly the cohort the previous cycle's `eden_copied_bytes` counted; the age is already in the header at copy time (`copied_survival_age`), so no new per-object state is needed. `retune_after_scavenge` keeps its arity and its two lock parameters are redefined to those, which is the whole change at the policy end: both sides of the ratio are now scoped to one cohort at every threshold. Both new counts are on the `[gc-copy-minor]` diagnostic line next to the whole-space ones, so first-round mortality is readable from ANY build rather than only from an instrumented branch -- the measurement this policy is about should not require a custom binary. Measured, one binary, three arms via `PERRY_GC_TENURING_SURVIVALS`, 3300-char replies, 4 turns in one process, macOS arm64: arm minors promoted S=1 share via the lock =1 (pre-clamp equivalent) 356 1055 MB 100 % - clamp only, run 1 368 982 MB 85 % 8 clamp only, run 2 384 980 MB 84 % 12 =2 (positive control) 380 785 MB 0 % n/a Tests. No existing expected value moves -- all 1,069 gc tests pass unchanged, which is itself the finding: nothing in the suite distinguished the two scopes, because they are equal on every heap whose survivor space holds one generation, and that is every heap at a threshold of 2 or below. So the premise gets a test of its own on a real heap: two rooted objects introduced one cycle apart at the power-on threshold, asserting that the two numbers AGREE while only one generation is resident and then DIFFER once an aged resident joins it, with the aged object in the whole-space number and not in the cohort number. A test-only witness (`test_last_cohort_split`) reports the pair the copier computed. The two lock tests keep their values and gain the scoping in their names and comments; `a_cohort_that_dies_in_its_round_holds_at_the_occupancy_floor` now states that this same heap locks if the call site passes the whole space, which is what it used to pass. The previous commit's changelog fragment claimed the lock correctly stays out on claude-code (5 of 358 substantial cohorts under the bar). That figure was taken with the threshold PINNED, where every cohort the lock can rate is a first-round cohort; it does not describe the rule running, and the fragment is corrected rather than left to be read as a result. Claude-Session: https://claude.ai/code/session_014knX724SYDogwzsXybCGxp (cherry picked from commit c154ba61f6ca32c8610d58f5122d2646674eab8c) --- changelog.d/9851-lock-rates-one-cohort.md | 49 ++++++++++++ ...occupancy-may-not-promote-on-first-copy.md | 11 ++- crates/perry-runtime/src/gc/copying.rs | 79 +++++++++++++++++-- crates/perry-runtime/src/gc/telemetry.rs | 14 ++++ crates/perry-runtime/src/gc/tenuring.rs | 65 +++++++++++---- .../gc/tests/copying/survival_and_malloc.rs | 76 ++++++++++++++++++ 6 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 changelog.d/9851-lock-rates-one-cohort.md diff --git a/changelog.d/9851-lock-rates-one-cohort.md b/changelog.d/9851-lock-rates-one-cohort.md new file mode 100644 index 0000000000..deb0ad2d56 --- /dev/null +++ b/changelog.d/9851-lock-rates-one-cohort.md @@ -0,0 +1,49 @@ +### Fixed + +- **The tenuring survival-rate lock now rates one fresh cohort, not the whole + survivor space — a well-formed ratio that stopped describing what it is named + after as soon as the threshold it sets rose above 2.** + + The lock exists to answer "did an aging round filter anything?" and, when the + answer is no, to promote on first copy. It tested + + ``` + prev_copied >= substantial && survivor_live_bytes * 10 >= prev_copied * 9 + ``` + + where `survivor_live_bytes` is every live byte leaving the from-survivor space + this cycle, of any age, and `prev_copied` is the previous cycle's whole intake + into that space. Those two scopes match — the survivor spaces are a strict + semispace pair, so the from-space holds exactly what the last cycle copied — + and the ratio cannot exceed 1. **The defect is not the arithmetic; it is which + population the ratio rates, and that is chosen by the very threshold the lock + sets.** At a threshold of 2 the space holds one fresh cohort and the ratio is + one aging round's survival. At 3 or 4 it also holds objects that have already + survived a round and are therefore selected for longevity, so the aggregate + clears the 90 % bar while a fresh cohort does not. The rule reads its own + setting back as evidence. + + This was invisible while the occupancy rule sealed the loop at S=1, because + there `copied_bytes` is 0 and the lock's guard can never be satisfied. + Removing that seal handed the lock its guard back, and it became the dominant + route to promote-on-first-copy. + + Measured on the compiled claude-code TUI, one binary, three arms via + `PERRY_GC_TENURING_SURVIVALS`, 3300-character replies, 4 turns in one process: + + | arm | minors | promoted | S=1 share of promotion | reached 1 via the lock | + |---|---|---|---|---| + | `=1` (pre-clamp equivalent) | 356 | 1055 MB | 100 % | - | + | occupancy clamp only | 368 / 384 | 982 / 980 MB | 85 % / 84 % | **8 / 12** | + | `=2` (positive control) | 380 | 785 MB | 0 % | n/a | + + The copier now also accounts the fresh half of each cycle: `eden_copied_bytes` + (bytes copied out of *Eden* into the to-survivor space, no re-copies) and + `survivor_first_round_live_bytes` (live bytes leaving the from-survivor space + whose stored survival age is 1, i.e. members of exactly the cohort the + previous cycle's `eden_copied_bytes` counted). The lock rates those two. Both + are on the `[gc-copy-minor]` diagnostic line, so first-round mortality is + readable from any build rather than only from an instrumented one. + + Reaching 1 still belongs to the paths that measure mortality; what changes is + that the measurement is now of one aging round at every threshold. diff --git a/changelog.d/9851-occupancy-may-not-promote-on-first-copy.md b/changelog.d/9851-occupancy-may-not-promote-on-first-copy.md index 0dbe039495..40d6d0a057 100644 --- a/changelog.d/9851-occupancy-may-not-promote-on-first-copy.md +++ b/changelog.d/9851-occupancy-may-not-promote-on-first-copy.md @@ -49,8 +49,15 @@ and the sweep seed (the mark-sweep's own Eden live/dead split). Both are untouched, so the rule is self-limiting: on a workload whose cohort genuinely does not die, the lock fires after one cohort's copy and takes the loop back - to 1. On claude-code it correctly does not — 5 of 358 substantial cohorts sit - under the lock's threshold — so the clamp holds rather than oscillating. + to 1. + + On claude-code it **does** fire, 8-12 times per four-turn run, and the + companion entry below is why: once the clamp lets the ladder climb past 2 the + lock is rating a population its own threshold selected. An earlier version of + this entry claimed the opposite ("5 of 358 substantial cohorts sit under the + lock's threshold, so the clamp holds rather than oscillating"); that figure + was measured with the threshold *pinned*, where every cohort the lock can + rate is a first-round cohort, and it does not describe the rule running. Two existing tests change their expected value from 1 to 2 and keep their names, structure and invariants: `drops_immediately_and_rises_debounced` diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 2eb134d64f..c3f6b368b5 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -617,8 +617,29 @@ impl CopyingNurseryCollector { // moved somewhere at any threshold), which is what makes the loop's // fixed point stable. match ptr.kind { - CopyingPointerKind::Eden => self.stats.eden_live_bytes += total, - _ => self.stats.survivor_live_bytes += total, + CopyingPointerKind::Eden => { + self.stats.eden_live_bytes += total; + // #9851 follow-up: the fresh half of `copied_bytes`. The + // survival-rate lock's denominator must be the intake of ONE + // cohort; `copied_bytes` also carries survivor residents being + // re-copied, which at a threshold above 2 is most of it. + if !promote { + self.stats.eden_copied_bytes += total; + } + } + _ => { + self.stats.survivor_live_bytes += total; + // ...and the matching numerator. A from-survivor object whose + // stored age is 1 entered from Eden on the previous cycle, so + // it is a member of exactly the cohort `eden_copied_bytes` + // counted then. Ages above 1 have already survived a round and + // are a population selected for longevity; including them is + // what made the ratio drift above the lock's bar as the + // threshold rose. + if prior_age == 1 { + self.stats.survivor_first_round_live_bytes += total; + } + } } new_user as usize } @@ -1867,14 +1888,24 @@ pub(super) fn run_copied_minor_attempt( .copied_objects .saturating_add(collector.stats.promoted_objects), ); - retune_after_scavenge( - collector.stats.eden_live_bytes, + // #9851 follow-up: the survival-rate lock is fed the FRESH cohort's intake + // and that same cohort's survival, not the whole survivor space's. See + // `retune_after_scavenge`. + #[cfg(test)] + test_record_cohort_split( collector.stats.copied_bytes, + collector.stats.eden_copied_bytes, collector.stats.survivor_live_bytes, + collector.stats.survivor_first_round_live_bytes, + ); + retune_after_scavenge( + collector.stats.eden_live_bytes, + collector.stats.eden_copied_bytes, + collector.stats.survivor_first_round_live_bytes, ); if crate::gc::gc_diag_enabled() { eprintln!( - "[gc-copy-minor] ran in_place={} untraced={} untraced_cycles={} untraced_objects={} in_place_blocks={} in_place_dead_bytes={} sparse_blocks={} survival_permille={} copied_objects={} copied_bytes={} promoted_objects={} promoted_bytes={} freed_bytes={} tenuring_survivals={} eden_live_bytes={} trigger={:?} declared_safepoint={}", + "[gc-copy-minor] ran in_place={} untraced={} untraced_cycles={} untraced_objects={} in_place_blocks={} in_place_dead_bytes={} sparse_blocks={} survival_permille={} copied_objects={} copied_bytes={} promoted_objects={} promoted_bytes={} freed_bytes={} tenuring_survivals={} eden_live_bytes={} eden_copied_bytes={} survivor_live_bytes={} survivor_first_round_live_bytes={} trigger={:?} declared_safepoint={}", collector.stats.in_place_promotion, untraced, super::untraced_promotion_cycles(), @@ -1890,6 +1921,9 @@ pub(super) fn run_copied_minor_attempt( freed_bytes, collector.stats.tenuring_survivals, collector.stats.eden_live_bytes, + collector.stats.eden_copied_bytes, + collector.stats.survivor_live_bytes, + collector.stats.survivor_first_round_live_bytes, _trigger_kind, super::policy::GC_AT_DECLARED_SAFEPOINT.with(std::cell::Cell::get) ); @@ -1908,6 +1942,41 @@ pub(super) fn run_copied_minor_attempt( })) } +/// Test-only witness for the #9851 follow-up: the whole-space pair against the +/// fresh-cohort pair, as the copier computed them for one cycle. Without this +/// the change is unfalsifiable from a test — the two quantities are equal on +/// every heap whose survivor space holds a single generation, which is every +/// heap at a threshold of 2 or below. +#[cfg(test)] +thread_local! { + static LAST_COHORT_SPLIT: std::cell::Cell<(usize, usize, usize, usize)> = + const { std::cell::Cell::new((0, 0, 0, 0)) }; +} + +#[cfg(test)] +fn test_record_cohort_split( + copied_bytes: usize, + eden_copied_bytes: usize, + survivor_live_bytes: usize, + first_round_live_bytes: usize, +) { + LAST_COHORT_SPLIT.with(|c| { + c.set(( + copied_bytes, + eden_copied_bytes, + survivor_live_bytes, + first_round_live_bytes, + )) + }); +} + +/// `(copied_bytes, eden_copied_bytes, survivor_live_bytes, first_round_live_bytes)` +/// from the most recent copying minor on this thread. +#[cfg(test)] +pub(super) fn test_last_cohort_split() -> (usize, usize, usize, usize) { + LAST_COHORT_SPLIT.with(std::cell::Cell::get) +} + fn finalize_dead_copied_minor_from_space_side_allocations() { crate::map::finalize_dead_copied_minor_from_space_maps(); crate::set::finalize_dead_copied_minor_from_space_sets(); diff --git a/crates/perry-runtime/src/gc/telemetry.rs b/crates/perry-runtime/src/gc/telemetry.rs index ac62c3b605..e8c48255e1 100644 --- a/crates/perry-runtime/src/gc/telemetry.rs +++ b/crates/perry-runtime/src/gc/telemetry.rs @@ -275,6 +275,18 @@ pub(super) struct CopyingNurseryTraceStats { /// Live bytes re-copied/promoted out of the from-survivor space this /// cycle — the re-copy tax the adaptive loop exists to bound. pub(super) survivor_live_bytes: usize, + /// #9851 follow-up: the FRESH half of `copied_bytes` — bytes copied out of + /// Eden into the to-survivor space this cycle, excluding survivor-space + /// residents being re-copied. This is the intake of exactly one cohort, + /// and it is the denominator the survival-rate lock must use. + pub(super) eden_copied_bytes: usize, + /// The matching numerator: live bytes moved out of the from-survivor space + /// this cycle whose stored survival age was 1 — i.e. objects that entered + /// the survivor space from Eden on the PREVIOUS cycle, and nothing older. + /// `survivor_live_bytes` rates the whole space, whose composition changes + /// with the threshold; this rates one aging round of one fresh cohort, + /// which is what the lock's conclusion is about. + pub(super) survivor_first_round_live_bytes: usize, pub(super) large_excluded_objects: usize, pub(super) large_excluded_bytes: usize, pub(super) reset_blocks: usize, @@ -1151,6 +1163,8 @@ impl GcCycleTrace { "tenuring_survivals": self.copying_nursery.tenuring_survivals, "eden_live_bytes": self.copying_nursery.eden_live_bytes, "survivor_live_bytes": self.copying_nursery.survivor_live_bytes, + "eden_copied_bytes": self.copying_nursery.eden_copied_bytes, + "survivor_first_round_live_bytes": self.copying_nursery.survivor_first_round_live_bytes, "large_excluded_objects": self.copying_nursery.large_excluded_objects, "large_excluded_bytes": self.copying_nursery.large_excluded_bytes, "reset_blocks": self.copying_nursery.reset_blocks, diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index b8998490da..4f1b63ce67 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -515,20 +515,37 @@ pub(super) fn compute_target_survivals(eden_live_bytes: usize, desired_bytes: us /// Feed one finished copying-minor cycle into the feedback loop. /// `eden_live_bytes` is the cycle's Eden survivor influx (bytes moved out -/// of Eden, whether copied to a survivor space or promoted); -/// `copied_bytes` is what this cycle put into the to-survivor space; -/// `survivor_live_bytes` is what came back out of the from-survivor space -/// alive (numerator of the survival rate against the *previous* cycle's -/// `copied_bytes`). +/// of Eden, whether copied to a survivor space or promoted). +/// +/// The other two are **one cohort's** intake and that same cohort's survival, +/// and they must stay that way (#9851 follow-up): +/// `eden_copied_bytes` is what this cycle copied out of *Eden* into the +/// to-survivor space — a fresh cohort, no re-copies — and +/// `first_round_live_bytes` is what came back out of the from-survivor space +/// alive with a stored age of 1, i.e. members of the cohort that the +/// *previous* cycle's `eden_copied_bytes` counted. +/// +/// Why not the whole space. The survivor spaces are a strict semispace pair, +/// so the from-space at cycle N holds exactly what cycle N-1 copied, and +/// `survivor_live_bytes / prev_copied_bytes` is a well-formed survival ratio — +/// of the whole space. But *what that space contains* is set by the very +/// threshold this loop controls: at S<=2 it is one fresh cohort, at S=3-4 it +/// also holds age-2 and age-3 objects, which have already survived a round and +/// are therefore selected for longevity. Rating that mixture and concluding +/// "the aging round filters nothing" applies a measurement of an aged, +/// self-selected population to first-round cohorts. Measured on the compiled +/// claude-code TUI: a fresh cohort survives at 74 %, and the loop still reached +/// the lock's 90 % bar 8-12 times per four-turn run once #9851's clamp let the +/// ladder climb past 2. pub(super) fn retune_after_scavenge( eden_live_bytes: usize, - copied_bytes: usize, - survivor_live_bytes: usize, + eden_copied_bytes: usize, + first_round_live_bytes: usize, ) { retune_nursery_cap_scale(eden_live_bytes); let desired = desired_survivor_bytes(); let substantial = desired / 4; - let prev_copied = PREV_COPIED_BYTES.with(|c| c.replace(copied_bytes)); + let prev_cohort_copied = PREV_COPIED_BYTES.with(|c| c.replace(eden_copied_bytes)); let current = TENURING_SURVIVALS.with(Cell::get); if PROMOTE_LOCK.with(Cell::get) { @@ -554,10 +571,18 @@ pub(super) fn retune_after_scavenge( return; } - // Survival-rate lock: last cycle's survivor intake was substantial and + // Survival-rate lock: last cycle's FRESH COHORT was substantial and // (nearly) all of it came back out alive, so the aging round filters // nothing — every copied byte is a byte that will be promoted anyway. - if prev_copied >= substantial && survivor_live_bytes.saturating_mul(10) >= prev_copied * 9 { + // + // Both sides are scoped to that one cohort (#9851 follow-up). Rating the + // whole survivor space instead makes the ratio rise with the threshold + // this rule sets, because a higher threshold is precisely what keeps + // already-aged objects in the space; the rule then reads its own setting + // back as evidence. See `retune_after_scavenge`'s header. + if prev_cohort_copied >= substantial + && first_round_live_bytes.saturating_mul(10) >= prev_cohort_copied * 9 + { PROMOTE_LOCK.with(|l| l.set(true)); UNLOCK_STREAK.with(|s| s.set(0)); RAISE_STREAK.with(|s| s.set(0)); @@ -1031,10 +1056,13 @@ mod tests { // comes back fully alive. THAT is lifetime evidence, and it must still // reach 1. // - // TWO cycles, deliberately: the lock rates `survivor_live_bytes` against - // the PREVIOUS cycle's `copied_bytes` (`PREV_COPIED_BYTES`), so the - // first call is what puts a cohort in the survivor space and the second - // is what reports it coming back alive. Phase 1 above copied nothing, so + // TWO cycles, deliberately: the lock rates the fresh cohort's survival + // against the PREVIOUS cycle's `eden_copied_bytes` (`PREV_COPIED_BYTES`), + // so the first call is what puts a cohort in the survivor space and the + // second is what reports that same cohort coming back alive. Both + // arguments here are cohort-scoped, which is what the follow-up to + // #9851 made them: the whole survivor space is a different population + // once the threshold rises above 2. Phase 1 above copied nothing, so // there is nothing to rate until this pair runs — which is precisely the // blindness the change is about, here as a test mechanic. retune_after_scavenge(16 * d, 3 * d, 0); @@ -1058,7 +1086,14 @@ mod tests { /// occupancy floor rather than being locked to 1 — the case cc actually is. /// Measured there: 26.1 % of each cohort dies in one survivor round, in /// steady state, on 393 samples; the lock needs >=90 % survival, so it - /// correctly stays out and the clamp holds instead of oscillating. + /// correctly stays out. + /// + /// The arguments are the FRESH COHORT's intake and survival (#9851 + /// follow-up). Fed the whole survivor space instead — which is what the + /// call site used to pass — this same heap locks, because above a threshold + /// of 2 that space also holds objects already selected for longevity. That + /// is not a hypothetical: on cc the clamp alone left 85 % of promotion at + /// S=1, reached through this lock 8-12 times per four-turn run. #[test] fn a_cohort_that_dies_in_its_round_holds_at_the_occupancy_floor() { reset_for_test(); diff --git a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs index aed0b6c681..68478b74a4 100644 --- a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs +++ b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs @@ -1065,3 +1065,79 @@ fn nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor() { ); js_shadow_slot_set(0, 0); } + +/// #9851 follow-up — THE PREMISE OF THE LOCK REWIRE, on a real heap. +/// +/// The survival-rate lock used to rate `survivor_live_bytes` (every live byte +/// leaving the from-survivor space, of any age) against the previous cycle's +/// whole `copied_bytes`. Those two scopes match — the survivor spaces are a +/// strict semispace pair, so the from-space holds exactly what the last cycle +/// copied — and the ratio is well-formed. What is wrong is *which population* +/// it rates, and that is chosen by the threshold the lock itself sets: at a +/// threshold of 2 the space holds one fresh cohort, at 3 or 4 it also holds +/// objects that have already survived a round and are therefore selected for +/// longevity. +/// +/// This test pins the fact that makes the rewire meaningful rather than a +/// rename: **at a threshold above 2 the whole-space number and the fresh-cohort +/// number are different numbers**, with the aged resident in the first and not +/// in the second. On cc that difference is the whole finding — the aggregate +/// clears the lock's 90 % bar while a fresh cohort survives at 74 %. +/// +/// Shape: at the power-on threshold (promote on the 4th survival) two rooted +/// objects are introduced one cycle apart, so by the third minor the +/// from-survivor space holds one age-2 object and one age-1 object. +#[test] +fn the_survivor_space_and_the_fresh_cohort_are_different_numbers_above_threshold_two() { + // TWO shadow slots: the test needs two independently rooted objects + // introduced one cycle apart, so that the survivor space holds two age + // classes at once. With one slot B is unrooted, dies immediately, and the + // fresh-cohort number is trivially zero. + let _guard = CopyingNurseryTestGuard::new(2); + + // Cycle 1: A enters the survivor space from Eden. The from-survivor space + // was empty, so both numbers are zero and the cohort is all of nothing. + let a = young_leaf(); + js_shadow_slot_set(0, ptr_bits(a)); + let _ = gc_collect_minor(); + let (_, _, survivor_live_1, first_round_1) = crate::gc::copying::test_last_cohort_split(); + assert_eq!( + (survivor_live_1, first_round_1), + (0, 0), + "cycle 1 evacuates Eden only: nothing came out of the survivor space" + ); + + // Cycle 2: A is re-copied (age 1 -> 2) and B enters from Eden. The + // from-survivor space held ONLY A, which is a first-round object, so the + // two numbers must still agree — this is the regime the lock was designed + // in, and the assertion that the split is not simply always different. + let b = young_leaf(); + js_shadow_slot_set(1, ptr_bits(b)); + let _ = gc_collect_minor(); + let (_, _, survivor_live_2, first_round_2) = crate::gc::copying::test_last_cohort_split(); + assert!(survivor_live_2 > 0, "A must have come back out of the survivor space"); + assert_eq!( + survivor_live_2, first_round_2, + "with a single generation resident the whole-space number IS the \ + fresh-cohort number — at threshold <= 2 the old rule was correct" + ); + + // Cycle 3: the from-survivor space now holds A (age 2) and B (age 1). + // `survivor_live_bytes` counts both; the fresh cohort is B alone. + let _ = gc_collect_minor(); + let (_, _, survivor_live_3, first_round_3) = crate::gc::copying::test_last_cohort_split(); + assert!( + first_round_3 > 0, + "B is a first-round survivor and must be counted as one" + ); + assert!( + survivor_live_3 > first_round_3, + "the aged resident A is in the whole-space number and must NOT be in \ + the fresh-cohort number: whole-space {survivor_live_3}, cohort \ + {first_round_3}. If these are equal the lock is still rating a \ + population its own threshold selected." + ); + + js_shadow_slot_set(0, 0); + js_shadow_slot_set(1, 0); +} From eda33a57ef3d75b17487d014aeca00516dffb717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 6 Sep 2026 21:10:24 +0200 Subject: [PATCH 10/20] fix(gc): the occupancy rule may not claim the CEILING before any round is measured The symmetric half of #9851. That commit stopped the occupancy rule concluding "promote on first copy" -- a claim about LIFETIME derived from a measurement of SPACE. The same formula makes the same category error at the other end: compute_target_survivals = 1 + desired / influx (capped at the ceiling) returns the ceiling for a tiny influx AND for a zero one. On the first minors of a process -- heap nearly empty, no cohort ever followed -- occupancy therefore claims the MAXIMUM, before a single object has been given the chance to die. It is the expensive direction of the error, because every survivor is then copied up to three times before it may be promoted. Measured on the landing base (main5 + #9881, one binary, four env arms, two rounds of 4 turns at 3300 and 400, quiet host), this startup excursion is the WHOLE difference between the adaptive loop and a pinned threshold: * unset vs pinned S=1: turn-1 CPU 3.41 s vs 3.02 s at 3300 (+0.35..0.45 s both rounds) and 1.05 s vs 0.72 s at 400 (+50 %), while the sum over turns 2-4 is within noise (6.18-6.23 vs 6.35-6.41); * the adaptive arm's transitions are `4 -> 2 (occupancy) -> 1 (lock)` and ALL of them land inside turn 1; turns 2-4 run at S=1 with nothing copied. So the adaptive policy's only cost on this workload was a startup claim it had no evidence for, and its steady state was already the pinned one. The rule is now symmetric: **until one survivor round has actually been rated, the occupancy rule holds at `OCCUPANCY_MIN_SURVIVALS`.** That value is not a tuning choice; it is the lowest threshold that PRODUCES the measurement the rule needs in order to say anything -- at 1 nothing enters the survivor space, at 2 exactly one cohort does. The power-on threshold becomes the same value for the same reason: starting at the ceiling is a lifetime claim made before the process has run. `SURVIVOR_ROUND_MEASURED` is set the moment a cohort the previous cycle copied becomes rateable, so the gate lifts after about two minors and the ladder is unchanged from then on -- it delays the claim until evidence exists, it does not remove the ladder. The two paths that MEASURE mortality are untouched: the survival-rate lock and the sweep seed may still reach 1 whenever they have the evidence for it. `compute_target_survivals` is again left alone, and its test is again the proof: the arithmetic still returns the ceiling for a zero and a tiny influx. Only what the loop may do with that changes. Tests. A new two-phase test: eight startup-shaped minors (tiny influx, nothing copied) must leave the loop at the floor and out of the lock; then, once a cohort has gone through the survivor space and been followed, the debounced rise must still reach the ceiling. Sabotage: delete the gate, or restore the power-on value to the ceiling, and phase 1 fails. Two existing tests move with the power-on value and keep their properties: `drops_immediately_and_rises_debounced` is about the ladder's ASYMMETRY, so it now seeds a fully-dying cohort first (which rates a round without involving the lock) and then tests the same immediate-drop / debounced-rise behaviour; `sweep_seed_refuses_a_small_fully_live_eden` asserts the threshold is unchanged from power-on, which is the floor now. `survival_rate_lock_breaks_a_saturated_ pipeline` needs no change -- the lock firing implies a rated round, so its ladder recovery is unaffected. NOT COMPILED: the box is at 7 GB free, under this campaign's 12 GB build floor, so neither the build nor the suite has been run against this commit. The braces balance and the reasoning above is stated per test, but that is a review and not a check. (cherry picked from commit 481e941bdd297b845d74ea47c3f5baa7ebf216e2) --- crates/perry-runtime/src/gc/tenuring.rs | 128 +++++++++++++++++++++++- 1 file changed, 123 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 4f1b63ce67..07f2be47e3 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -199,7 +199,17 @@ const RAISE_DEBOUNCE_CYCLES: u8 = 2; const NURSERY_CAP_SCALE_MAX: u8 = 4; crate::perry_thread_local! { - static TENURING_SURVIVALS: Cell = const { Cell::new(GC_TENURING_SURVIVALS_MAX) }; + /// Power-on threshold. This is `OCCUPANCY_MIN_SURVIVALS`, not the ceiling: + /// see `SURVIVOR_ROUND_MEASURED`. Starting at the ceiling is a claim that + /// young objects live long, made before a single object has been given the + /// chance to die, and it is the expensive direction of that claim -- every + /// survivor is copied three times before it can be promoted. + static TENURING_SURVIVALS: Cell = const { Cell::new(OCCUPANCY_MIN_SURVIVALS) }; + /// Has any survivor round been RATED yet on this thread -- i.e. did some + /// cycle put a cohort into the survivor space that the next cycle could + /// then follow? Until this is true the loop has no lifetime evidence of + /// any kind, and the occupancy rule may not move off the floor. + static SURVIVOR_ROUND_MEASURED: Cell = const { Cell::new(false) }; static RAISE_STREAK: Cell = const { Cell::new(0) }; /// Survival-rate lock: promote-on-first-copy until influx goes quiet. static PROMOTE_LOCK: Cell = const { Cell::new(false) }; @@ -546,6 +556,12 @@ pub(super) fn retune_after_scavenge( let desired = desired_survivor_bytes(); let substantial = desired / 4; let prev_cohort_copied = PREV_COPIED_BYTES.with(|c| c.replace(eden_copied_bytes)); + // A cohort went into the survivor space last cycle, so THIS cycle is the + // one that could follow it: from here on the loop has lifetime evidence and + // the occupancy rule is allowed to move off the floor. + if prev_cohort_copied > 0 { + SURVIVOR_ROUND_MEASURED.with(|m| m.set(true)); + } let current = TENURING_SURVIVALS.with(Cell::get); if PROMOTE_LOCK.with(Cell::get) { @@ -597,7 +613,28 @@ pub(super) fn retune_after_scavenge( // sweep seed on `... != 1` ("would occupancy alone already promote on first // copy?"). Clamping the shared function would silently disarm the sweep // seed, which is one of the two paths that IS allowed to reach 1. - let target = compute_target_survivals(eden_live_bytes, desired).max(OCCUPANCY_MIN_SURVIVALS); + // + // The startup follow-up makes that rule SYMMETRIC. `1 + desired / influx` + // returns the ceiling for a tiny influx and for a zero one, so on the first + // minors of a process — when the heap is nearly empty and no cohort has + // ever been followed — occupancy claims the maximum. That is the same + // category error in the other direction: a claim about LIFETIME from a + // measurement of SPACE, made before any evidence exists, and the expensive + // one, because every survivor is then copied up to three times before it + // may be promoted. Measured on the compiled claude-code TUI, the whole + // adaptive-vs-pinned difference was this startup excursion — + // `4 -> 2 (occupancy) -> 1 (lock)` inside turn 1 and nothing afterwards, + // worth +0.35..0.45 s at 3300 chars and +50 % at 400. + // + // So until one survivor round has actually been rated, occupancy holds at + // the floor: the lowest threshold that PRODUCES the measurement it needs to + // say anything at all. Evidence, not the ladder, is what lets it move. + let measured = SURVIVOR_ROUND_MEASURED.with(Cell::get); + let target = if measured { + compute_target_survivals(eden_live_bytes, desired).max(OCCUPANCY_MIN_SURVIVALS) + } else { + OCCUPANCY_MIN_SURVIVALS + }; let next = if target < current { RAISE_STREAK.with(|s| s.set(0)); target @@ -763,7 +800,8 @@ fn set_survivals(current: u8, next: u8, eden_live_bytes: usize, why: &str) { #[cfg(test)] pub(super) fn reset_for_test() { - TENURING_SURVIVALS.with(|s| s.set(GC_TENURING_SURVIVALS_MAX)); + TENURING_SURVIVALS.with(|s| s.set(OCCUPANCY_MIN_SURVIVALS)); + SURVIVOR_ROUND_MEASURED.with(|m| m.set(false)); RAISE_STREAK.with(|s| s.set(0)); PROMOTE_LOCK.with(|l| l.set(false)); UNLOCK_STREAK.with(|s| s.set(0)); @@ -956,7 +994,17 @@ mod tests { fn drops_immediately_and_rises_debounced() { reset_for_test(); let desired = desired_survivor_bytes(); - assert_eq!(tenuring_survivals(), 4); + // Power-on is the FLOOR now, not the ceiling (startup follow-up): the + // ladder may not claim a lifetime in either direction without evidence. + assert_eq!(tenuring_survivals(), OCCUPANCY_MIN_SURVIVALS); + + // Give the loop its evidence, because this test is about the ladder's + // ASYMMETRY and not about the startup gate. Two cycles with a cohort + // that fully dies: the second rates the first, so a survivor round has + // been measured, and 0 % survival keeps the lock out of it. + retune_after_scavenge(desired * 2, 3 * desired, 0); + retune_after_scavenge(desired * 2, 3 * desired, 0); + assert_eq!(tenuring_survivals(), OCCUPANCY_MIN_SURVIVALS); // Heavy influx: instant drop, no debounce. #9851 changed the FLOOR this // lands on (2, not 1 — the occupancy rule may not claim a lifetime), not @@ -1082,6 +1130,73 @@ mod tests { reset_for_test(); } + /// STARTUP FOLLOW-UP — the occupancy rule may not claim the CEILING either. + /// + /// `1 + desired / influx` returns the ceiling for a tiny influx and for a + /// zero one, so on the first minors of a process — heap nearly empty, no + /// cohort ever followed — occupancy claims the maximum. That is the same + /// category error as claiming 1: a statement about LIFETIME derived from a + /// measurement of SPACE, made before any evidence exists. It is also the + /// expensive direction, because every survivor is then copied up to three + /// times before it may be promoted. + /// + /// Measured on the compiled claude-code TUI, this was the WHOLE difference + /// between the adaptive loop and a pinned threshold: a single startup + /// excursion `4 -> 2 (occupancy) -> 1 (lock)` inside turn 1, nothing + /// afterwards, worth +0.35..0.45 s at 3300 characters and +50 % at 400. + /// + /// Sabotage: delete the `SURVIVOR_ROUND_MEASURED` gate in + /// `retune_after_scavenge` (or restore the power-on value to + /// `GC_TENURING_SURVIVALS_MAX`) and phase 1 fails — the loop reports the + /// ceiling on a heap where nothing has ever been rated. + #[test] + fn occupancy_may_not_claim_the_ceiling_before_any_round_is_measured() { + reset_for_test(); + let d = desired_survivor_bytes(); + + // Precondition: the ARITHMETIC still says "ceiling" for a startup-sized + // influx. This change gates what the loop may do with that, exactly as + // #9851 did at the other end of the range. + assert_eq!(compute_target_survivals(0, d), GC_TENURING_SURVIVALS_MAX); + assert_eq!(compute_target_survivals(d / 64, d), GC_TENURING_SURVIVALS_MAX); + + // Phase 1: power-on, then many startup-shaped minors — tiny influx, + // nothing copied, so nothing rateable. The loop must sit at the floor + // and never climb. + assert_eq!(tenuring_survivals(), OCCUPANCY_MIN_SURVIVALS); + for _ in 0..8 { + retune_after_scavenge(d / 64, 0, 0); + assert_eq!( + tenuring_survivals(), + OCCUPANCY_MIN_SURVIVALS, + "no survivor round has been rated, so occupancy has no lifetime \ + evidence and may not leave the floor" + ); + } + assert!( + !PROMOTE_LOCK.with(Cell::get), + "and it must not have reached the floor via the lock either" + ); + + // Phase 2: once a cohort has actually gone through the survivor space + // and been followed, the ladder is allowed to move again. A cohort that + // fully dies keeps the lock out, so what is observed here is the + // occupancy rule being re-enabled and nothing else. + retune_after_scavenge(d / 64, 3 * d, 0); + retune_after_scavenge(d / 64, 3 * d, 0); + for _ in 0..8 { + retune_after_scavenge(d / 64, 0, 0); + } + assert_eq!( + tenuring_survivals(), + GC_TENURING_SURVIVALS_MAX, + "with a round measured and the influx quiet, the debounced rise must \ + still reach the ceiling — the gate delays the claim until there is \ + evidence, it does not remove the ladder" + ); + reset_for_test(); + } + /// #9851: a cohort that DIES in its survivor round must keep the loop at the /// occupancy floor rather than being locked to 1 — the case cc actually is. /// Measured there: 26.1 % of each cohort dies in one survivor round, in @@ -1333,7 +1448,10 @@ mod tests { reset_for_test(); let d = desired_survivor_bytes(); seed_promote_lock_from_sweep(d / 8, 0); - assert_eq!(tenuring_survivals(), 4); + // Unchanged from power-on, which is the floor now rather than the + // ceiling (startup follow-up). The property under test is that the + // sweep seed REFUSED — it left the threshold where it found it. + assert_eq!(tenuring_survivals(), OCCUPANCY_MIN_SURVIVALS); reset_for_test(); } From 8a47a79e2c22b025c5d0f1b9ffca7035f818ea53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 04:01:49 +0200 Subject: [PATCH 11/20] test(gc): pin the tenuring threshold the tests exercise; power-on is the floor (cherry picked from commit 939187c426de516b6bba6d8a9de7ca6a649a40e3) --- crates/perry-runtime/src/gc/tenuring.rs | 66 ++++++++++++++++--- .../src/gc/tests/copying/adaptive_tenuring.rs | 10 +-- .../tests/copying/promoted_remembered_7803.rs | 7 +- .../gc/tests/copying/survival_and_malloc.rs | 22 +++++-- .../gc/tests/copying/weak_holder_registry.rs | 2 + crates/perry-runtime/src/gc/tests/oldgen.rs | 2 + .../runtime_roots/hook_dispatch_handles.rs | 4 ++ crates/perry-runtime/src/gc/tests/support.rs | 7 +- 8 files changed, 95 insertions(+), 25 deletions(-) diff --git a/crates/perry-runtime/src/gc/tenuring.rs b/crates/perry-runtime/src/gc/tenuring.rs index 07f2be47e3..72255c0b78 100644 --- a/crates/perry-runtime/src/gc/tenuring.rs +++ b/crates/perry-runtime/src/gc/tenuring.rs @@ -138,7 +138,7 @@ use super::*; -/// Ceiling and power-on value: the previous fixed threshold. +/// Ceiling and previous fixed threshold. pub(super) const GC_TENURING_SURVIVALS_MAX: u8 = GC_COPY_PROMOTION_SURVIVALS; /// The lowest threshold the **occupancy rule** may select. @@ -235,17 +235,50 @@ crate::perry_thread_local! { static OBJECT_CENSUS_SEEDED: Cell = const { Cell::new(false) }; } +#[cfg(test)] +thread_local! { + /// Scoped threshold pin for tests of mechanisms that require a particular + /// promotion age. This is thread-local for the same reason as the adaptive + /// state: runtime tests share one process and may run on different threads. + static TENURING_SURVIVALS_TEST_OVERRIDE: Cell> = const { Cell::new(None) }; +} + /// The survivals threshold the next copying minor should promote at: /// `next_age >= tenuring_survivals()` tenures. In `1..=4`; 4 is the /// original fixed policy, 1 promotes every live nursery object on first /// copy. pub(super) fn tenuring_survivals() -> u8 { + #[cfg(test)] + if let Some(forced) = TENURING_SURVIVALS_TEST_OVERRIDE.with(Cell::get) { + return forced; + } if let Some(forced) = tenuring_survivals_override() { return forced; } TENURING_SURVIVALS.with(Cell::get) } +/// Pin the promotion age for a threshold-sensitive test on this thread. +/// Restores the previous pin on drop; the adaptive policy continues to run +/// underneath it, but every copying minor snapshots the explicitly pinned age. +#[cfg(test)] +pub(super) fn set_survivals_for_test(survivals: u8) -> TenuringSurvivalsTestGuard { + assert!((1..=GC_TENURING_SURVIVALS_MAX).contains(&survivals)); + TenuringSurvivalsTestGuard( + TENURING_SURVIVALS_TEST_OVERRIDE.with(|cell| cell.replace(Some(survivals))), + ) +} + +#[cfg(test)] +pub(super) struct TenuringSurvivalsTestGuard(Option); + +#[cfg(test)] +impl Drop for TenuringSurvivalsTestGuard { + fn drop(&mut self) { + TENURING_SURVIVALS_TEST_OVERRIDE.with(|cell| cell.set(self.0)); + } +} + /// `PERRY_GC_TENURING_SURVIVALS=` pins the promotion age, overriding the /// adaptive threshold (#7432). Diagnostic only; unset means adaptive. /// @@ -1158,7 +1191,10 @@ mod tests { // influx. This change gates what the loop may do with that, exactly as // #9851 did at the other end of the range. assert_eq!(compute_target_survivals(0, d), GC_TENURING_SURVIVALS_MAX); - assert_eq!(compute_target_survivals(d / 64, d), GC_TENURING_SURVIVALS_MAX); + assert_eq!( + compute_target_survivals(d / 64, d), + GC_TENURING_SURVIVALS_MAX + ); // Phase 1: power-on, then many startup-shaped minors — tiny influx, // nothing copied, so nothing rateable. The loop must sit at the floor @@ -1305,15 +1341,23 @@ mod tests { let d = desired_survivor_bytes(); // Medium-lived objects: a substantial intake of which only half // survives its survivor round. Aging is filtering — the lock must - // stay out and the occupancy ladder must decide. + // stay out and the occupancy ladder must age from the power-on floor. + let mut seen = Vec::new(); for _ in 0..6 { retune_after_scavenge(d / 2, d / 2, d / 4); assert!( - tenuring_survivals() >= 3, - "a cohort that dies in the survivor space must keep aging (got {})", - tenuring_survivals() + !PROMOTE_LOCK.with(Cell::get), + "50% survival is below the lock's 90% bar" ); + seen.push(tenuring_survivals()); } + assert_eq!( + seen, + [2, 2, 3, 3, 3, 3], + "power-on is the floor now, not the ceiling: after a survivor round \ + is measured, the debounced occupancy ladder must keep the dying \ + cohort aging rather than claim promote-on-first-copy" + ); reset_for_test(); } @@ -1401,8 +1445,9 @@ mod tests { let eden_live = d * 4; assert_eq!( tenuring_survivals(), - 4, - "with no input the loop is at the ceiling: the wasted copy state" + OCCUPANCY_MIN_SURVIVALS, + "power-on is the floor now, not the ceiling: with no lifetime \ + evidence the loop may not claim either extreme" ); seed_promote_lock_from_sweep(eden_live, eden_live / 50); @@ -1433,8 +1478,9 @@ mod tests { seed_promote_lock_from_sweep(eden_live, eden_dead); assert_eq!( tenuring_survivals(), - 4, - "10% Eden survival must not seed promote-on-first-copy" + OCCUPANCY_MIN_SURVIVALS, + "10% Eden survival must leave the loop at the power-on floor, not \ + seed promote-on-first-copy by claiming a threshold below 2" ); reset_for_test(); } diff --git a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs index 193cdafba9..b8259420ac 100644 --- a/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs +++ b/crates/perry-runtime/src/gc/tests/copying/adaptive_tenuring.rs @@ -27,17 +27,17 @@ fn heavy_influx_lowers_threshold_and_promotes_next_cycle() { let _guard = CopyingNurseryTestGuard::new(SLOTS); assert_eq!( crate::gc::tenuring::tenuring_survivals(), - GC_COPY_PROMOTION_SURVIVALS, - "guard must start every test at the power-on threshold" + crate::gc::tenuring::OCCUPANCY_MIN_SURVIVALS, + "guard must start every test at the power-on floor, not the ceiling" ); fill_slots_with_heavy_influx(); let before = (js_shadow_slot_get(0) & POINTER_MASK) as usize; assert!(crate::arena::pointer_in_nursery(before)); - // Cycle 1 runs at the power-on threshold: the cohort is copied into a - // survivor space (ages to 1), and its influx re-tunes the threshold down - // to promote-on-first-copy. + // Cycle 1 runs at the power-on floor: the cohort is copied into a survivor + // space (ages to 1), and heavy influx must not take occupancy below that + // floor by claiming promote-on-first-copy without lifetime evidence. let _ = gc_collect_minor(); assert_eq!( crate::gc::tenuring::tenuring_survivals(), diff --git a/crates/perry-runtime/src/gc/tests/copying/promoted_remembered_7803.rs b/crates/perry-runtime/src/gc/tests/copying/promoted_remembered_7803.rs index 45c6c25f82..18963db4af 100644 --- a/crates/perry-runtime/src/gc/tests/copying/promoted_remembered_7803.rs +++ b/crates/perry-runtime/src/gc/tests/copying/promoted_remembered_7803.rs @@ -62,6 +62,8 @@ fn young_padded_closure_capturing(bits: u64) -> usize { #[test] fn drain_promoted_parent_keeps_its_young_child_edge_remembered() { let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); // parent captures a young leaf; intermediate captures parent. Only the // INTERMEDIATE is rooted, so the parent is reached — and, on the @@ -95,9 +97,8 @@ fn drain_promoted_parent_keeps_its_young_child_edge_remembered() { deref(capture_bits_of(spacer)) }; - // Age everyone to the brink of promotion (power-on threshold: promote on - // the fourth survival — pinned by - // `test_copying_minor_promotes_survivor_on_fourth_survival`). + // Age everyone to the explicitly pinned promotion boundary: the fourth + // survival. This test exercises drain promotion at S=4, not power-on. for _ in 0..3 { let _ = gc_collect_minor(); } diff --git a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs index 68478b74a4..b6104a63f5 100644 --- a/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs +++ b/crates/perry-runtime/src/gc/tests/copying/survival_and_malloc.rs @@ -3,6 +3,8 @@ use super::*; #[test] fn test_copying_minor_promotes_survivor_on_fourth_survival() { let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); let child = young_leaf(); js_shadow_slot_set(0, ptr_bits(child)); @@ -53,6 +55,8 @@ fn test_copying_minor_preserves_old_page_accounting_for_defrag_policy() { pinned_header: std::ptr::null_mut(), }; let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); clear_marks(); clear_mark_seeds(); @@ -176,6 +180,8 @@ fn test_copying_minor_preserves_old_page_accounting_for_defrag_policy() { #[test] fn test_copying_minor_sticky_old_to_survivor_edge_promotes_on_fourth_cycle() { let _guard = CopyingNurseryTestGuard::new(0); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); let child = young_leaf(); let (old_arr, elements) = unsafe { alloc_old_test_array(1) }; unsafe { @@ -964,6 +970,8 @@ fn test_movable_regexp_evacuation_migrates_all_address_owned_state() { #[test] fn test_copied_minor_promotable_census_filtered_walk_matches_unfiltered() { let _guard = CopyingNurseryTestGuard::new(1); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); let child = young_leaf(); js_shadow_slot_set(0, ptr_bits(child)); @@ -1084,9 +1092,10 @@ fn nursery_regexp_that_dies_young_is_finalized_by_the_copied_minor() { /// in the second. On cc that difference is the whole finding — the aggregate /// clears the lock's 90 % bar while a fresh cohort survives at 74 %. /// -/// Shape: at the power-on threshold (promote on the 4th survival) two rooted -/// objects are introduced one cycle apart, so by the third minor the -/// from-survivor space holds one age-2 object and one age-1 object. +/// Shape: at an explicitly pinned threshold above 2 (promote on the 4th +/// survival) two rooted objects are introduced one cycle apart, so by the +/// third minor the from-survivor space holds one age-2 object and one age-1 +/// object. #[test] fn the_survivor_space_and_the_fresh_cohort_are_different_numbers_above_threshold_two() { // TWO shadow slots: the test needs two independently rooted objects @@ -1094,6 +1103,8 @@ fn the_survivor_space_and_the_fresh_cohort_are_different_numbers_above_threshold // classes at once. With one slot B is unrooted, dies immediately, and the // fresh-cohort number is trivially zero. let _guard = CopyingNurseryTestGuard::new(2); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); // Cycle 1: A enters the survivor space from Eden. The from-survivor space // was empty, so both numbers are zero and the cohort is all of nothing. @@ -1115,7 +1126,10 @@ fn the_survivor_space_and_the_fresh_cohort_are_different_numbers_above_threshold js_shadow_slot_set(1, ptr_bits(b)); let _ = gc_collect_minor(); let (_, _, survivor_live_2, first_round_2) = crate::gc::copying::test_last_cohort_split(); - assert!(survivor_live_2 > 0, "A must have come back out of the survivor space"); + assert!( + survivor_live_2 > 0, + "A must have come back out of the survivor space" + ); assert_eq!( survivor_live_2, first_round_2, "with a single generation resident the whole-space number IS the \ diff --git a/crates/perry-runtime/src/gc/tests/copying/weak_holder_registry.rs b/crates/perry-runtime/src/gc/tests/copying/weak_holder_registry.rs index 581e22d237..1ea175c046 100644 --- a/crates/perry-runtime/src/gc/tests/copying/weak_holder_registry.rs +++ b/crates/perry-runtime/src/gc/tests/copying/weak_holder_registry.rs @@ -242,6 +242,8 @@ fn test_full_weak_processing_work_is_independent_of_unrelated_heap_size() { #[test] fn test_registry_tracks_holder_across_three_moving_minors() { let _guard = CopyingNurseryTestGuard::new(3); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); let map = crate::weakref::js_weakmap_new(); let live_key = crate::object::js_object_alloc(0, 0); diff --git a/crates/perry-runtime/src/gc/tests/oldgen.rs b/crates/perry-runtime/src/gc/tests/oldgen.rs index 589adc3132..79b1453fd2 100644 --- a/crates/perry-runtime/src/gc/tests/oldgen.rs +++ b/crates/perry-runtime/src/gc/tests/oldgen.rs @@ -1328,6 +1328,8 @@ fn test_minor_skips_whole_heap_old_to_young_rebuild() { #[test] fn test_minor_preserves_old_to_young_edge_across_minors() { let _isolation = copying_nursery_isolation_lock(); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); let _barrier_guard = GeneratedWriteBarrierTestGuard::active(); reset_remembered_set(); diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs index 29217da550..cc65a5e865 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/hook_dispatch_handles.rs @@ -131,6 +131,8 @@ fn test_timer_tick_roots_callback_args_and_previous_context_across_hooks() { let _async_hook_guard = AsyncHookRuntimeTestGuard::new(); let _guard = CopyingNurseryTestGuard::new(0); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); gc_register_mutable_root_scanner(crate::async_hooks::scan_async_hooks_roots_mut); @@ -289,6 +291,8 @@ fn test_array_map_runtime_handles_survive_callback_copied_minor_gc() { #[test] fn test_map_materializers_runtime_handles_survive_copied_minor_gc() { let _guard = CopyingNurseryTestGuard::new(0); + let _tenuring = + crate::gc::tenuring::set_survivals_for_test(crate::gc::tenuring::GC_TENURING_SURVIVALS_MAX); let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); register_runtime_handle_root_scanner_for_tests(); diff --git a/crates/perry-runtime/src/gc/tests/support.rs b/crates/perry-runtime/src/gc/tests/support.rs index 130c0f7a08..d0cbecce02 100644 --- a/crates/perry-runtime/src/gc/tests/support.rs +++ b/crates/perry-runtime/src/gc/tests/support.rs @@ -414,9 +414,10 @@ pub(crate) struct CopyingNurseryTestGuard { } pub(super) fn reset_copying_nursery_runtime_test_state() { - // Age-sensitive tests assume the power-on tenuring threshold (promote at - // the 4th survival); pin it so a heavy-influx test earlier on the same - // thread cannot leak a lowered adaptive threshold in. + // Restore the adaptive policy to its power-on floor. Tests of mechanisms + // that require a particular promotion age pin it explicitly with + // `tenuring::set_survivals_for_test`, so a power-on policy change cannot + // silently change the mechanism they exercise. crate::gc::tenuring::reset_for_test(); // #7645: the young-pin latch is process-wide and monotone, so one // earlier pinning test would otherwise leave every later copying test From 1d4b10e5adf9e31f685ac87f938684f5e0587cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 04:06:11 +0200 Subject: [PATCH 12/20] chore(gc): classify the tenuring-lock holders for the root-holder gate LAST_COHORT_SPLIT (cfg(test), copying.rs) is test_only; SURVIVOR_ROUND_MEASURED (tenuring.rs) is a boolean, not a GC pointer. Inventory only; no code change. (cherry picked from commit 9c85be57e6d9a979252cfed6a999ec1f19b1a238) --- scripts/gc_runtime_root_holders.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index f1582b60c2..6da701028b 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -266,6 +266,18 @@ "verdict": "not_a_gc_pointer", "why": "Boolean census request latch, set by census_arm and consumed at full-sweep entry; contains no address or JS value." }, + { + "file": "crates/perry-runtime/src/gc/copying.rs", + "name": "LAST_COHORT_SPLIT", + "verdict": "test_only", + "why": "Declared under #[cfg(test)] at crates/perry-runtime/src/gc/copying.rs:1951; this Cell<(usize, usize, usize, usize)> holds only the byte counts of the last survivor-space/fresh-cohort split for the tenuring lock tests. It is absent from shipped binaries." + }, + { + "file": "crates/perry-runtime/src/gc/tenuring.rs", + "name": "SURVIVOR_ROUND_MEASURED", + "verdict": "not_a_gc_pointer", + "why": "Declared at crates/perry-runtime/src/gc/tenuring.rs:212; this Cell records whether any survivor round has been rated on this thread, gating the occupancy rule off its floor. A boolean, never a heap pointer." + }, { "file": "crates/perry-runtime/src/gc/census.rs", "name": "LABEL", From 99f4739de651f7486843da9e7f808bd9767bdcdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 14:13:21 +0200 Subject: [PATCH 13/20] fix(gc): release malloc borrow before verification Snapshot malloc-backed headers before running verifier callbacks so exact child validation can lazily rebuild the malloc registry without re-entering its RefCell borrow. Add a worker-thread copying-minor regression fixture. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 499b71628d53b76d87ba68c6f4a59ed597e6d82e) --- .../verify-evacuation-malloc-borrow.md | 5 ++ crates/perry-runtime/src/gc/tests/copying.rs | 1 + .../gc/tests/copying/verify_malloc_borrow.rs | 73 +++++++++++++++++++ crates/perry-runtime/src/gc/verify.rs | 64 ++++++++-------- 4 files changed, 109 insertions(+), 34 deletions(-) create mode 100644 changelog.d/verify-evacuation-malloc-borrow.md create mode 100644 crates/perry-runtime/src/gc/tests/copying/verify_malloc_borrow.rs diff --git a/changelog.d/verify-evacuation-malloc-borrow.md b/changelog.d/verify-evacuation-malloc-borrow.md new file mode 100644 index 0000000000..ac7d7aeb25 --- /dev/null +++ b/changelog.d/verify-evacuation-malloc-borrow.md @@ -0,0 +1,5 @@ +Fixed `PERRY_GC_VERIFY_EVACUATION=1` re-entering the thread-local malloc +registry while it validated malloc-backed object fields. Diagnostic heap walks +now snapshot malloc headers before validation, so copying-minor verification can +run with a populated side table instead of panicking on a nested `RefCell` +borrow. diff --git a/crates/perry-runtime/src/gc/tests/copying.rs b/crates/perry-runtime/src/gc/tests/copying.rs index 289829b854..88312209a4 100644 --- a/crates/perry-runtime/src/gc/tests/copying.rs +++ b/crates/perry-runtime/src/gc/tests/copying.rs @@ -7,6 +7,7 @@ mod pointer_publish_7154; mod promise_side_tables; mod promoted_remembered_7803; mod survival_and_malloc; +mod verify_malloc_borrow; mod weak_holder_registry; mod weak_semantics; use super::super::*; diff --git a/crates/perry-runtime/src/gc/tests/copying/verify_malloc_borrow.rs b/crates/perry-runtime/src/gc/tests/copying/verify_malloc_borrow.rs new file mode 100644 index 0000000000..eef5e6aa24 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copying/verify_malloc_borrow.rs @@ -0,0 +1,73 @@ +use super::*; + +#[test] +fn test_copied_minor_verify_evacuation_releases_malloc_registry_before_validation() { + std::thread::spawn(|| { + let _guard = CopyingNurseryTestGuard::new(2); + let _env_guard = VerifyEvacuationTestGuard::on(); + let _trigger_guard = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let malloc_child = gc_malloc( + std::mem::size_of::(), + GC_TYPE_CLOSURE, + ); + let malloc_parent = gc_malloc( + std::mem::size_of::() + 8, + GC_TYPE_CLOSURE, + ); + unsafe { + init_test_closure(malloc_child); + init_test_closure_with_one_capture(malloc_parent, ptr_bits(malloc_child as usize)); + } + js_shadow_slot_set(0, ptr_bits(malloc_parent as usize)); + let young = young_leaf(); + js_shadow_slot_set(1, ptr_bits(young)); + + // Make the exact-validation call do real registry work. The verifier's + // malloc-parent walk must snapshot the headers and release its borrow + // before this child lookup reaches `ensure_set_built`. Sabotage: put + // the verifier loop back inside `MALLOC_STATE.with(...borrow())`; the + // lookup's `borrow_mut()` then panics this worker thread. + deactivate_malloc_registry_for_tests(); + assert!( + MALLOC_STATE.with(|state| !state.borrow().objects.is_empty()), + "the malloc verifier fixture must populate the side table" + ); + assert!( + !malloc_registry_active_for_tests(), + "the exact-validation lookup must have a registry to rebuild" + ); + let rebuilds_before = MALLOC_REGISTRY_REBUILD_COUNT.with(|count| count.get()); + let stats = verify_old_to_young_edges_collect(); + let rebuilds_after = MALLOC_REGISTRY_REBUILD_COUNT.with(|count| count.get()); + assert!( + stats.checked_old_objects > 0 && stats.checked_old_to_young_edges > 0, + "the verifier must inspect the malloc parent and its malloc child" + ); + assert_eq!( + rebuilds_after, + rebuilds_before + 1, + "exact child validation must rebuild the non-empty malloc registry" + ); + + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + assert!( + trace.copying_nursery.copied_objects > 0, + "the worker-thread collection must copy a live nursery object" + ); + assert!( + trace.phase_us.contains_key("evacuation_verify"), + "the copied minor must run evacuation verification" + ); + assert!( + trace.phase_us.contains_key("old_young_edge_verify"), + "the copied minor must run the malloc-parent verifier" + ); + assert_ne!((js_shadow_slot_get(1) & POINTER_MASK) as usize, young); + assert!(malloc_user_ptr_tracked(malloc_parent)); + assert!(malloc_user_ptr_tracked(malloc_child)); + }) + .join() + .expect("worker-thread copying minor must complete without a RefCell borrow panic"); +} diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index 9d6bb0b26f..a6f6450c70 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -1,5 +1,16 @@ use super::*; +/// Snapshot malloc-backed headers before invoking a verifier callback. +/// +/// Slot validation can exact-check a candidate malloc pointer, which lazily +/// builds `MallocState.set` under a mutable borrow. Keeping even a shared +/// `MALLOC_STATE` borrow across that validation would make the diagnostic +/// verifier re-enter the same `RefCell` and panic instead of checking the heap. +#[inline] +fn malloc_headers_for_verification() -> Vec<*mut GcHeader> { + MALLOC_STATE.with(|state| state.borrow().objects.clone()) +} + /// Follow forwarding pointers for a word that may hold a heap reference, /// NaN-boxed or bare, preserving the form it was stored in. /// @@ -795,14 +806,11 @@ pub(super) fn verify_old_to_young_edges_collect() -> OldYoungEdgeVerifyStats { crate::arena::old_arena_walk_objects(|hp| unsafe { verify_old_young_parent_slots_covered(&snapshot, &mut stats, hp as *mut GcHeader); }); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &header in s.objects.iter() { - unsafe { - verify_old_young_parent_slots_covered(&snapshot, &mut stats, header); - } + for header in malloc_headers_for_verification() { + unsafe { + verify_old_young_parent_slots_covered(&snapshot, &mut stats, header); } - }); + } stats } @@ -1025,14 +1033,11 @@ pub(super) fn verify_array_pointer_slots_enumerated() -> ArraySlotEnumerationSta } verify_array_pointer_slots_enumerated_for(&mut stats, header); }); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &header in s.objects.iter() { - unsafe { - verify_array_pointer_slots_enumerated_for(&mut stats, header); - } + for header in malloc_headers_for_verification() { + unsafe { + verify_array_pointer_slots_enumerated_for(&mut stats, header); } - }); + } stats } @@ -1068,14 +1073,11 @@ pub(super) fn verify_marked_heap_no_unmarked_children() -> MarkInvariantVerifySt crate::arena::arena_walk_objects(|hp| unsafe { verify_marked_object_child_marks(&mut stats, hp as *mut GcHeader); }); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &header in s.objects.iter() { - unsafe { - verify_marked_object_child_marks(&mut stats, header); - } + for header in malloc_headers_for_verification() { + unsafe { + verify_marked_object_child_marks(&mut stats, header); } - }); + } if stats.missing_edges != 0 { panic_mark_invariant_verifier_failed(stats); } @@ -1091,14 +1093,11 @@ pub(super) fn verify_marked_heap_report_nonfatal(phase: &str) { crate::arena::arena_walk_objects(|hp| unsafe { verify_marked_object_child_marks(&mut stats, hp as *mut GcHeader); }); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &header in s.objects.iter() { - unsafe { - verify_marked_object_child_marks(&mut stats, header); - } + for header in malloc_headers_for_verification() { + unsafe { + verify_marked_object_child_marks(&mut stats, header); } - }); + } let tn = |t: u8| gc_type_info(t).map_or("?", |i| i.name); if let Some(m) = stats.first_missing { let (ptype, ctype) = unsafe { @@ -1453,12 +1452,9 @@ pub(super) fn verify_heap_objects(verifier: EvacuationVerifier<'_>) { verify_heap_object_fields(header, verifier, "heap fields"); }; crate::arena::arena_walk_objects(|hp| verify_one(hp as *mut GcHeader)); - MALLOC_STATE.with(|s| { - let s = s.borrow(); - for &h in s.objects.iter() { - verify_one(h); - } - }); + for header in malloc_headers_for_verification() { + verify_one(header); + } } pub(super) fn verify_evacuated_no_stale_forwarded_refs(verifier: EvacuationVerifier<'_>) { From 8c6f66f320a9331210ca0712f7b9b4004a15d504 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 14:15:08 +0200 Subject: [PATCH 14/20] docs: report evacuation verifier borrow fix Record the re-entrancy path, structural fix, disk-gated validation status, and the requested perrymaster campaign handoff. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 4295d390ddfa1cadeca376d32913366eff123212) --- .../codex/REPORT_verify_evacuation_borrow.md | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md diff --git a/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md b/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md new file mode 100644 index 0000000000..928231c932 --- /dev/null +++ b/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md @@ -0,0 +1,84 @@ +# `PERRY_GC_VERIFY_EVACUATION` malloc-borrow fix + +## Commit + +- Fix commit: `499b71628d53b76d87ba68c6f4a59ed597e6d82e` (`fix(gc): release malloc borrow before verification`) +- Base: `8b7dc3342b22fe6270739c8d51585c3d2cdfa618` (`origin/main` when the task started) + +## Re-entrancy path + +The observed copied-minor path is diagnostic-only: + +1. `crates/perry-runtime/src/gc/copying.rs:1516-1519` gates + `verify_old_to_young_edges_covered()` on + `gc_verify_evacuation_enabled()`. +2. Before this fix, `verify_old_to_young_edges_collect()` held a shared + `MALLOC_STATE` borrow while iterating `s.objects` at + `crates/perry-runtime/src/gc/verify.rs:798-805` (base commit lines). +3. Each candidate reached + `verify_old_young_parent_slots_covered()` → `visit_gc_rewrite_slots()` → + `verify_old_young_slot_covered()` at current + `crates/perry-runtime/src/gc/verify.rs:731-753` and `:690-700`. +4. A non-arena child reaches the exact membership check in + `remembered_child_needs_tracking()` at + `crates/perry-runtime/src/gc/barrier/mod.rs:1568-1585`. +5. That calls `gc_malloc_header_is_tracked()`, whose inner mutable borrow is + `crates/perry-runtime/src/gc/malloc.rs:526-529` (`borrow_mut()` is line 527) + and whose `ensure_set_built()` may rebuild from `objects` at `:508-515`. + +Because `MALLOC_STATE` is thread-local, the shared outer borrow and mutable +inner borrow are on the same collection thread. `RefCell` therefore panics +before the verifier can inspect the heap. The path is entered only when +`PERRY_GC_VERIFY_EVACUATION` is enabled; the later stale-forwarded-reference +walk is independently gated at `copying.rs:1596-1600`. + +I audited the production exact-membership callers (`barrier/mod.rs`, +`young_log.rs`, `native_handle.rs`, `timer.rs`, `path.rs`, `symbol/get.rs`, +`value/dyn_index.rs`, and `json/stringify.rs`). None invokes the helper while +holding a `MALLOC_STATE` borrow. The exact nested path above is verifier-only; +there is no non-diagnostic production re-entrancy to prioritize. A second +diagnostic (`PERRY_GC_VERIFY_CLASSIFIER`) can cause live classification from +some GC walks, but that is also diagnostic, not a production path. + +## Change + +`crates/perry-runtime/src/gc/verify.rs:10-12` now snapshots the malloc header +vector and releases the `MALLOC_STATE` borrow before any verifier callback. +Every verifier-owned malloc-object walk uses that helper, including the +old-to-young check, marked-child checks, array-slot enumeration, and the final +evacuation heap walk. Exact validation semantics remain unchanged: there is no +`try_borrow` fallback and no weakened pointer check. + +The named regression test is +`gc::tests::copying::verify_malloc_borrow::test_copied_minor_verify_evacuation_releases_malloc_registry_before_validation`. +It runs on a spawned worker thread, creates a malloc-backed closure parent and +malloc-backed child, makes the non-empty registry inactive, proves the exact +lookup rebuild count advances, then completes a copying minor with evacuation +verification enabled and asserts that an actual nursery object copied and both +verification phases ran. Sabotage is explicit: restore the malloc verifier loop +under `MALLOC_STATE.with(...borrow())`; the child lookup's `borrow_mut()` panics +the worker and makes `join().expect(...)` fail. + +## Validation + +Not run because the mandatory pre-Cargo check, `df -g /`, reported only **11 +GB available**, below the 12 GB floor. Per task instructions I did not invoke +Cargo and did not wait for disk capacity. Consequently these gates were not +run: + +- the named regression test; +- every test matching `verify_evacuation`; +- every test matching `malloc`; +- `cargo test -p perry-runtime --release --lib -- --test-threads=1`; +- `cargo build --release -p perry-runtime --features wasm-host`. + +Non-Cargo static checks completed: `rustfmt --check` on all edited Rust files +and `git diff --check`. The largest touched Rust file is 1,994 lines, below the +2,000-line repository cap. + +## Perrymaster request + +Relink on main's cache, then run `cc` with +`PERRY_GC_VERIFY_EVACUATION=1` for **4 turns**. The run must complete all four +turns, emit `[gc-verify]`-style verifier output proving the diagnostic was live, +and contain no `RefCell already borrowed` or other panic. From 2d514d266d0e6d98e87e1a46a84c5fe909d8f995 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 15:31:41 +0200 Subject: [PATCH 15/20] fix(gc): attribute stale evacuation pointers Name heap parents, layout slots, root scanners, and collection coverage when evacuation verification finds a stale forwarding alias. Emit a compact success witness with heap-walk and remembered-edge counts under GC diagnostics. Add focused failure-attribution and success-line regression tests. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 1ec9e0e8ac72cbce4098d86940b205f90effafd8) --- crates/perry-runtime/src/gc/copying.rs | 14 +- crates/perry-runtime/src/gc/cycle.rs | 7 +- crates/perry-runtime/src/gc/instruments.rs | 13 + crates/perry-runtime/src/gc/mod.rs | 2 + crates/perry-runtime/src/gc/roots.rs | 13 +- crates/perry-runtime/src/gc/tests/copying.rs | 1 + .../gc/tests/copying/verify_parent_context.rs | 104 +++++++ crates/perry-runtime/src/gc/verify.rs | 108 +++++-- crates/perry-runtime/src/gc/verify_diag.rs | 293 ++++++++++++++++++ 9 files changed, 519 insertions(+), 36 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/copying/verify_parent_context.rs create mode 100644 crates/perry-runtime/src/gc/verify_diag.rs diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index c3f6b368b5..85f04241b2 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1534,14 +1534,18 @@ pub(super) fn run_copied_minor_attempt( promoted_sticky.restore(); collector.sticky.extend(promoted_sticky); } - if gc_verify_evacuation_enabled() { + let old_young_edges = if gc_verify_evacuation_enabled() { let phase_start = trace_phase_start(trace); let old_young_edge_verifier = verify_old_to_young_edges_covered(); + let checked_edges = old_young_edge_verifier.checked_old_to_young_edges; trace_phase_record(trace, "old_young_edge_verify", phase_start); if let Some(trace) = trace.as_mut() { trace.old_young_edge_verifier = old_young_edge_verifier; } - } + checked_edges + } else { + 0 + }; // #7803: PERRY_GC_NATIVE_SLOT_VERIFY=1 — abort on the cycle that leaves a // native slot naming from-space, instead of many cycles later at the // pin-latch. Placed after every rewrite pass, before the from-space flip. @@ -1617,7 +1621,11 @@ pub(super) fn run_copied_minor_attempt( if gc_verify_evacuation_enabled() { let phase_start = trace_phase_start(trace); let valid_ptrs = build_valid_pointer_set(); - verify_evacuated_no_stale_forwarded_refs(EvacuationVerifier::copying_minor(&valid_ptrs)); + let context = begin_evacuation_verify_cycle(_trigger_kind, Some(&snapshot)); + let stats = verify_evacuated_no_stale_forwarded_refs( + EvacuationVerifier::copying_minor(&valid_ptrs).with_context(context), + ); + report_evacuation_success(context, stats, old_young_edges); trace_phase_record(trace, "evacuation_verify", phase_start); } diff --git a/crates/perry-runtime/src/gc/cycle.rs b/crates/perry-runtime/src/gc/cycle.rs index 66777e520a..a8e6eee6af 100644 --- a/crates/perry-runtime/src/gc/cycle.rs +++ b/crates/perry-runtime/src/gc/cycle.rs @@ -1411,9 +1411,10 @@ impl GcCycleState { trace_phase_record(&mut self.trace, "reference_rewrite", phase_start); if gc_verify_evacuation_enabled() { let phase_start = trace_phase_start(&self.trace); - verify_evacuated_no_stale_forwarded_refs(EvacuationVerifier::all_forwarded( - valid_ptrs, - )); + let context = begin_evacuation_verify_cycle(self.trigger_kind, None); + verify_evacuated_no_stale_forwarded_refs( + EvacuationVerifier::all_forwarded(valid_ptrs).with_context(context), + ); trace_phase_record(&mut self.trace, "evacuation_verify", phase_start); } let released = diff --git a/crates/perry-runtime/src/gc/instruments.rs b/crates/perry-runtime/src/gc/instruments.rs index 267a8bd1d5..da4e7bb3ab 100644 --- a/crates/perry-runtime/src/gc/instruments.rs +++ b/crates/perry-runtime/src/gc/instruments.rs @@ -9,6 +9,7 @@ //! //! Process-global rather than thread-local: the report is about the run. +use std::cell::Cell; use std::sync::atomic::{AtomicU64, Ordering}; static COPYING_MINORS: AtomicU64 = AtomicU64::new(0); @@ -63,6 +64,10 @@ static INCREMENTAL_CYCLE_STARTS: AtomicU64 = AtomicU64::new(0); static INCREMENTAL_STEPS: AtomicU64 = AtomicU64::new(0); static INCREMENTAL_COMPLETIONS: AtomicU64 = AtomicU64::new(0); +crate::perry_thread_local! { + static THREAD_INCREMENTAL_COMPLETIONS: Cell = const { Cell::new(0) }; +} + /// A budgeted (incremental) cycle was STARTED. #[inline] pub(crate) fn note_incremental_cycle_start() { @@ -82,6 +87,14 @@ pub(crate) fn note_incremental_step() { #[inline] pub(crate) fn note_incremental_completion() { INCREMENTAL_COMPLETIONS.fetch_add(1, Ordering::Relaxed); + THREAD_INCREMENTAL_COMPLETIONS.with(|count| count.set(count.get().saturating_add(1))); +} + +/// Budgeted cycles completed on the collection thread. Failure diagnostics +/// compare this with the value at the preceding verified minor, rather than +/// using the process-wide count (which would mix independent agent heaps). +pub(crate) fn incremental_completions_on_current_thread() -> u64 { + THREAD_INCREMENTAL_COMPLETIONS.with(Cell::get) } /// Budgeted incremental cycles started in this process. diff --git a/crates/perry-runtime/src/gc/mod.rs b/crates/perry-runtime/src/gc/mod.rs index 64409430cb..13ae921a77 100644 --- a/crates/perry-runtime/src/gc/mod.rs +++ b/crates/perry-runtime/src/gc/mod.rs @@ -213,6 +213,8 @@ pub(crate) use cycle_malloc_trim::{ reset_test_malloc_trim_executed_count, test_malloc_trim_executed_count, }; mod verify; +mod verify_diag; +use verify_diag::*; /// #7035: whole-heap from-space scan — verification that does NOT depend on /// the rewrite pass own root enumeration. Debug-only diff --git a/crates/perry-runtime/src/gc/roots.rs b/crates/perry-runtime/src/gc/roots.rs index c40c29cf9c..4b6483f14d 100644 --- a/crates/perry-runtime/src/gc/roots.rs +++ b/crates/perry-runtime/src/gc/roots.rs @@ -908,7 +908,7 @@ impl<'a> RuntimeRootVisitor<'a> { } RuntimeRootVisitMode::Verify { verifier, surface } => { if let Some(new_bits) = verifier.stale_nanboxed_value(bits) { - panic_stale_forwarded_reference(surface, 0, bits, new_bits); + panic_stale_forwarded_reference(*verifier, surface, 0, bits, new_bits); } None } @@ -937,7 +937,7 @@ impl<'a> RuntimeRootVisitor<'a> { RuntimeRootVisitMode::Rewrite { valid_ptrs } => try_rewrite_value(bits, valid_ptrs), RuntimeRootVisitMode::Verify { verifier, surface } => { if let Some(new_bits) = verifier.stale_value(bits) { - panic_stale_forwarded_reference(surface, 0, bits, new_bits); + panic_stale_forwarded_reference(*verifier, surface, 0, bits, new_bits); } None } @@ -975,6 +975,7 @@ impl<'a> RuntimeRootVisitor<'a> { RuntimeRootVisitMode::Verify { verifier, surface } => { if let Some(new_addr) = verifier.stale_raw_addr(addr) { panic_stale_forwarded_reference( + *verifier, surface, 0, copy_tag | (addr as u64 & POINTER_MASK), @@ -1002,7 +1003,13 @@ impl<'a> RuntimeRootVisitor<'a> { RuntimeRootVisitMode::CopyingRewrite { collector } => collector.rewrite_raw_addr(addr), RuntimeRootVisitMode::Verify { verifier, surface } => { if let Some(new_addr) = verifier.stale_raw_addr(addr) { - panic_stale_forwarded_reference(surface, 0, addr as u64, new_addr as u64); + panic_stale_forwarded_reference( + *verifier, + surface, + 0, + addr as u64, + new_addr as u64, + ); } None } diff --git a/crates/perry-runtime/src/gc/tests/copying.rs b/crates/perry-runtime/src/gc/tests/copying.rs index 88312209a4..81e1e3baac 100644 --- a/crates/perry-runtime/src/gc/tests/copying.rs +++ b/crates/perry-runtime/src/gc/tests/copying.rs @@ -8,6 +8,7 @@ mod promise_side_tables; mod promoted_remembered_7803; mod survival_and_malloc; mod verify_malloc_borrow; +mod verify_parent_context; mod weak_holder_registry; mod weak_semantics; use super::super::*; diff --git a/crates/perry-runtime/src/gc/tests/copying/verify_parent_context.rs b/crates/perry-runtime/src/gc/tests/copying/verify_parent_context.rs new file mode 100644 index 0000000000..69b1da1563 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/copying/verify_parent_context.rs @@ -0,0 +1,104 @@ +use super::*; + +fn panic_message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => (*message).to_owned(), + Err(_) => "non-string panic payload".to_owned(), + }, + } +} + +#[test] +fn stale_forwarded_reference_panic_names_parent_slot_and_coverage() { + let message = std::thread::spawn(|| { + let failure = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = CopyingNurseryTestGuard::new(1); + let _verify = VerifyEvacuationTestGuard::on(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + + let child = young_leaf(); + let (_parent, field) = unsafe { alloc_old_test_object(1) }; + unsafe { + // Deliberate sabotage: publish the old -> young field without + // its write barrier, so neither the page nor the owner enters + // the remembered snapshot this minor walks. + *field = ptr_bits(child); + } + js_shadow_slot_set(0, ptr_bits(child)); + + let _ = collect_minor_trace(GcTriggerKind::Direct); + panic!("the sabotaged parent must leave a stale forwarded field"); + })); + panic_message(failure.expect_err("the evacuation verifier must reject the stale field")) + }) + .join() + .expect("worker thread must return the caught verifier panic"); + + for field in [ + "parent_type=", + "parent_space=old_page", + "slot_index=0", + "remembered=no", + "child_type=", + "minor=", + "trigger=", + ] { + assert!( + message.contains(field), + "verifier panic omitted {field:?}: {message}" + ); + } +} + +#[test] +fn evacuation_verifier_pass_line_counts_parents_and_slots() { + const CHILD_ENV: &str = "PERRY_TEST_VERIFY_PASS_LINE_CHILD"; + let thread = std::thread::current(); + let name = thread.name().expect("libtest must name the test thread"); + if std::env::var(CHILD_ENV).ok().as_deref() == Some(name) { + let _guard = CopyingNurseryTestGuard::new(1); + let _verify = VerifyEvacuationTestGuard::on(); + let _trigger = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + js_shadow_slot_set(0, ptr_bits(young_leaf())); + let trace = collect_minor_trace(GcTriggerKind::Direct); + assert_copied_minor_trace(&trace, true, CopiedMinorFallbackReason::None, false); + println!("evacuation verifier pass-line child completed"); + return; + } + + let output = std::process::Command::new(std::env::current_exe().expect("current test binary")) + .args(["--exact", name, "--nocapture", "--test-threads=1"]) + .env(CHILD_ENV, name) + .env("PERRY_GC_DIAG", "1") + .output() + .expect("launch isolated diagnostic witness"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success() && stdout.contains("evacuation verifier pass-line child completed"), + "diagnostic witness failed: {}\nstdout:\n{stdout}\nstderr:\n{stderr}", + output.status + ); + + let lines: Vec<_> = stderr + .lines() + .filter(|line| line.starts_with("[gc-verify] minor=") && line.contains(" evacuation_ok ")) + .collect(); + assert_eq!( + lines.len(), + 1, + "expected exactly one copied-minor verifier pass line; stderr:\n{stderr}" + ); + let parents = lines[0] + .split_whitespace() + .find_map(|word| word.strip_prefix("parents=")) + .and_then(|value| value.parse::().ok()) + .expect("pass line must contain a numeric parents count"); + assert!( + parents > 0, + "pass line must prove the heap walk ran: {}", + lines[0] + ); +} diff --git a/crates/perry-runtime/src/gc/verify.rs b/crates/perry-runtime/src/gc/verify.rs index a6f6450c70..732354f9fe 100644 --- a/crates/perry-runtime/src/gc/verify.rs +++ b/crates/perry-runtime/src/gc/verify.rs @@ -57,8 +57,10 @@ pub(super) fn try_rewrite_raw_addr(ptr_addr: usize, valid_ptrs: &ValidPointerSet /// Carry that distinction through every verifier surface, including FFI roots. #[derive(Clone, Copy)] pub(super) struct EvacuationVerifier<'a> { - valid_ptrs: &'a ValidPointerSet, + pub(super) valid_ptrs: &'a ValidPointerSet, copying_minor: bool, + pub(super) context: Option>, + pub(super) parent_header: Option<*mut GcHeader>, } impl<'a> EvacuationVerifier<'a> { @@ -66,6 +68,8 @@ impl<'a> EvacuationVerifier<'a> { Self { valid_ptrs, copying_minor: false, + context: None, + parent_header: None, } } @@ -74,9 +78,21 @@ impl<'a> EvacuationVerifier<'a> { Self { valid_ptrs, copying_minor: true, + context: None, + parent_header: None, } } + pub(super) fn with_context(mut self, context: EvacuationVerifyCycleContext<'a>) -> Self { + self.context = Some(context); + self + } + + fn with_parent(mut self, parent_header: *mut GcHeader) -> Self { + self.parent_header = Some(parent_header); + self + } + pub(super) fn stale_raw_addr(self, addr: usize) -> Option { follow_forwarding_raw_addr(addr, self.valid_ptrs, |source, target| { if !self.copying_minor { @@ -155,14 +171,13 @@ fn follow_forwarding_raw_addr( #[cold] pub(super) fn panic_stale_forwarded_reference( + verifier: EvacuationVerifier<'_>, surface: &str, slot_addr: usize, old_bits: u64, new_bits: u64, ) -> ! { - panic!( - "gc evacuation verification failed: stale forwarded pointer in {surface}: slot=0x{slot_addr:x} old=0x{old_bits:x} forwarded_to=0x{new_bits:x}" - ); + panic_stale_forwarded_reference_detailed(verifier, surface, slot_addr, old_bits, new_bits); } /// In-place rewrite helper: read `*slot`, run it through @@ -183,7 +198,7 @@ pub(super) unsafe fn verify_slot( ) { let bits = *slot; if let Some(new_bits) = verifier.stale_value(bits) { - panic_stale_forwarded_reference(surface, slot as usize, bits, new_bits); + panic_stale_forwarded_reference(verifier, surface, slot as usize, bits, new_bits); } } @@ -1205,15 +1220,25 @@ pub(super) unsafe fn verify_heap_object_fields( header: *mut GcHeader, verifier: EvacuationVerifier<'_>, surface: &'static str, -) { +) -> usize { let flags = (*header).gc_flags; if flags & GC_FLAG_FORWARDED != 0 { - return; - } - visit_gc_rewrite_slots(header, |slot| unsafe { - slot.record_layout_read(); - verify_slot(slot.slot as *const u64, verifier, surface); + return 0; + } + let verifier = verifier.with_parent(header); + let mut slots = 0usize; + visit_gc_rewrite_slot_descriptors(header, |descriptor| unsafe { + slots = slots.saturating_add(match descriptor { + GcMutableSlotDescriptor::Slot(_) => 1, + GcMutableSlotDescriptor::Range { range, .. } => range.slot_count(), + GcMutableSlotDescriptor::PointerFreeRange(_) => 0, + }); + descriptor.visit_slots(&mut |slot| { + slot.record_layout_read(); + verify_slot(slot.slot as *const u64, verifier, surface); + }); }); + slots } /// Walk every live (MARKED, non-FORWARDED) object on the heap and @@ -1356,17 +1381,18 @@ pub(super) fn verify_mutable_root_slots(verifier: EvacuationVerifier<'_>) { MutableRootSlotKind::NativeStack => "native stack-map roots", MutableRootSlotKind::GlobalRoot => "global roots", }; - panic_stale_forwarded_reference(surface, slot.ptr as usize, bits, new_bits); + panic_stale_forwarded_reference(verifier, surface, slot.ptr as usize, bits, new_bits); } }); } pub(super) fn verify_mutable_registered_roots(verifier: EvacuationVerifier<'_>) { let scanners: Vec = MUTABLE_ROOT_SCANNERS.with(|s| s.borrow().clone()); - let mut visitor = RuntimeRootVisitor::for_verify(verifier, "runtime mutable root scanner"); for entry in scanners { + let mut visitor = RuntimeRootVisitor::for_verify(verifier, entry.name); (entry.scanner)(&mut visitor); } + let mut visitor = RuntimeRootVisitor::for_verify(verifier, "ffi mutable root scanner"); visit_ffi_mutable_registered_roots(&mut visitor); } @@ -1376,7 +1402,7 @@ pub(super) fn verify_copy_only_scanner_bits( surface: &'static str, ) { if let Some(new_bits) = verifier.stale_nanboxed_value(bits) { - panic_stale_forwarded_reference(surface, 0, bits, new_bits); + panic_stale_forwarded_reference(verifier, surface, 0, bits, new_bits); } } @@ -1407,15 +1433,35 @@ pub(super) fn verify_copy_only_registered_roots(verifier: EvacuationVerifier<'_> pub(super) fn verify_remembered_dirty_ranges(verifier: EvacuationVerifier<'_>) { let snapshot = remembered_dirty_snapshot(); let mut stats = RememberedSetTraceStats::default(); - let mut verify_dirty_slot = |slot: *mut u64, _stats: &mut RememberedSetTraceStats| unsafe { - verify_slot(slot as *const u64, verifier, "remembered dirty ranges"); + let mut seen_headers = crate::fast_hash::new_ptr_hash_set(); + let mut verify_header = |header: *mut GcHeader| unsafe { + if !seen_headers.insert(header as usize) { + return; + } + let parent_verifier = verifier.with_parent(header); + let mut verify_dirty_slot = |slot: *mut u64, _stats: &mut RememberedSetTraceStats| { + verify_slot( + slot as *const u64, + parent_verifier, + "remembered dirty ranges", + ); + }; + scan_dirty_header_once( + header, + &snapshot.dirty_pages, + verifier.valid_ptrs, + &mut stats, + &mut verify_dirty_slot, + ); }; - scan_remembered_dirty_slot_ranges( - &snapshot, - verifier.valid_ptrs, - &mut stats, - &mut verify_dirty_slot, - ); + if !snapshot.dirty_old_pages.is_empty() { + crate::arena::old_arena_walk_objects_on_pages(&snapshot.dirty_old_pages, |header| { + verify_header(header as *mut GcHeader); + }); + } + for &(_, header) in &snapshot.external_dirty_entries { + verify_header(header as *mut GcHeader); + } for header_addr in snapshot.fallback_headers { let user_ptr = header_addr + GC_HEADER_SIZE; @@ -1432,8 +1478,9 @@ pub(super) fn verify_remembered_dirty_ranges(verifier: EvacuationVerifier<'_>) { } } -pub(super) fn verify_heap_objects(verifier: EvacuationVerifier<'_>) { - let verify_one = |header: *mut GcHeader| unsafe { +pub(super) fn verify_heap_objects(verifier: EvacuationVerifier<'_>) -> EvacuationVerifyStats { + let mut stats = EvacuationVerifyStats::default(); + let mut verify_one = |header: *mut GcHeader| unsafe { let flags = (*header).gc_flags; if flags & GC_FLAG_FORWARDED != 0 { return; @@ -1449,20 +1496,27 @@ pub(super) fn verify_heap_objects(verifier: EvacuationVerifier<'_>) { return; } } - verify_heap_object_fields(header, verifier, "heap fields"); + stats.parents = stats.parents.saturating_add(1); + stats.slots = + stats + .slots + .saturating_add(verify_heap_object_fields(header, verifier, "heap fields")); }; crate::arena::arena_walk_objects(|hp| verify_one(hp as *mut GcHeader)); for header in malloc_headers_for_verification() { verify_one(header); } + stats } -pub(super) fn verify_evacuated_no_stale_forwarded_refs(verifier: EvacuationVerifier<'_>) { +pub(super) fn verify_evacuated_no_stale_forwarded_refs( + verifier: EvacuationVerifier<'_>, +) -> EvacuationVerifyStats { verify_mutable_root_slots(verifier); verify_mutable_registered_roots(verifier); verify_copy_only_registered_roots(verifier); verify_remembered_dirty_ranges(verifier); - verify_heap_objects(verifier); + verify_heap_objects(verifier) } /// Top-level Phase C4b-γ-2 entry: rewrite every reference site we diff --git a/crates/perry-runtime/src/gc/verify_diag.rs b/crates/perry-runtime/src/gc/verify_diag.rs new file mode 100644 index 0000000000..21274291d4 --- /dev/null +++ b/crates/perry-runtime/src/gc/verify_diag.rs @@ -0,0 +1,293 @@ +//! Failure-only attribution for the evacuation verifier. +//! +//! The verifier's passing slot closure deliberately stays free of the page, +//! registry, type-name and descriptor re-walks below. A stale edge is already +//! fatal, so that path can spend the extra work needed to name the owner and +//! the collection coverage which failed to visit it. + +use super::*; +use std::cell::Cell; + +#[derive(Clone, Copy)] +pub(super) struct EvacuationVerifyCycleContext<'a> { + pub(super) minor: u64, + pub(super) trigger: GcTriggerKind, + pub(super) after_budgeted_step: bool, + pub(super) dirty_snapshot: Option<&'a RememberedDirtySnapshot>, +} + +crate::perry_thread_local! { + static VERIFY_MINOR_ORDINAL: Cell = const { Cell::new(0) }; + static LAST_VERIFY_BUDGETED_COMPLETIONS: Cell = const { Cell::new(0) }; +} + +pub(super) fn begin_evacuation_verify_cycle( + trigger: GcTriggerKind, + dirty_snapshot: Option<&RememberedDirtySnapshot>, +) -> EvacuationVerifyCycleContext<'_> { + let minor = VERIFY_MINOR_ORDINAL.with(|ordinal| { + let next = ordinal.get().saturating_add(1); + ordinal.set(next); + next + }); + let completed = super::instruments::incremental_completions_on_current_thread(); + let after_budgeted_step = LAST_VERIFY_BUDGETED_COMPLETIONS.with(|last| { + let after = completed > last.get(); + last.set(completed); + after + }); + EvacuationVerifyCycleContext { + minor, + trigger, + after_budgeted_step, + dirty_snapshot, + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(super) struct EvacuationVerifyStats { + pub(super) parents: usize, + pub(super) slots: usize, +} + +fn yes_no(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + +fn type_name(obj_type: u8) -> &'static str { + gc_type_info(obj_type).map_or("unknown", |info| info.name) +} + +fn decoded_addr(bits: u64) -> usize { + decode_root_word(bits) + .map(|word| word.addr()) + .unwrap_or((bits & POINTER_MASK) as usize) +} + +unsafe fn object_type_at(verifier: EvacuationVerifier<'_>, addr: usize) -> &'static str { + if addr <= GC_HEADER_SIZE || !verifier.valid_ptrs.contains(&addr) { + return "unknown"; + } + type_name((*header_from_user_ptr(addr as *const u8)).obj_type) +} + +unsafe fn object_space(header: *mut GcHeader, user: usize) -> &'static str { + let flags = (*header).gc_flags; + if flags & GC_FLAG_PINNED != 0 { + return "pinned"; + } + if flags & GC_FLAG_ARENA == 0 { + return "malloc"; + } + match crate::arena::classify_heap_space(user) { + crate::arena::HeapSpace::PromotedYoung => "promoted_in_place_this_cycle", + crate::arena::HeapSpace::NurseryEden => "nursery_from", + space if space == crate::arena::active_survivor_space() => "nursery_from", + space if space == crate::arena::inactive_survivor_space() => "nursery_to", + crate::arena::HeapSpace::Old | crate::arena::HeapSpace::Longlived => "old_page", + crate::arena::HeapSpace::Unknown + | crate::arena::HeapSpace::Survivor0 + | crate::arena::HeapSpace::Survivor1 => "old_page", + } +} + +unsafe fn forwarded_target_space(verifier: EvacuationVerifier<'_>, addr: usize) -> &'static str { + if addr <= GC_HEADER_SIZE || !verifier.valid_ptrs.contains(&addr) { + return "old_page"; + } + object_space(header_from_user_ptr(addr as *const u8), addr) +} + +fn layout_visitor_name(kind: GcLayoutSlotKind) -> &'static str { + match kind { + GcLayoutSlotKind::None => "GcMutableSlotDescriptor", + GcLayoutSlotKind::ArrayElements => "ArrayElements", + GcLayoutSlotKind::ObjectFields => "ObjectFields", + GcLayoutSlotKind::RegExpFields => "RegExpFields", + GcLayoutSlotKind::ClosureCaptures => "ClosureCaptures", + GcLayoutSlotKind::ObjectMeta => "ObjectMeta", + } +} + +fn rewrite_visitor_name(kind: GcRewriteDescriptorKind) -> &'static str { + match kind { + GcRewriteDescriptorKind::Leaf => "GcMutableSlotDescriptor", + GcRewriteDescriptorKind::Array => "ArrayFields", + GcRewriteDescriptorKind::Object => "ObjectSideFields", + GcRewriteDescriptorKind::RegExp => "RegExpFields", + GcRewriteDescriptorKind::Closure => "ClosureSideFields", + GcRewriteDescriptorKind::Promise => "PromiseFields", + GcRewriteDescriptorKind::Error => "ErrorFields", + GcRewriteDescriptorKind::Map => "MapEntries", + GcRewriteDescriptorKind::LazyArray => "LazyArrayFields", + GcRewriteDescriptorKind::Set => "SetElements", + GcRewriteDescriptorKind::NativeTypedView => "NativeTypedViewFields", + GcRewriteDescriptorKind::NativePodView => "NativePodViewFields", + GcRewriteDescriptorKind::ObjectMeta => "ObjectMeta", + GcRewriteDescriptorKind::MetaOnly => "MetaOnlyFields", + } +} + +unsafe fn descriptor_slot_index( + descriptor: GcMutableSlotDescriptor, + wanted: usize, +) -> Option { + match descriptor { + GcMutableSlotDescriptor::Slot(slot) => (slot.slot as usize == wanted).then_some(0), + GcMutableSlotDescriptor::Range { range, .. } => { + let start = range.slots() as usize; + let offset = wanted.checked_sub(start)?; + (offset % std::mem::size_of::() == 0 + && offset / std::mem::size_of::() < range.slot_count()) + .then_some(offset / std::mem::size_of::()) + } + GcMutableSlotDescriptor::PointerFreeRange(_) => None, + } +} + +unsafe fn descriptor_slot_count(descriptor: GcMutableSlotDescriptor) -> usize { + match descriptor { + GcMutableSlotDescriptor::Slot(_) => 1, + GcMutableSlotDescriptor::Range { range, .. } => range.slot_count(), + GcMutableSlotDescriptor::PointerFreeRange(_) => 0, + } +} + +unsafe fn describe_parent_slot( + header: *mut GcHeader, + slot_addr: usize, +) -> (Option, &'static str) { + let layout_kind = gc_type_layout_slot_kind((*header).obj_type); + let mut layout_match = None; + let mut layout_base = 0usize; + visit_gc_layout_slot_descriptors(header, &mut |descriptor| { + if layout_match.is_none() { + layout_match = descriptor_slot_index(descriptor, slot_addr) + .map(|index| layout_base.saturating_add(index)); + } + layout_base = layout_base.saturating_add(descriptor_slot_count(descriptor)); + }); + if let Some(index) = layout_match { + return (Some(index), layout_visitor_name(layout_kind)); + } + + let rewrite_kind = gc_type_rewrite_descriptor_kind((*header).obj_type); + let mut rewrite_match = None; + let mut rewrite_base = 0usize; + visit_gc_rewrite_slot_descriptors(header, |descriptor| { + if rewrite_match.is_none() { + rewrite_match = descriptor_slot_index(descriptor, slot_addr) + .map(|index| rewrite_base.saturating_add(index)); + } + rewrite_base = rewrite_base.saturating_add(descriptor_slot_count(descriptor)); + }); + (rewrite_match, rewrite_visitor_name(rewrite_kind)) +} + +#[cold] +pub(super) fn panic_stale_forwarded_reference_detailed( + verifier: EvacuationVerifier<'_>, + surface: &str, + slot_addr: usize, + old_bits: u64, + new_bits: u64, +) -> ! { + let surface_token = surface + .chars() + .map(|ch| if ch.is_ascii_whitespace() { '_' } else { ch }) + .collect::(); + let old_addr = decoded_addr(old_bits); + let new_addr = decoded_addr(new_bits); + let (child_type, child_space) = unsafe { + ( + object_type_at(verifier, old_addr).or_else_unknown(object_type_at(verifier, new_addr)), + forwarded_target_space(verifier, new_addr), + ) + }; + let (minor, trigger, after_budgeted_step) = verifier.context.map_or_else( + || ("n/a".to_owned(), "n/a".to_owned(), "n/a"), + |context| { + ( + context.minor.to_string(), + format!("{:?}", context.trigger), + yes_no(context.after_budgeted_step), + ) + }, + ); + + if let Some(parent_header) = verifier.parent_header { + unsafe { + let parent = (parent_header as *mut u8).add(GC_HEADER_SIZE) as usize; + let parent_space = object_space(parent_header, parent); + let (slot_index, visitor) = describe_parent_slot(parent_header, slot_addr); + let slot_index = slot_index.map_or_else(|| "n/a".to_owned(), |i| i.to_string()); + let coverage_expected = matches!( + parent_space, + "old_page" | "malloc" | "promoted_in_place_this_cycle" + ); + let (remembered, dirty_snapshot) = if coverage_expected { + let remembered_snapshot = remembered_dirty_snapshot(); + let remembered = old_young_slot_covered( + &remembered_snapshot, + parent_header as usize, + slot_addr as *mut u64, + ); + let dirty = verifier + .context + .and_then(|context| context.dirty_snapshot) + .map(|snapshot| { + old_young_slot_covered( + snapshot, + parent_header as usize, + slot_addr as *mut u64, + ) + }); + ( + yes_no(remembered), + dirty.map_or("n/a(no_cycle_snapshot)", yes_no), + ) + } else { + ("n/a(nursery_parent)", "n/a(nursery_parent)") + }; + panic!( + "gc evacuation verification failed: stale forwarded pointer in {surface}: surface={surface_token} parent=0x{parent:x} parent_type={} parent_space={parent_space} slot=0x{slot_addr:x} slot_index={slot_index} visitor={visitor} old=0x{old_bits:x} forwarded_to=0x{new_bits:x} child_type={child_type} child_space={child_space} remembered={remembered} young_logged=n/a(heap_parent_uses_remembered_set) dirty_snapshot={dirty_snapshot} minor={minor} trigger={trigger} after_budgeted_step={after_budgeted_step}", + type_name((*parent_header).obj_type), + ); + } + } + + panic!( + "gc evacuation verification failed: stale forwarded pointer in {surface}: surface={surface_token} parent=n/a(root) parent_type=n/a(root) parent_space=n/a(root) slot=0x{slot_addr:x} slot_index=n/a(root) visitor={surface_token} old=0x{old_bits:x} forwarded_to=0x{new_bits:x} child_type={child_type} child_space={child_space} remembered=n/a(root) young_logged=n/a(root_scanner_does_not_expose_owner_key) dirty_snapshot=n/a(root) minor={minor} trigger={trigger} after_budgeted_step={after_budgeted_step}" + ); +} + +trait UnknownTypeFallback { + fn or_else_unknown(self, fallback: Self) -> Self; +} + +impl UnknownTypeFallback for &'static str { + fn or_else_unknown(self, fallback: Self) -> Self { + if self == "unknown" { + fallback + } else { + self + } + } +} + +pub(super) fn report_evacuation_success( + context: EvacuationVerifyCycleContext<'_>, + stats: EvacuationVerifyStats, + old_young_edges: usize, +) { + if gc_diag_enabled() { + eprintln!( + "[gc-verify] minor={} evacuation_ok parents={} slots={} old_young_edges={old_young_edges}", + context.minor, stats.parents, stats.slots, + ); + } +} From 07eeb083778cc3ecb048bd87f5b8a06a02b36905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 15:34:10 +0200 Subject: [PATCH 16/20] docs(gc): report verifier parent attribution Record VF2 field derivation, covered failure sites, passing-path cost, focused test evidence, disk-limited gates, and the perrymaster campaign request. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 38229ccb0698f0512935b234fba5b79a091daa6d) --- .../codex/REPORT_verify_evacuation_borrow.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md b/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md index 928231c932..0fb5e6201b 100644 --- a/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md +++ b/cc-perf-campaign/codex/REPORT_verify_evacuation_borrow.md @@ -82,3 +82,147 @@ Relink on main's cache, then run `cc` with `PERRY_GC_VERIFY_EVACUATION=1` for **4 turns**. The run must complete all four turns, emit `[gc-verify]`-style verifier output proving the diagnostic was live, and contain no `RefCell already borrowed` or other panic. + +## VF2: the parent names itself + +### Commit + +- Implementation commit: `1ec9e0e8ac72cbce4098d86940b205f90effafd8` + (`fix(gc): attribute stale evacuation pointers`) +- Extended branch base: `4295d390d` (the first verifier report commit) + +### Failure line and field derivation + +Every stale-forwarding panic remains one physical line and now carries the +same field vocabulary for heap slots, roots, and runtime side-table scanners: + +- `parent` is the user address (`header + GC_HEADER_SIZE`). `parent_type` is + `gc_type_info(header.obj_type).name`. Root-owned slots use `n/a(root)` because + their scanner has no GC parent header. +- `parent_space` first reads `GC_FLAG_PINNED` (`pinned`), then + `GC_FLAG_ARENA` (clear means `malloc`). Arena parents use the current arena + block class: `PromotedYoung` means `promoted_in_place_this_cycle`; Eden and + the active survivor half mean `nursery_from`; the inactive survivor half + means `nursery_to`; Old and Longlived mean `old_page`. The transient + `PromotedYoung` block class is the collector's this-cycle promotion set, so + no historical/guessed tenuring classification is used. +- `slot_index` is the cumulative zero-based pointer-slot index within the + parent. It is derived only after failure by replaying the layout descriptors. + `visitor` is the matching `GcLayoutSlotKind` or, for side fields absent from + the layout walk, the matching `GcRewriteDescriptorKind` (for example + `GcMutableSlotDescriptor`, `ArrayElements`, `ObjectFields`, `RegExpFields`, + `ClosureCaptures`, `ObjectMeta`, or the corresponding side-field family). +- `child_type` reads `obj_type` from the still-valid forwarding source header; + if that source is not in the exact pointer census, it falls back to the + forwarded-to header. `child_space` classifies `forwarded_to` with the same + `GC_FLAG_PINNED` / `GC_FLAG_ARENA` checks and arena block classes. Thus + `nursery_to` is a survivor copy and `old_page` is a promoted copy. +- `remembered` re-snapshots the live remembered set on the cold failure path + and applies `old_young_slot_covered`, including an external `(page, owner)` + entry when the slot is outside its parent. `dirty_snapshot` tests the exact + pre-collection `RememberedDirtySnapshot` consumed by this copied minor. + Nursery parents report both as `n/a(nursery_parent)`. `young_logged` is + `n/a(heap_parent_uses_remembered_set)` for heap parents: young-entry logs own + side-table keys, not heap parents. Root/side-table failures report + `n/a(root_scanner_does_not_expose_owner_key)` because the scanner API exposes + the scanner class and value but not the table's log key; this is an explicit + non-answer rather than a guessed `no`. +- `minor` is a per-collection-thread evacuation-verifier ordinal. `trigger` is + the cycle's `GcTriggerKind`. `after_budgeted_step` compares a new per-thread + budgeted-cycle completion counter with the value observed by the preceding + verified minor, so an unrelated agent heap cannot set it. + +The original `slot`, `old`, and `forwarded_to` values remain present. The +failure line also has `surface`, a whitespace-free scanner/surface token, so a +root-side miss can be grouped without parsing prose. + +### Panic sites covered + +All callers of `panic_stale_forwarded_reference` pass the cycle context and +use the common formatter: + +- heap object rewrite descriptors (`heap fields`), remembered dirty ranges, + and remembered fallback headers; +- shadow-stack, native stack-map, and global mutable roots; +- each named Rust mutable-root scanner and the FFI mutable-root scanner; +- Rust and FFI copy-only root scanners; +- the `RuntimeRootVisitor` NaN-box, heap-word, tagged-raw-address, and + metadata-raw-address paths used by runtime side tables. + +Heap dirty-range verification now retains the owner header while visiting a +dirty slot, so a failure in that earlier verifier surface names the parent too, +instead of waiting for the later whole-heap walk. + +### Passing-path cost and liveness line + +The expensive work is behind the cold stale-reference branch: type lookup, +space classification, remembered-set re-snapshot, coverage probes, string +construction, and descriptor replay occur only immediately before panic. The +passing per-slot closure is unchanged: it records the layout read and invokes +`verify_slot`, with no new type, arena, set, or snapshot read. Counts are +accumulated once per accepted parent and once per descriptor from the +descriptor's existing slot count; there is no added per-slot diagnostic probe. + +When a copied minor passes and `PERRY_GC_DIAG=1`, it emits exactly one: + +```text +[gc-verify] minor= evacuation_ok parents= slots= old_young_edges= +``` + +`parents` and `slots` come from the live whole-heap verification walk; +`old_young_edges` is the already-computed count from +`verify_old_to_young_edges_covered`, so the line proves both verifier phases +ran without adding a second edge walk. + +### Tests and gates + +The disk-compliant focused gate ran with 13 GB available: + +```text +cargo test -p perry-runtime --release --lib -j4 -- --test-threads=1 verify +``` + +All 16 matching tests passed: the 14 existing verify/malloc-borrow tests plus +the two named VF2 tests. That run preceded the final failure-only dirty-owner +retention and cumulative slot-index cleanup; rerunning the resulting commit is +**not run: disk**. + +- `stale_forwarded_reference_panic_names_parent_slot_and_coverage` publishes + an old-page parent's nursery field without the write barrier, catches the + copied-minor verifier panic on a worker thread, and asserts + `parent_type=`, `parent_space=old_page`, `slot_index=0`, `remembered=no`, + `child_type=`, `minor=`, and `trigger=`. +- `evacuation_verifier_pass_line_counts_parents_and_slots` uses an isolated + child-test process with diagnostics enabled, captures stderr, requires + exactly one copied-minor success line, and parses `parents` as a positive + integer. + +Sabotage reruns: **not run: disk**. Removing one asserted panic field was +restored without retaining the mutation; the mandatory pre-Cargo check then +reported 11 GB, below the 12 GB floor. Skipping the pass line was likewise not +run for the same disk reason. The assertions are direct (missing the field or +line reaches `assert!` / `assert_eq!`), but no mutation-test pass is claimed. + +The full release `--lib` suite and the release `wasm-host` build are **not run: +disk**: subsequent checks reported 10--11 GB available. The attempted field +sabotage command was interrupted immediately after its pre-check exposed the +sub-floor value; no further Cargo gate completed. Static gates passed: +`rustfmt --check` and +`git diff --check`. Touched Rust files are below 2,000 lines; the largest is +`copying.rs` at 1,928 lines (`roots.rs` 1,886; `cycle.rs` 1,797; `verify.rs` +1,558; `verify_diag.rs` 293). + +### Perrymaster request + +Relink `app-vf` (main + this branch) and `app-vfat` (the AT2 tree + this branch) +on their existing caches. Run **N = 6** four-turn 3300 sessions for each app +with: + +```text +PERRY_GC_VERIFY_EVACUATION=1 PERRY_GC_DIAG=1 RUST_BACKTRACE=1 +``` + +Report every verifier failure line verbatim together with that minor's +preceding `[gc-step]`, `[gc-trigger]`, and `[gc-survival]` lines. For one clean +run of each app, report the `[gc-verify]` pass-line counts so verifier liveness +is independently visible. From dd2a07bca64a9b0681ab972eaf34a7524e5879cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 18:00:12 +0200 Subject: [PATCH 17/20] chore(gc): re-audit census snapshot inventory Re-pin the PASS1_MARKED non-moving window after auditing the verifier diagnostic plumbing, and classify its three counter-only TLS holders. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 4fc86a2e03ae5d42cd5931be901220350fe73766) --- scripts/gc_runtime_root_holders.json | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 6da701028b..377b5a96b7 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -288,7 +288,7 @@ "file": "crates/perry-runtime/src/gc/census.rs", "name": "PASS1_MARKED", "verdict": "non_moving_snapshot", - "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged.", + "why": "Real GC header addresses, deliberately untraced so the diagnostic does not keep its observed objects alive. Populated only at the end of mark propagation of a synchronous full cycle; consumed at sweep entry in the same run_to_completion invocation. The intervening full-cycle phases do not relocate or run JS callbacks. The Vec is used for membership comparisons and dropped with the census before sweep. Budgeted and minor cycles skip both boundaries. Pin re-audited 2026-09-05 after #9760 touched `gc/mod.rs`: that change is `mod heap_stats;` plus a `pub(crate) use` re-export and alters no mark/sweep control flow. `heap_stats()` is reached only from `js_bun_jsc_heap_stats` (the JS-facing `bun:jsc.heapStats()`), i.e. from mutator code, never inside a cycle, and its own module contract forbids allocation or collection during its walk. The mark-complete \u2192 sweep-entry window is unchanged. Re-audited 2026-09-05 (train125) after #9769 and #9771 touched pinned files. #9769 adds one `reg_scanner!` registration to `gc/mod.rs`; #9771 adds a feature-gated `alloc_census_init()` there and a feature-gated Rust-heap dump inside `take_census`. `alloc-census` is not in the default feature set, and decisively: `census_take_if_armed_at_full_sweep_start` does `PASS1_MARKED.with(|p| p.borrow_mut().take())` BEFORE calling `take_census`, so the snapshot has already left the thread-local by the time #9771's code runs \u2014 it cannot affect the window. Neither change alters mark/sweep control flow. Re-audited 2026-09-06 after #9831 touched `gc/policy.rs`. Its hunks are (a) the tiny-parse pressure guard's pricing (`tiny_parse_pressure_headroom_bytes`, `tiny_parse_pressure_due*`, a `Cell` byte-count base) consulted from JSON.parse's mutator-side boundaries (`gc_bump_malloc_trigger`, `gc_collect_pending_suppressed_parse`, `gc_schedule_parse_boundary_collection_if_pressure`), none of which is reachable from inside a cycle, and (b) one extra `Cell` store in `note_collection_finished_arena_occupancy`, which runs from `publish_reclaim_outcome` in the Publish subphase \u2014 after `step_sweep` has already consumed the snapshot. Mark/sweep control flow between `census_pass1_if_armed` and `census_take_if_armed_at_full_sweep_start` is untouched. Re-audited 2026-09-05 (train126) after #9755 restructured `gc/cycle.rs`. Its hunks are all root-scan machinery (`RootScanSubphase`, `RootScanCycleState`, the mutable-scanner iteration state), which runs BEFORE mark propagation completes; `gc/mod.rs` gains only a `mod young_log;` declaration. The bracketing is unchanged \u2014 `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep` \u2014 and a synchronous full mark-sweep still moves nothing between them. Re-pinned 2026-09-05 for the #9740 hot-TLS conversion of this file: the sole change is `thread_local!` \u2192 `crate::perry_thread_local!`, a macro-name swap with identical declaration syntax and `.with()` call sites. No control flow, no phase boundary, and no storage semantics change. Re-audited 2026-09-06 (train128) after #9794's GC diagnostics touched `gc/mod.rs` and `gc/policy.rs`: both gain diagnostic module declarations and counters only \u2014 no mark/sweep control flow, and the census bracketing in `step_mark_propagation` / `step_sweep` is unchanged. Re-audited for #9794's GC diagnostics: `gc/mod.rs` gains `mod diag_sites;` / `mod survival_diag;`, a re-export, a `diag_sites::full_started(...)` call at TRIGGER time (before mark propagation begins), and exit-time reporting. Nothing executes between mark-complete and sweep-entry, so the window is unchanged. Re-audited 2026-09-06 for the retained array-growth verifier fix: the cycle.rs change passes the existing non-copying evacuation verifier an explicit all-forwarded policy. That call remains in minor finalization, outside the synchronous full-cycle census window; its root and heap reads do not allocate GC objects, move objects, or invoke JS callbacks. The mark-complete and sweep-entry boundaries are unchanged. Re-audited 2026-09-05 after #9830 touched `gc/policy.rs`. That change is (a) six `thread_local! {` blocks rewritten as `crate::perry_thread_local! {` and (b) one `#[cfg(test)]` accessor listing the trigger path's hot-slot indices. The macro keeps the same storage, the same `.with()` at every read and write, and the same destructor registration (the teardown guard exists exactly when `needs_drop` holds, which is what `std::thread_local!` already decided); no value, predicate or branch in the file changes, so no mark or sweep control flow does. The one new behaviour is on a declaration's FIRST read: `HotKey::resolve_and_cache` takes a mutex and allocates a key through the GLOBAL allocator. Even if a first read landed inside this window it would be sound \u2014 the window's contract is that nothing relocates and no JS callback runs, and a mimalloc allocation does neither. `census_pass1_if_armed` is still inside `step_mark_propagation` and `census_take_if_armed_at_full_sweep_start` inside `step_sweep`; the bracketing is untouched. Re-audited 2026-09-06 (train132) after #9860 and #9845 touched `gc/mod.rs`. Both hunks are re-export lists and nothing else: #9860 adds `idle_reclaim_elapsed_starts` / `IDLE_RECLAIM_REARM_MS`, and #9845 adds `owner_is_dead_copied_minor_from_space_of_type`. No mark or sweep control flow changes. #9845's substantive work sits in `gc/oldgen.rs` and `gc/copying.rs`, neither pinned: the copying-minor arm (`finalize_dead_copied_minor_from_space_regexps`) runs on a MINOR, which skips both census boundaries; the full-cycle arm (`collect_dead_registered_regexps_post_trace`, from `with_dead_collection_finalize`) walks the RegExp registry building a Vec of addresses \u2014 no GC allocation, no JS callback, so it cannot relocate the snapshot's subjects \u2014 and it is reached from the sweep body, i.e. AFTER `census_take_if_armed_at_full_sweep_start` has already `take()`n the snapshot out of the thread-local. The mark-complete -> sweep-entry window is unchanged. Re-audited 2026-09-07 for #9965 after 1ec9e0e8a touched `gc/cycle.rs` and `gc/mod.rs`: `gc/mod.rs:216-217` only declares and imports the failure-attribution module, while `gc/cycle.rs:1414-1417` reads the trigger and diagnostic counters immediately before evacuation verification inside `atomic_finalize_minor_prelude`. Full cycles bypass `MinorPrelude` at `gc/cycle.rs:1192-1196`; evacuation remains guarded by the minor-only context at `gc/cycle.rs:1330-1372`. The snapshot store remains at `gc/cycle.rs:963-964` after synchronous full marking, and its take remains at `gc/cycle.rs:1454-1457` before sweep. No new write, relocation, collection, or JS callback was added to that full-cycle interval, so the PASS1_MARKED window is unaffected.", "window": { "start": { "file": "crates/perry-runtime/src/gc/census.rs", @@ -304,8 +304,8 @@ }, "sources": { "crates/perry-runtime/src/gc/census.rs": "388414f9629f196e84673e91bebd04bdcdcabdaa180252d2dfe4b82d1b49ca5a", - "crates/perry-runtime/src/gc/cycle.rs": "2e2f5adca2229f74409e01a1cb571e2147cd8a33f58d0976711fce98d4777309", - "crates/perry-runtime/src/gc/mod.rs": "43523b66595c61516ef6fcd4139d3ec5b4768a13c46ae1470c1d45481eacfdd9", + "crates/perry-runtime/src/gc/cycle.rs": "77eadaf7c4157308c3b14be5e1ff11d7198b84b503a50ff2d244851248d3e800", + "crates/perry-runtime/src/gc/mod.rs": "cf763b4d1743cd4ab5a571aef9b1eddba973ef8a205d343dea8f34775ff2fa8a", "crates/perry-runtime/src/gc/policy.rs": "dc9242ed40c0aa9c411d1ec0235c0219c6716dd82d56eb4d46578f7e889825d2", "crates/perry-runtime/src/gc/progress.rs": "a5ad3971bbe4047229ca57325234780daa85921dbc778e1c08dff4ad07ccfb96" } @@ -353,6 +353,12 @@ "verdict": "not_a_gc_pointer", "why": "#9794: per-method primitive-dispatch counts, `HashMap`. Rust-owned method-name strings and counters." }, + { + "file": "crates/perry-runtime/src/gc/instruments.rs", + "name": "THREAD_INCREMENTAL_COMPLETIONS", + "verdict": "not_a_gc_pointer", + "why": "#9965 verifier attribution: per-thread `Cell` tally of completed budgeted cycles. `note_incremental_completion` only increments the count and `incremental_completions_on_current_thread` only reads it; it cannot hold a heap pointer or NaN-boxed value." + }, { "file": "crates/perry-runtime/src/gc/oldgen_defrag.rs", "name": "LAST_IDLE_PREDICTED_RELEASE", @@ -377,6 +383,18 @@ "verdict": "not_a_gc_pointer", "why": "#9717: monotonic count of array-growth forwarding stubs a budgeted full cycle admitted through `classifier_valid_object_start`, reported as `forwarded_stub_recoveries=` on the PERRY_GC_DIAG `[gc-incremental]` line. A `Cell` holding a tally, never an address \u2014 the stubs it counts are reached through the worklist, not retained here. Nothing for the collector." }, + { + "file": "crates/perry-runtime/src/gc/verify_diag.rs", + "name": "LAST_VERIFY_BUDGETED_COMPLETIONS", + "verdict": "not_a_gc_pointer", + "why": "#9965 verifier attribution: per-thread `Cell` storing the preceding verifier invocation's budgeted-cycle completion count. It is compared with the current count to derive the diagnostic `after_budgeted_step` boolean and can never contain a heap pointer or NaN-boxed value." + }, + { + "file": "crates/perry-runtime/src/gc/verify_diag.rs", + "name": "VERIFY_MINOR_ORDINAL", + "verdict": "not_a_gc_pointer", + "why": "#9965 verifier attribution: per-thread `Cell` monotonically counting evacuation-verifier invocations so failure and success diagnostics can label the minor ordinal. It stores only a saturating counter, never a heap pointer or NaN-boxed value." + }, { "file": "crates/perry-runtime/src/hot_diag.rs", "name": "ENUM_DIAG", From fea28cc08155a8f066d97cb392fb45c2892e2102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 19:44:19 +0200 Subject: [PATCH 18/20] perf: add receiver representation ledgers Instrument headerless receiver producers and dynamic receiver funnels behind the opt-in PERRY_RECEIVER_REPR_DIAG sink. Add explicit native return storage kinds while retaining the existing pointer-boxing lowering, plus checked provider inventory and fail-capable fixtures. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit 9b6884e214beb93873f21dd76afe49e42f3f25df) --- .github/workflows/native-result-ledger.yml | 34 ++ crates/perry-codegen/src/lib.rs | 9 +- .../src/lower_call/native_module_dispatch.rs | 12 +- .../lower_call/native_table/async_decimal.rs | 34 +- .../src/lower_call/native_table/bun.rs | 8 +- .../src/lower_call/native_table/databases.rs | 174 +++--- .../src/lower_call/native_table/extras.rs | 2 +- .../src/lower_call/native_table/fastify.rs | 18 +- .../lower_call/native_table/http_client.rs | 46 +- .../src/lower_call/native_table/http_http2.rs | 10 +- .../lower_call/native_table/http_server.rs | 40 +- .../src/lower_call/native_table/media.rs | 104 ++-- .../src/lower_call/native_table/mod.rs | 25 +- .../native_table/net_classes_state.rs | 8 +- .../src/lower_call/native_table/net_events.rs | 106 ++-- .../native_table/node_core/dgram_fs_os.rs | 2 +- .../native_table/node_core/util_buffer.rs | 8 +- .../native_table/node_core_process.rs | 6 +- .../lower_call/native_table/node_domain.rs | 14 +- .../src/lower_call/native_table/node_misc.rs | 32 +- .../lower_call/native_table/thread_lodash.rs | 24 +- .../src/lower_call/native_table/tls_events.rs | 22 +- .../src/lower_call/native_table/tui.rs | 50 +- .../src/lower_call/native_table/undici.rs | 6 +- .../lower_call/native_table/utils_crypto.rs | 24 +- .../src/lower_call/native_table/ws_events.rs | 8 +- crates/perry-runtime/src/async_hooks.rs | 18 + crates/perry-runtime/src/buffer/header.rs | 15 + crates/perry-runtime/src/buffer/mod.rs | 10 +- crates/perry-runtime/src/hot_diag.rs | 16 +- .../src/hot_diag/receiver_repr.rs | 509 ++++++++++++++++++ .../field_get_set/get_field_by_name_tail.rs | 4 + .../src/object/field_get_set/ic_miss.rs | 3 + crates/perry-runtime/src/object/mod.rs | 2 +- .../src/object/native_call_method.rs | 62 +++ .../native_call_method/primitive_methods.rs | 3 + crates/perry-runtime/src/object/null_stub.rs | 10 + .../src/object/prototype_chain.rs | 3 + crates/perry-runtime/src/proxy.rs | 5 + crates/perry-runtime/src/shared_sab.rs | 3 + crates/perry-runtime/src/symbol.rs | 10 + .../perry-runtime/src/symbol/constructors.rs | 5 + crates/perry-runtime/src/text.rs | 6 + crates/perry-runtime/src/timer.rs | 3 + crates/perry-runtime/src/tui/hooks.rs | 16 + crates/perry-runtime/src/tui/mod.rs | 6 + crates/perry-runtime/src/tui/state.rs | 7 + crates/perry-runtime/src/tui/tree.rs | 7 + crates/perry-runtime/src/value/addr_class.rs | 15 + crates/perry-stdlib/src/common/handle.rs | 10 + crates/perry-stdlib/src/fetch/mod.rs | 5 + crates/perry-stdlib/src/zlib.rs | 5 + scripts/native_result_ledger.py | 235 ++++++++ scripts/native_result_ledger.tsv | 323 +++++++++++ 54 files changed, 1751 insertions(+), 391 deletions(-) create mode 100644 .github/workflows/native-result-ledger.yml create mode 100644 crates/perry-runtime/src/hot_diag/receiver_repr.rs create mode 100644 scripts/native_result_ledger.py create mode 100644 scripts/native_result_ledger.tsv diff --git a/.github/workflows/native-result-ledger.yml b/.github/workflows/native-result-ledger.yml new file mode 100644 index 0000000000..51c4ac2653 --- /dev/null +++ b/.github/workflows/native-result-ledger.yml @@ -0,0 +1,34 @@ +name: Native Result Ledger + +on: + pull_request: + paths: + - "crates/perry-codegen/src/lower_call/native_table/**" + - "scripts/native_result_ledger.py" + - "scripts/native_result_ledger.tsv" + - ".github/workflows/native-result-ledger.yml" + push: + branches: [main] + paths: + - "crates/perry-codegen/src/lower_call/native_table/**" + - "scripts/native_result_ledger.py" + - "scripts/native_result_ledger.tsv" + - ".github/workflows/native-result-ledger.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-result-ledger-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Prove and run the typed-result inventory + run: | + python3 scripts/native_result_ledger.py --self-test + python3 scripts/native_result_ledger.py diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 3e3ea4fd81..6cf9c09a6d 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -143,7 +143,7 @@ pub mod expr_shadow_layout { /// check (#463) miss a real implementation. /// /// Arg / return *kinds* are reported as opaque strings (`"NA_STR"`, -/// `"NR_PTR"`, ...) so the consistency test can compare against the +/// `"NR_GCPTR"`, ...) so the consistency test can compare against the /// manifest's `params` / `returns` types without `perry-api-manifest` /// having to depend on `perry-codegen`'s internal enums (#512). pub struct NativeMethodRef { @@ -162,8 +162,11 @@ pub struct NativeMethodRef { /// `"NA_VARARGS"`, `"NA_JSON"`. Used by `perry-api-manifest`'s /// param-count drift test (#512). pub arg_kinds: &'static [&'static str], - /// Return-kind tag. One of `"NR_PTR"`, `"NR_PROMISE"`, `"NR_STR"`, - /// `"NR_BIGINT"`, `"NR_F64"`, `"NR_I32"`, `"NR_VOID"`. + /// Return-kind tag. Pointer-boxed results explicitly distinguish + /// `"NR_GCPTR"`, `"NR_NULLABLE_GCPTR"`, `"NR_HANDLE_ID"`, + /// `"NR_FOREIGN_PTR"`, and `"NR_JS_VALUE"`; the existing + /// `"NR_PROMISE"`, `"NR_STR"`, `"NR_BIGINT"`, `"NR_F64"`, + /// `"NR_I32"`, and `"NR_VOID"` tags are unchanged. pub ret_kind: &'static str, } diff --git a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs index d84dcccad0..4583fd834e 100644 --- a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs @@ -190,7 +190,11 @@ pub fn lower_native_module_dispatch( // Determine return type for the declare let ret_type = match sig.ret { - NativeRetKind::Ptr + NativeRetKind::GcPtr + | NativeRetKind::NullableGcPtr + | NativeRetKind::HandleId + | NativeRetKind::ForeignPtr + | NativeRetKind::JsValue | NativeRetKind::Promise | NativeRetKind::Str | NativeRetKind::ObjFromJsonStr @@ -207,7 +211,11 @@ pub fn lower_native_module_dispatch( llvm_args.iter().map(|(t, s)| (*t, s.as_str())).collect(); match sig.ret { - NativeRetKind::Ptr => { + NativeRetKind::GcPtr + | NativeRetKind::NullableGcPtr + | NativeRetKind::HandleId + | NativeRetKind::ForeignPtr + | NativeRetKind::JsValue => { let blk = ctx.block(); let raw = blk.call(I64, sig.runtime, &arg_slices); let lowered = LoweredValue::native_handle(raw.clone()); diff --git a/crates/perry-codegen/src/lower_call/native_table/async_decimal.rs b/crates/perry-codegen/src/lower_call/native_table/async_decimal.rs index e0fcea9442..c016b0967e 100644 --- a/crates/perry-codegen/src/lower_call/native_table/async_decimal.rs +++ b/crates/perry-codegen/src/lower_call/native_table/async_decimal.rs @@ -89,7 +89,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_async_hooks_create_hook", args: &[NA_F64], - ret: NR_PTR, + ret: NR_FOREIGN_PTR, }, NativeModSig { module: "async_hooks", @@ -125,7 +125,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: Some("AsyncHook"), runtime: "js_async_hook_enable", args: &[], - ret: NR_PTR, + ret: NR_FOREIGN_PTR, }, NativeModSig { module: "async_hooks", @@ -134,7 +134,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: Some("AsyncHook"), runtime: "js_async_hook_disable", args: &[], - ret: NR_PTR, + ret: NR_FOREIGN_PTR, }, NativeModSig { module: "async_hooks", @@ -161,7 +161,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: Some("AsyncResource"), runtime: "js_async_resource_emit_destroy", args: &[], - ret: NR_PTR, + ret: NR_FOREIGN_PTR, }, NativeModSig { module: "async_hooks", @@ -179,7 +179,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: Some("AsyncResource"), runtime: "js_async_resource_bind", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_NULLABLE_GCPTR, }, // ========== decimal.js (arbitrary-precision math) ========== // `new Decimal(value)` is dispatched by `lower_builtin_new` (calls @@ -194,7 +194,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_plus_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -203,7 +203,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_minus_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -212,7 +212,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_times_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -221,7 +221,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_div_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -230,7 +230,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_mod_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -239,7 +239,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_pow", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -248,7 +248,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_sqrt", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -257,7 +257,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_abs", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -266,7 +266,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_neg", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -275,7 +275,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_round", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -284,7 +284,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_floor", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "decimal.js", @@ -293,7 +293,7 @@ pub(super) const ASYNC_DECIMAL_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_decimal_ceil", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Formatting — return strings (NR_STR NaN-boxes the *StringHeader). NativeModSig { diff --git a/crates/perry-codegen/src/lower_call/native_table/bun.rs b/crates/perry-codegen/src/lower_call/native_table/bun.rs index 5ae17ea8c0..772555b0cb 100644 --- a/crates/perry-codegen/src/lower_call/native_table/bun.rs +++ b/crates/perry-codegen/src/lower_call/native_table/bun.rs @@ -43,7 +43,7 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_bun_transpiler_new", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "bun", @@ -52,7 +52,7 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_bun_tcp_listen", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "bun", @@ -63,7 +63,7 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ // handle, reusing the same event-loop pump as node:http. runtime: "js_bun_serve", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "bun", @@ -72,7 +72,7 @@ pub(crate) const BUN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_bun_build", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "bun", diff --git a/crates/perry-codegen/src/lower_call/native_table/databases.rs b/crates/perry-codegen/src/lower_call/native_table/databases.rs index 5c169be374..ebad043faa 100644 --- a/crates/perry-codegen/src/lower_call/native_table/databases.rs +++ b/crates/perry-codegen/src/lower_call/native_table/databases.rs @@ -9,7 +9,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_create_connection", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -18,7 +18,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_create_pool", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "mysql2/promise", @@ -27,7 +27,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_create_connection", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -36,7 +36,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_create_pool", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // mysql2 Pool-specific methods (class_filter: Some("Pool")) NativeModSig { @@ -46,7 +46,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Pool"), runtime: "js_mysql2_pool_query", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -55,7 +55,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Pool"), runtime: "js_mysql2_pool_execute", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -64,7 +64,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Pool"), runtime: "js_mysql2_pool_end", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -73,7 +73,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Pool"), runtime: "js_mysql2_pool_query", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -82,7 +82,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Pool"), runtime: "js_mysql2_pool_execute", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -91,7 +91,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Pool"), runtime: "js_mysql2_pool_end", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // mysql2 PoolConnection-specific methods NativeModSig { @@ -101,7 +101,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("PoolConnection"), runtime: "js_mysql2_pool_connection_query", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -110,7 +110,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("PoolConnection"), runtime: "js_mysql2_pool_connection_execute", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -119,7 +119,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("PoolConnection"), runtime: "js_mysql2_pool_connection_query", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -128,7 +128,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("PoolConnection"), runtime: "js_mysql2_pool_connection_execute", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, // mysql2 generic instance methods (Connection fallback, class_filter: None) NativeModSig { @@ -138,7 +138,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_query", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -147,7 +147,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_execute", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -156,7 +156,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_end", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -165,7 +165,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_pool_get_connection", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -183,7 +183,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_begin_transaction", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -192,7 +192,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_commit", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2", @@ -201,7 +201,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_rollback", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -210,7 +210,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_query", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -219,7 +219,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_execute", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -228,7 +228,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_end", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -237,7 +237,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_pool_get_connection", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -255,7 +255,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_begin_transaction", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -264,7 +264,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_commit", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mysql2/promise", @@ -273,7 +273,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mysql2_connection_rollback", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== PostgreSQL (pg) ========== // `new Client(config)` and `new Pool(config)` are dispatched by @@ -287,7 +287,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_pg_connect", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "pg", @@ -296,7 +296,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_pg_create_pool", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, // `client.connect()` — async, opens the TCP connection on a handle that // `new Client(config)` previously created in the pre-connect state. @@ -311,7 +311,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Client"), runtime: "js_pg_client_connect", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // Pool-specific query/end — different runtime fns from the Client paths. // Pre-existing dispatch was unfiltered and routed both Pool and Client @@ -327,7 +327,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Pool"), runtime: "js_pg_pool_query", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "pg", @@ -336,7 +336,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Pool"), runtime: "js_pg_pool_end", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "pg", @@ -345,7 +345,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_pg_client_query", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "pg", @@ -354,7 +354,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_pg_client_end", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== ioredis ========== // NB: every row was previously emitting `js_redis_*` symbols which don't @@ -374,7 +374,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ // js_ioredis_new ignores its arg and reads env vars — same behavior. runtime: "js_ioredis_new", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "ioredis", @@ -383,7 +383,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_set", args: &[NA_STR, NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "ioredis", @@ -392,7 +392,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_get", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "ioredis", @@ -401,7 +401,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_del", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "ioredis", @@ -410,7 +410,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_exists", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "ioredis", @@ -419,7 +419,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_incr", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "ioredis", @@ -428,7 +428,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_decr", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "ioredis", @@ -437,7 +437,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_expire", args: &[NA_STR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "ioredis", @@ -446,7 +446,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_quit", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // Issue #605 — npm `redis`'s `client.connect()` is async. ioredis // auto-connects in `new Redis()` and exposes `connect()` as a no-op @@ -471,7 +471,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ioredis_quit", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== MongoDB ========== // `new MongoClient(uri)` is dispatched by `lower_builtin_new` (sync ctor @@ -512,7 +512,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_client_db", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "mongodb", @@ -521,7 +521,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_db_collection", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `_value` wrapper variants — every collection method that accepts an // object/filter arg goes through a wrapper that JSON-stringifies the @@ -537,7 +537,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_insert_one_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mongodb", @@ -546,7 +546,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_insert_many_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mongodb", @@ -555,7 +555,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_find_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mongodb", @@ -564,7 +564,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_find_one_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mongodb", @@ -573,7 +573,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_update_one_value", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mongodb", @@ -582,7 +582,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_update_many_value", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mongodb", @@ -591,7 +591,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_delete_one_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mongodb", @@ -600,7 +600,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_delete_many_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "mongodb", @@ -609,7 +609,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_collection_count_value", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, // aggregate / createIndex / toArray runtime functions don't exist in // perry-stdlib yet — listed as commented-out so the dispatch table @@ -627,7 +627,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_mongodb_client_close", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== better-sqlite3 ========== NativeModSig { @@ -637,7 +637,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sqlite_open", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "better-sqlite3", @@ -646,7 +646,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sqlite_prepare", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // stmt.run/get/all/iterate take JS-side variadic params. The runtime // consumes them as a single `*const ArrayHeader`, so VarArgsAsArray @@ -663,7 +663,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sqlite_stmt_run", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "better-sqlite3", @@ -681,7 +681,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sqlite_stmt_all", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, // `stmt.raw([toggle])` — flips the statement into raw mode and // returns the same handle so `stmt.raw().all(...)` chains. drizzle's @@ -697,7 +697,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sqlite_stmt_raw", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "better-sqlite3", @@ -725,7 +725,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_bun_sqlite_database_call", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "bun:sqlite", @@ -734,7 +734,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Database"), runtime: "js_bun_sqlite_database_query", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "bun:sqlite", @@ -743,7 +743,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Database"), runtime: "js_bun_sqlite_database_query", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "bun:sqlite", @@ -752,7 +752,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Database"), runtime: "js_bun_sqlite_database_run", args: &[NA_F64, NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "bun:sqlite", @@ -770,7 +770,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Database"), runtime: "js_node_sqlite_database_sync_serialize", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "bun:sqlite", @@ -788,7 +788,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Database"), runtime: "js_bun_sqlite_database_transaction", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "bun:sqlite", @@ -797,7 +797,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Statement"), runtime: "js_node_sqlite_statement_sync_run", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "bun:sqlite", @@ -815,7 +815,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Statement"), runtime: "js_node_sqlite_statement_sync_all", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "bun:sqlite", @@ -824,7 +824,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Statement"), runtime: "js_bun_sqlite_statement_values", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "bun:sqlite", @@ -852,7 +852,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_database_sync_call", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sqlite", @@ -861,7 +861,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_session_call", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sqlite", @@ -870,7 +870,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_statement_sync_call", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sqlite", @@ -933,7 +933,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_database_sync_prepare", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sqlite", @@ -942,7 +942,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_database_sync_serialize", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sqlite", @@ -996,7 +996,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("DatabaseSync"), runtime: "js_node_sqlite_database_sync_create_tag_store", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sqlite", @@ -1005,7 +1005,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("DatabaseSync"), runtime: "js_node_sqlite_database_sync_create_session", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sqlite", @@ -1068,7 +1068,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_database_sync_limits", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sqlite", @@ -1077,7 +1077,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Session"), runtime: "js_node_sqlite_session_changeset", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sqlite", @@ -1086,7 +1086,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("Session"), runtime: "js_node_sqlite_session_patchset", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sqlite", @@ -1122,7 +1122,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("SQLTagStore"), runtime: "js_node_sqlite_sql_tag_store_run", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sqlite", @@ -1140,7 +1140,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("SQLTagStore"), runtime: "js_node_sqlite_sql_tag_store_all", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sqlite", @@ -1185,7 +1185,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: Some("SQLTagStore"), runtime: "js_node_sqlite_sql_tag_store_db", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sqlite", @@ -1194,7 +1194,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_statement_sync_run", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sqlite", @@ -1212,7 +1212,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_statement_sync_all", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sqlite", @@ -1230,7 +1230,7 @@ pub(super) const DATABASES_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_sqlite_statement_sync_columns", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sqlite", diff --git a/crates/perry-codegen/src/lower_call/native_table/extras.rs b/crates/perry-codegen/src/lower_call/native_table/extras.rs index 7445fa4fcd..e9d53bd11d 100644 --- a/crates/perry-codegen/src/lower_call/native_table/extras.rs +++ b/crates/perry-codegen/src/lower_call/native_table/extras.rs @@ -317,7 +317,7 @@ pub(super) const EXTRAS_ROWS: &[NativeModSig] = &[ class_filter: Some("Wallet"), runtime: "js_ethers_wallet_create_random", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, // ========== #2875 DisposableStack / AsyncDisposableStack ========== // `new DisposableStack()` / `new AsyncDisposableStack()` are dispatched diff --git a/crates/perry-codegen/src/lower_call/native_table/fastify.rs b/crates/perry-codegen/src/lower_call/native_table/fastify.rs index 1d65f6e26d..23980b36db 100644 --- a/crates/perry-codegen/src/lower_call/native_table/fastify.rs +++ b/crates/perry-codegen/src/lower_call/native_table/fastify.rs @@ -9,7 +9,7 @@ pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_fastify_create_with_opts", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "fastify", @@ -165,7 +165,7 @@ pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_fastify_app_server", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // #1113 — `app.server.on(event, cb)`. `app.server` returns the // FastifyApp handle (pointer-tagged), so `.on(…)` lowers as a @@ -250,7 +250,7 @@ pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_fastify_req_headers", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "fastify", @@ -287,7 +287,7 @@ pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_fastify_reply_status", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `reply.code(N)` is an alias for `reply.status(N)` in npm Fastify. Without // this row, `reply.code(201)` silently no-op'd and the HTTP status stayed 200. @@ -298,7 +298,7 @@ pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_fastify_reply_status", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "fastify", @@ -315,7 +315,7 @@ pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ // user code at it. CORS hooks, Cache-Control, and content-type // overrides all evaporated. // - // `ret: NR_PTR` is critical — the Rust impl returns `Handle` (i64). + // `ret: NR_HANDLE_ID` is critical — the Rust impl returns `Handle` (i64). // Previously `NR_F64` caused chained `.header(...).send(...)` to read // an uninitialized XMM0/D0 register as the receiver, producing // `(number).send is not a function` errors (#1048). @@ -331,11 +331,11 @@ pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ class_filter: Some("Reply"), runtime: "js_fastify_reply_header", args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `reply.type(value)` — Fastify alias for setting `content-type`. // Routes to `js_fastify_reply_type` (thin wrapper over reply_header). - // `ret: NR_PTR` for the same reason as `reply.header` above (#1048). + // `ret: NR_HANDLE_ID` for the same reason as `reply.header` above (#1048). NativeModSig { module: "fastify", has_receiver: true, @@ -343,7 +343,7 @@ pub(super) const FASTIFY_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_fastify_reply_type", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Fastify context methods (Hono-style) NativeModSig { diff --git a/crates/perry-codegen/src/lower_call/native_table/http_client.rs b/crates/perry-codegen/src/lower_call/native_table/http_client.rs index eb43c085f1..b891376d5b 100644 --- a/crates/perry-codegen/src/lower_call/native_table/http_client.rs +++ b/crates/perry-codegen/src/lower_call/native_table/http_client.rs @@ -44,7 +44,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_http_request_overload", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -53,7 +53,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_http_get_overload", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "https", @@ -62,7 +62,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_https_request_overload", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "https", @@ -71,7 +71,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_https_get_overload", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // #3712 — module-level header-validation / parser-proxy helpers. Runtime // impls live in `crates/perry-runtime/src/object/native_module_dispatch.rs`. @@ -134,7 +134,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("ClientRequest"), runtime: "js_http_on", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -143,7 +143,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("ClientRequest"), runtime: "js_http_once", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -156,7 +156,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ // `(encoding?, callback?)` tail. runtime: "js_http_client_request_end_full", args: &[NA_F64, NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -180,7 +180,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("ClientRequest"), runtime: "js_http_set_header", args: &[NA_STR, NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -192,7 +192,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ // `req.setTimeout(n, cb)` on a never-responding server hung forever). runtime: "js_http_set_timeout_full", args: &[NA_F64, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -244,7 +244,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ "destroy", "js_http_client_request_destroy", &[NA_F64], - NR_PTR, + NR_HANDLE_ID, ), cr( "flushHeaders", @@ -373,7 +373,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_http_agent_new", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "https", @@ -382,7 +382,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_https_agent_new", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Agent instance methods. Most are chainable no-ops today — Perry // doesn't pool sockets, but Node's test suite asserts the methods @@ -407,7 +407,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_destroy", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -416,7 +416,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -425,7 +425,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Property accessors as `__get_` synthetic methods. The HIR // rewrites bare `agent.maxSockets` reads to `agent.__get_maxSockets()` @@ -583,7 +583,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_sockets", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "http", @@ -592,7 +592,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_sockets", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "http", @@ -601,7 +601,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_free_sockets", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "http", @@ -610,7 +610,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_free_sockets", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "http", @@ -619,7 +619,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_requests", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "http", @@ -628,7 +628,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_requests", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "http", @@ -718,7 +718,7 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_create_connection", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -727,6 +727,6 @@ pub(super) const HTTP_CLIENT_ROWS: &[NativeModSig] = &[ class_filter: Some("Agent"), runtime: "js_http_agent_create_socket", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, ]; diff --git a/crates/perry-codegen/src/lower_call/native_table/http_http2.rs b/crates/perry-codegen/src/lower_call/native_table/http_http2.rs index 78265e64dc..005232d6c3 100644 --- a/crates/perry-codegen/src/lower_call/native_table/http_http2.rs +++ b/crates/perry-codegen/src/lower_call/native_table/http_http2.rs @@ -9,7 +9,7 @@ pub(super) const HTTP_HTTP2_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_http2_create_server", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http2", @@ -18,7 +18,7 @@ pub(super) const HTTP_HTTP2_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_http2_create_secure_server", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http2", @@ -27,7 +27,7 @@ pub(super) const HTTP_HTTP2_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_http2_connect", args: &[NA_F64, NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http2", @@ -38,7 +38,7 @@ pub(super) const HTTP_HTTP2_ROWS: &[NativeModSig] = &[ // Variadic listen() overloads — see the http `listen` row. Issue #2041. // Returns the server handle for chainability (#2129). args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http2", @@ -88,7 +88,7 @@ pub(super) const HTTP_HTTP2_ROWS: &[NativeModSig] = &[ // NA_JSV: pass the settings object's raw NaN-boxed bits (the runtime // JSON-stringifies it); NR_PTR: return value is a Buffer pointer. args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "http2", diff --git a/crates/perry-codegen/src/lower_call/native_table/http_server.rs b/crates/perry-codegen/src/lower_call/native_table/http_server.rs index dc044db30a..f32c3f8d47 100644 --- a/crates/perry-codegen/src/lower_call/native_table/http_server.rs +++ b/crates/perry-codegen/src/lower_call/native_table/http_server.rs @@ -14,7 +14,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_http_create_server_with_options", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `http.Server(handler)` is Node's callable-constructor alias for // `http.createServer` (works with or without `new`). #2132. @@ -26,7 +26,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_http_create_server_with_options", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // HttpServer instance methods (class_filter: HttpServer) NativeModSig { @@ -48,7 +48,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ // this was NR_VOID and chained sites broke at runtime with // `undefined.on is not a function`. args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -335,7 +335,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("HttpServer"), runtime: "js_node_http_server_set_timeout_method", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `server.ref()` / `server.unref()` — EventEmitter chainables that // return `this`. Without these rows they fell through to a generic @@ -352,7 +352,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("HttpServer"), runtime: "js_node_http_server_ref", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -361,7 +361,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("HttpServer"), runtime: "js_node_http_server_unref", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // IncomingMessage instance methods NativeModSig { @@ -405,7 +405,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ // yielding `undefined`/a raw number. runtime: "js_node_http_im_pause_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -418,7 +418,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ // `(number|undefined).on`. runtime: "js_node_http_im_resume_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -445,7 +445,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("IncomingMessage"), runtime: "js_http_incoming_message_set_encoding", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -454,7 +454,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("IncomingMessage"), runtime: "js_node_http_im_set_timeout", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // ServerResponse instance methods NativeModSig { @@ -467,7 +467,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ // (e.g. Set-Cookie) are detected and emitted as one wire line per // element instead of a single comma-joined / JSON-stringified line. args: &[NA_STR, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -521,7 +521,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("ServerResponse"), runtime: "js_node_http_res_append_header", args: &[NA_STR, NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -530,7 +530,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("ServerResponse"), runtime: "js_node_http_res_set_headers", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -616,7 +616,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("ServerResponse"), runtime: "js_node_http_res_set_timeout", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "http", @@ -1005,7 +1005,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_https_create_server", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `https.Server(options, handler)` is Node's callable-constructor // alias for `https.createServer` (works with or without `new`). #2132. @@ -1016,7 +1016,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_https_create_server", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "https", @@ -1027,7 +1027,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ // Variadic listen() overloads — see the http `listen` row. Issue #2041. // Returns the server handle for chainability (#2129). args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "https", @@ -1101,7 +1101,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("HttpsServer"), runtime: "js_node_https_server_ref", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "https", @@ -1110,7 +1110,7 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("HttpsServer"), runtime: "js_node_https_server_unref", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "https", @@ -1317,6 +1317,6 @@ pub(super) const HTTP_SERVER_ROWS: &[NativeModSig] = &[ class_filter: Some("HttpsServer"), runtime: "js_node_https_server_set_timeout_method", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, ]; diff --git a/crates/perry-codegen/src/lower_call/native_table/media.rs b/crates/perry-codegen/src/lower_call/native_table/media.rs index 44e1f12310..1853542479 100644 --- a/crates/perry-codegen/src/lower_call/native_table/media.rs +++ b/crates/perry-codegen/src/lower_call/native_table/media.rs @@ -15,7 +15,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_from_input", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -24,7 +24,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_from_input", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -33,7 +33,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_resize", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -42,7 +42,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_rotate", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -51,7 +51,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_flip", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -60,7 +60,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_flop", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -69,7 +69,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_grayscale", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -78,7 +78,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_blur", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -87,7 +87,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_sharpen", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `.extract({ left, top, width, height })` — the options object is passed // as a NaN-boxed value (NA_F64 slot); `js_sharp_extract` reads its fields. @@ -98,7 +98,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_extract", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -107,7 +107,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_auto_orient", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `.extend({ top, bottom, left, right, background })` — options object. NativeModSig { @@ -117,7 +117,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_extend", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -126,7 +126,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_trim", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `.composite([{ input, top, left }, …])` — array of layer objects, passed // as a NaN-boxed pointer (NA_F64 slot); `js_sharp_composite` walks it. @@ -137,7 +137,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_composite", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -146,7 +146,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_jpeg", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -155,7 +155,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_png", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -164,7 +164,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_webp", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -173,7 +173,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_avif", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "sharp", @@ -182,7 +182,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_to_file", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sharp", @@ -191,7 +191,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_to_buffer", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sharp", @@ -200,7 +200,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_sharp_metadata", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "sharp", @@ -229,7 +229,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cheerio_load", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cheerio", @@ -238,7 +238,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cheerio_select", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cheerio", @@ -283,7 +283,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cheerio_selection_first", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cheerio", @@ -292,7 +292,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cheerio_selection_last", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cheerio", @@ -301,7 +301,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cheerio_selection_eq", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cheerio", @@ -310,7 +310,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cheerio_selection_find", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cheerio", @@ -319,7 +319,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cheerio_selection_children", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cheerio", @@ -328,7 +328,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cheerio_selection_parent", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cheerio", @@ -350,7 +350,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ // codec extracts the buffer/string pointer itself; the options object // carries `{ level }`. args: &[NA_JSV, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -361,7 +361,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ // #2935: data as raw NaN-box bits so the codec unboxes the buffer // pointer itself (a Buffer/string both decompress correctly). args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -371,7 +371,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ runtime: "js_zlib_deflate_sync", // #2935: see gzipSync above. args: &[NA_JSV, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -381,7 +381,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ runtime: "js_zlib_inflate_sync", // #2935: see gunzipSync above. args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -455,7 +455,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_deflate_raw_sync", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -464,7 +464,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_inflate_raw_sync", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -473,7 +473,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_unzip_sync", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -494,7 +494,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_brotli_compress_sync", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -503,7 +503,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_brotli_decompress_sync", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -530,7 +530,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_zstd_compress_sync", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -539,7 +539,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_zstd_decompress_sync", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "zlib", @@ -571,7 +571,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_gzip", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -580,7 +580,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_gunzip", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -589,7 +589,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_deflate", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -598,7 +598,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_inflate", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -607,7 +607,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_deflate_raw", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -616,7 +616,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_inflate_raw", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -625,7 +625,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_unzip", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -634,7 +634,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_brotli_compress", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `zlib.createBrotliDecompress(options?)` — now a real Transform-stream // handle (previously a feature-check Buffer stub; axios's @@ -646,7 +646,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_brotli_decompress", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -655,7 +655,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_zstd_compress", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "zlib", @@ -664,7 +664,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_zlib_create_zstd_decompress", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // ========== cron ========== // schedule() returns a Handle (i64) → NR_PTR. Instance methods take Handle (i64). @@ -690,7 +690,7 @@ pub(super) const MEDIA_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_cron_schedule", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "cron", diff --git a/crates/perry-codegen/src/lower_call/native_table/mod.rs b/crates/perry-codegen/src/lower_call/native_table/mod.rs index 55daa6711b..18cfadf122 100644 --- a/crates/perry-codegen/src/lower_call/native_table/mod.rs +++ b/crates/perry-codegen/src/lower_call/native_table/mod.rs @@ -80,8 +80,17 @@ pub(super) enum NativeArgKind { /// What the runtime function returns. #[derive(Copy, Clone, Debug)] pub(super) enum NativeRetKind { - /// Returns i64 handle → NaN-box as POINTER. - Ptr, + /// Returns a non-null Perry allocation with a readable `GcHeader`. + GcPtr, + /// Returns a Perry allocation, with zero carrying the provider's existing + /// null/failure policy. + NullableGcPtr, + /// Returns an integer registry id or provider sentinel. + HandleId, + /// Returns a headerless native address (for example an async-hook box). + ForeignPtr, + /// Returns raw NaN-boxed JS value bits in an integer ABI slot. + JsValue, /// Returns i64 promise handle → NaN-box as POINTER, but record the async /// boundary separately from generic native handles. Promise, @@ -136,7 +145,11 @@ pub(super) const NA_STR: NativeArgKind = NativeArgKind::StrPtr; pub(super) const NA_PTR: NativeArgKind = NativeArgKind::PtrI64; pub(super) const NA_JSV: NativeArgKind = NativeArgKind::JsvalI64; pub(super) const NA_VARARGS: NativeArgKind = NativeArgKind::VarArgsAsArray; -pub(super) const NR_PTR: NativeRetKind = NativeRetKind::Ptr; +pub(super) const NR_GCPTR: NativeRetKind = NativeRetKind::GcPtr; +pub(super) const NR_NULLABLE_GCPTR: NativeRetKind = NativeRetKind::NullableGcPtr; +pub(super) const NR_HANDLE_ID: NativeRetKind = NativeRetKind::HandleId; +pub(super) const NR_FOREIGN_PTR: NativeRetKind = NativeRetKind::ForeignPtr; +pub(super) const NR_JS_VALUE: NativeRetKind = NativeRetKind::JsValue; pub(super) const NR_PROMISE: NativeRetKind = NativeRetKind::Promise; pub(super) const NR_STR: NativeRetKind = NativeRetKind::Str; pub(super) const NR_OBJ_FROM_JSON_STR: NativeRetKind = NativeRetKind::ObjFromJsonStr; @@ -255,7 +268,11 @@ fn arg_kind_tag(a: &NativeArgKind) -> &'static str { fn ret_kind_tag(r: &NativeRetKind) -> &'static str { match r { - NativeRetKind::Ptr => "NR_PTR", + NativeRetKind::GcPtr => "NR_GCPTR", + NativeRetKind::NullableGcPtr => "NR_NULLABLE_GCPTR", + NativeRetKind::HandleId => "NR_HANDLE_ID", + NativeRetKind::ForeignPtr => "NR_FOREIGN_PTR", + NativeRetKind::JsValue => "NR_JS_VALUE", NativeRetKind::Promise => "NR_PROMISE", NativeRetKind::Str => "NR_STR", NativeRetKind::ObjFromJsonStr => "NR_OBJ_FROM_JSON_STR", diff --git a/crates/perry-codegen/src/lower_call/native_table/net_classes_state.rs b/crates/perry-codegen/src/lower_call/native_table/net_classes_state.rs index 06817aa5cc..d3c6148d3c 100644 --- a/crates/perry-codegen/src/lower_call/native_table/net_classes_state.rs +++ b/crates/perry-codegen/src/lower_call/native_table/net_classes_state.rs @@ -8,7 +8,7 @@ pub(super) const NET_CLASSES_STATE_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_net_block_list_new", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -17,7 +17,7 @@ pub(super) const NET_CLASSES_STATE_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_net_socket_address_new", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -53,7 +53,7 @@ pub(super) const NET_CLASSES_STATE_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_set_type_of_service", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -116,7 +116,7 @@ pub(super) const NET_CLASSES_STATE_ROWS: &[NativeModSig] = &[ class_filter: Some("BlockList"), runtime: "js_net_block_list_rules", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "net", diff --git a/crates/perry-codegen/src/lower_call/native_table/net_events.rs b/crates/perry-codegen/src/lower_call/native_table/net_events.rs index 07d6b9bd3e..c885104899 100644 --- a/crates/perry-codegen/src/lower_call/native_table/net_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/net_events.rs @@ -24,7 +24,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_net_socket_connect", args: &[NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Factory alias: `net.connect(...)` is the spec'd alias for // `net.createConnection(...)`. Pre-issue-#422 only the @@ -39,7 +39,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_net_socket_connect", args: &[NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `net.createServer` and callable `net.Server` are normally rewritten to // `Expr::NetCreateServer` so the one-arg listener shorthand is preserved. @@ -52,7 +52,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_net_create_server", args: &[NA_PTR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -61,7 +61,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_net_create_server", args: &[NA_PTR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Constructor: `new net.Socket()` allocates an unconnected socket // handle whose TCP connection is deferred until `sock.connect(port, @@ -78,7 +78,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_net_socket_alloc", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -236,7 +236,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -245,7 +245,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -256,7 +256,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ // callback is passed through but ignored. Returns the socket handle. runtime: "js_net_socket_set_timeout", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -266,7 +266,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ // #4973: real setEncoding — switches 'data' delivery to strings. runtime: "js_net_socket_set_encoding", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -275,7 +275,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -284,7 +284,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -293,7 +293,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_ref", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -302,7 +302,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_unref", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -311,7 +311,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -320,7 +320,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -329,7 +329,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Issue #2131 — `socket.address()` returns the local bind address // (`{ address, family, port }`). Captured at connect/accept time and @@ -504,7 +504,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ // listeners on sockets owned by perry-ext-net. runtime: "js_ext_net_socket_once", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -522,7 +522,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_remove_listener", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -531,7 +531,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_remove_listener", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -540,7 +540,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_remove_all_listeners", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -575,7 +575,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_listeners", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "net", @@ -584,7 +584,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_raw_listeners", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, // Issue #2131 — `socket.resetAndDestroy()` is the "send RST then // destroy" variant; we alias to `destroy()` (FIN-then-close) for @@ -597,7 +597,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Socket"), runtime: "js_net_socket_reset_and_destroy", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // upgradeToTLS returns a Promise (handle pointer) — await it to wait // for the TLS handshake before sending anything over the upgraded stream. @@ -629,7 +629,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ext_tls_connect", args: &[NA_F64, NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // ========== net.Server (issue #1123 followup) ========== // Server-side TCP via `net.createServer(...).listen(port, cb)`. The @@ -701,7 +701,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -710,7 +710,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -719,7 +719,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_noop_self", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Issue #2131 — `net.Server` EventEmitter surface beyond // `on`/`addListener`. Same shape as the Socket entries above; the @@ -732,7 +732,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_once", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -741,7 +741,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_remove_listener", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -750,7 +750,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_remove_listener", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -759,7 +759,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_remove_all_listeners", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "net", @@ -789,7 +789,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_listeners", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "net", @@ -798,7 +798,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_net_server_raw_listeners", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== node:stream — Readable.from / Duplex.from (#631/#1532) ========== // The other stream constructors (`new Readable(opts)` etc.) are wired @@ -1514,7 +1514,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_stream_method_event_names", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "stream", @@ -1532,7 +1532,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_stream_method_listeners", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "stream", @@ -1541,7 +1541,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_stream_method_raw_listeners", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== Events ========== NativeModSig { @@ -1551,7 +1551,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_event_emitter_new", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1572,7 +1572,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ // The listener also stays NA_JSV so runtime validation can throw // ERR_INVALID_ARG_TYPE for non-functions (#3072). args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1591,7 +1591,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ runtime: "js_event_emitter_remove_listener", // NA_JSV (#3072): validate the listener is callable before removal. args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1600,7 +1600,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_event_emitter_remove_all_listeners", args: &[NA_VARARGS], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // EventEmitter additions (#850) — `once` / `addListener` (alias for // `on`) / `prependListener` / `prependOnceListener` / `listenerCount` @@ -1615,7 +1615,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ runtime: "js_event_emitter_once", // NA_JSV (#3072): validate the listener is callable. args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1625,7 +1625,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ runtime: "js_event_emitter_on", // NA_JSV (#3072): validate the listener is callable. args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1635,7 +1635,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ runtime: "js_event_emitter_prepend_listener", // NA_JSV (#3072): validate the listener is callable. args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1645,7 +1645,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ runtime: "js_event_emitter_prepend_once_listener", // NA_JSV (#3072): validate the listener is callable. args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1655,7 +1655,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ runtime: "js_event_emitter_remove_listener", // NA_JSV (#3072): validate the listener is callable. args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1673,7 +1673,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_event_emitter_listeners", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "events", @@ -1682,7 +1682,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_event_emitter_raw_listeners", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "events", @@ -1691,7 +1691,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_event_emitter_event_names", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "events", @@ -1700,7 +1700,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_event_emitter_set_max_listeners", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "events", @@ -1767,7 +1767,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_events_once", args: &[NA_F64, NA_STR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "events", @@ -1776,7 +1776,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_events_on", args: &[NA_F64, NA_STR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "events", @@ -1785,7 +1785,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_events_add_abort_listener", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "events", @@ -1794,7 +1794,7 @@ pub(super) const NET_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_events_get_event_listeners", args: &[NA_F64, NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "events", diff --git a/crates/perry-codegen/src/lower_call/native_table/node_core/dgram_fs_os.rs b/crates/perry-codegen/src/lower_call/native_table/node_core/dgram_fs_os.rs index c21ed41d2e..9a895cf532 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_core/dgram_fs_os.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_core/dgram_fs_os.rs @@ -396,6 +396,6 @@ pub(crate) const NODE_CORE_DGRAM_FS_OS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_os_user_info_options", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, ]; diff --git a/crates/perry-codegen/src/lower_call/native_table/node_core/util_buffer.rs b/crates/perry-codegen/src/lower_call/native_table/node_core/util_buffer.rs index 2bc34f1653..d3096fe38b 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_core/util_buffer.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_core/util_buffer.rs @@ -616,7 +616,7 @@ pub(crate) const NODE_CORE_UTIL_BUFFER_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_buffer_copy_bytes_from", args: &[NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, // #2901: TC39 `Uint8Array.fromBase64(str, opts)` / `fromHex(str)`. // Routed via the buffer module (Uint8Array ≡ Buffer in Perry); the @@ -628,7 +628,7 @@ pub(crate) const NODE_CORE_UTIL_BUFFER_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_u8_from_base64", args: &[NA_STR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "buffer", @@ -637,7 +637,7 @@ pub(crate) const NODE_CORE_UTIL_BUFFER_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_u8_from_hex", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "buffer", @@ -689,7 +689,7 @@ pub(crate) const NODE_CORE_UTIL_BUFFER_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_buffer_transcode", args: &[NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, // Issue #1211: `import { resolveObjectURL } from "node:buffer"`. NativeModSig { diff --git a/crates/perry-codegen/src/lower_call/native_table/node_core_process.rs b/crates/perry-codegen/src/lower_call/native_table/node_core_process.rs index 8a6ddce928..08d8ac5919 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_core_process.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_core_process.rs @@ -382,7 +382,7 @@ pub(super) const NODE_CORE_PROCESS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_process_listeners", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "process", @@ -391,7 +391,7 @@ pub(super) const NODE_CORE_PROCESS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_process_raw_listeners", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "process", @@ -400,7 +400,7 @@ pub(super) const NODE_CORE_PROCESS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_process_event_names", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "process", diff --git a/crates/perry-codegen/src/lower_call/native_table/node_domain.rs b/crates/perry-codegen/src/lower_call/native_table/node_domain.rs index bdb4a87a2a..5643009c54 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_domain.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_domain.rs @@ -8,7 +8,7 @@ pub(super) const NODE_DOMAIN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_domain_create", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "domain", @@ -17,7 +17,7 @@ pub(super) const NODE_DOMAIN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_domain_create", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "domain", @@ -26,7 +26,7 @@ pub(super) const NODE_DOMAIN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_domain_create", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "domain", @@ -35,7 +35,7 @@ pub(super) const NODE_DOMAIN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_domain_on", args: &[NA_STR, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "domain", @@ -44,7 +44,7 @@ pub(super) const NODE_DOMAIN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_domain_on", args: &[NA_STR, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "domain", @@ -89,7 +89,7 @@ pub(super) const NODE_DOMAIN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_domain_add", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "domain", @@ -98,7 +98,7 @@ pub(super) const NODE_DOMAIN_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_domain_remove", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "domain", diff --git a/crates/perry-codegen/src/lower_call/native_table/node_misc.rs b/crates/perry-codegen/src/lower_call/native_table/node_misc.rs index dfdfb02be5..e51fd585f9 100644 --- a/crates/perry-codegen/src/lower_call/native_table/node_misc.rs +++ b/crates/perry-codegen/src/lower_call/native_table/node_misc.rs @@ -243,7 +243,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_querystring_unescape_buffer", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "querystring", @@ -252,7 +252,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_querystring_parse", args: &[NA_F64, NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "querystring", @@ -261,7 +261,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_querystring_parse", args: &[NA_F64, NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "querystring", @@ -289,7 +289,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lru_cache_new", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "lru-cache", @@ -307,7 +307,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lru_cache_set", args: &[NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "lru-cache", @@ -366,7 +366,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_name", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "commander", @@ -375,7 +375,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_description", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "commander", @@ -384,7 +384,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_version", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "commander", @@ -393,7 +393,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_command", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "commander", @@ -402,7 +402,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_option", args: &[NA_STR, NA_STR, NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "commander", @@ -411,7 +411,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_required_option", args: &[NA_STR, NA_STR, NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // .action(cb) — NA_PTR coerces the NaN-boxed closure to its raw i64 // pointer so the runtime can call back through `js_closure_call1`. @@ -422,7 +422,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_action", args: &[NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // .parse(argv) — runtime reads std::env::args() directly; user-provided // argv expression evaluates for side effects but is not forwarded. @@ -435,7 +435,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_parse", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "commander", @@ -444,7 +444,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_opts", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `.argument("")` declares a positional; returns the same handle so // the fluent chain continues (#5137). @@ -455,7 +455,7 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_argument", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `program.args` — a bare member read lowers to this 0-arg getter, which // returns a JS array of the parsed positional arguments (#5137). @@ -466,6 +466,6 @@ pub(super) const NODE_MISC_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_commander_args_array", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, ]; diff --git a/crates/perry-codegen/src/lower_call/native_table/thread_lodash.rs b/crates/perry-codegen/src/lower_call/native_table/thread_lodash.rs index 74eb491d95..a57c0d2908 100644 --- a/crates/perry-codegen/src/lower_call/native_table/thread_lodash.rs +++ b/crates/perry-codegen/src/lower_call/native_table/thread_lodash.rs @@ -12,7 +12,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_read_embedded", args: &[NA_F64], - ret: NR_PTR, + ret: NR_NULLABLE_GCPTR, }, // `embeddedFiles()` — zero-arg, returns a fresh `*mut ArrayHeader` of // `{ name, size, type }` objects (NaN-boxed POINTER by NR_PTR). @@ -23,7 +23,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_embedded_files", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== perry/thread (parallelMap, parallelFilter, spawn) ========== // Runtime expects both args as NaN-boxed f64 values and returns the same @@ -99,7 +99,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_chunk", args: &[NA_PTR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -108,7 +108,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_compact", args: &[NA_PTR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -117,7 +117,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_drop", args: &[NA_PTR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -153,7 +153,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_flatten", args: &[NA_PTR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -162,7 +162,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_uniq", args: &[NA_PTR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -171,7 +171,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_reverse", args: &[NA_PTR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -180,7 +180,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_take", args: &[NA_PTR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -225,7 +225,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_range", args: &[NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -234,7 +234,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_times", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", @@ -252,7 +252,7 @@ pub(super) const THREAD_LODASH_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_lodash_tail", args: &[NA_PTR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "lodash", diff --git a/crates/perry-codegen/src/lower_call/native_table/tls_events.rs b/crates/perry-codegen/src/lower_call/native_table/tls_events.rs index b7a210488a..5068cfb70b 100644 --- a/crates/perry-codegen/src/lower_call/native_table/tls_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/tls_events.rs @@ -15,7 +15,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_tls_create_server", args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -24,7 +24,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_tls_create_server", args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -33,7 +33,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_tls_tlssocket_constructor", args: &[NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -42,7 +42,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_listen", args: &[NA_F64, NA_JSV, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -51,7 +51,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_close", args: &[NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -69,7 +69,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_on", args: &[NA_STR, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -78,7 +78,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_on", args: &[NA_STR, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -87,7 +87,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_once", args: &[NA_STR, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -96,7 +96,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_remove_listener", args: &[NA_STR, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -105,7 +105,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_remove_listener", args: &[NA_STR, NA_JSV], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", @@ -114,7 +114,7 @@ pub(super) const TLS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Server"), runtime: "js_tls_server_remove_all_listeners", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "tls", diff --git a/crates/perry-codegen/src/lower_call/native_table/tui.rs b/crates/perry-codegen/src/lower_call/native_table/tui.rs index fc4fe5d875..704aa3bb4e 100644 --- a/crates/perry-codegen/src/lower_call/native_table/tui.rs +++ b/crates/perry-codegen/src/lower_call/native_table/tui.rs @@ -13,7 +13,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_text", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -22,7 +22,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_box", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -50,7 +50,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_state_alloc", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // state.get() — receiver call, dispatches against class "State" // registered by destructuring.rs. @@ -241,7 +241,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_text_styled", args: &[NA_STR, NA_STR, NA_STR, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // perry/tui Phase 4 — Spacer + ProgressBar widgets. NativeModSig { @@ -251,7 +251,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_spacer", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -260,7 +260,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_progress_bar", args: &[NA_F64, NA_F64, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // perry/tui Phase 4.5 — Spinner / Input / List / Select / TextArea. NativeModSig { @@ -270,7 +270,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_spinner", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -279,7 +279,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_input", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -288,7 +288,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_list", args: &[NA_PTR, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -297,7 +297,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_select", args: &[NA_PTR, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -306,7 +306,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_text_area", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // perry/tui Phase 4.6 — Table + Tabs widgets. Direct-FFI shapes // (positional args); object-literal `Table({headers, rows, selected})` @@ -319,7 +319,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_table", args: &[NA_PTR, NA_PTR, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -328,7 +328,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_tabs", args: &[NA_PTR, NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // perry/tui Phase 4.7 — Input(value, cursor). Direct-call shape; // codegen also dispatches to this from the 2-arg form so the @@ -340,7 +340,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_input_at", args: &[NA_STR, NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // perry/tui Phase 4.7 — AnimatedSpinner. Bare `AnimatedSpinner()` // hits this row with both args defaulted; object-literal opts @@ -352,7 +352,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_animated_spinner", args: &[NA_F64, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // ========== perry/tui Phase 1 — ink-API ergonomics hooks (#679) ========== // useState(initial) — call-site-indexed state cell. Returns the @@ -391,7 +391,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_use_state_tuple", args: &[NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, // useEffect(fn, deps?). Runs fn() on first call or when deps change. // fn is an unboxed closure pointer (NA_PTR); deps is an unboxed @@ -428,7 +428,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_use_ref", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -456,7 +456,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_use_app", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // app.exit() / app.waitUntilExit() — class_filter routes only when // the receiver was registered as a "TuiApp" instance (see @@ -487,7 +487,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_use_stdout", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // stdout.write(s) / stdout.columns() / stdout.rows(). NativeModSig { @@ -575,7 +575,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_perry_tui_use_focus_manager", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "perry/tui", @@ -615,7 +615,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_readline_create_interface", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "readline", @@ -699,7 +699,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_readline_iterator", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "readline", @@ -708,7 +708,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_readline_pause", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "readline", @@ -717,7 +717,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_readline_resume", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "readline", @@ -762,7 +762,7 @@ pub(super) const TUI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_readline_get_cursor_pos", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "readline", diff --git a/crates/perry-codegen/src/lower_call/native_table/undici.rs b/crates/perry-codegen/src/lower_call/native_table/undici.rs index 19b998512e..56c00c169d 100644 --- a/crates/perry-codegen/src/lower_call/native_table/undici.rs +++ b/crates/perry-codegen/src/lower_call/native_table/undici.rs @@ -25,7 +25,7 @@ pub(super) const UNDICI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_undici_proxy_agent_new", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "undici", @@ -34,7 +34,7 @@ pub(super) const UNDICI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_undici_agent_new", args: &[NA_STR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // ── module-level functions ───────────────────────────────────── NativeModSig { @@ -53,7 +53,7 @@ pub(super) const UNDICI_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_undici_get_global_dispatcher", args: &[], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // `request(url, options?)` — rejects with a "not implemented, use // fetch" error; the row exists so users get that clear message at diff --git a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs index 18a86f86a3..880a733ee1 100644 --- a/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs +++ b/crates/perry-codegen/src/lower_call/native_table/utils_crypto.rs @@ -110,7 +110,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_nodemailer_send_mail", args: &[NA_PTR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "nodemailer", @@ -119,7 +119,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_nodemailer_verify", args: &[], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== dotenv ========== NativeModSig { @@ -211,7 +211,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "backOff", args: &[NA_PTR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== argon2 ========== // Runtime FFI signatures take `*const StringHeader`, NOT NaN-boxed f64. @@ -225,7 +225,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_argon2_hash", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "argon2", @@ -234,7 +234,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_argon2_verify", args: &[NA_STR, NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== bcrypt ========== // Same ABI rule as argon2 above: password / hash args are @@ -247,7 +247,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_bcrypt_hash", args: &[NA_STR, NA_F64], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "bcrypt", @@ -256,7 +256,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_bcrypt_compare", args: &[NA_STR, NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, // ========== node-forge (PKI subset — perry-ext-node-forge) ========== // Namespaced statics (`forge.pki.rsa.generateKeyPair`, @@ -275,7 +275,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_forge_generate_key_pair", args: &[NA_F64], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "node-forge", @@ -284,7 +284,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_forge_create_certificate", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "node-forge", @@ -293,7 +293,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_forge_certificate_from_pem", args: &[NA_STR], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "node-forge", @@ -311,7 +311,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_forge_private_key_from_pem", args: &[NA_STR], - ret: NR_PTR, + ret: NR_JS_VALUE, }, NativeModSig { module: "node-forge", @@ -339,7 +339,7 @@ pub(super) const UTILS_CRYPTO_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_node_forge_md_sha256_create", args: &[], - ret: NR_PTR, + ret: NR_JS_VALUE, }, // Certificate builder instance methods. The receiver (the JS cert // object) is NaN-unboxed to an `i64` `*mut ObjectHeader` and passed diff --git a/crates/perry-codegen/src/lower_call/native_table/ws_events.rs b/crates/perry-codegen/src/lower_call/native_table/ws_events.rs index 7cdadc04c1..aaea329463 100644 --- a/crates/perry-codegen/src/lower_call/native_table/ws_events.rs +++ b/crates/perry-codegen/src/lower_call/native_table/ws_events.rs @@ -16,7 +16,7 @@ pub(super) const WS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ws_server_new", args: &[NA_F64], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "ws", @@ -25,7 +25,7 @@ pub(super) const WS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: None, runtime: "js_ws_connect", args: &[NA_STR], - ret: NR_PTR, + ret: NR_GCPTR, }, NativeModSig { module: "ws", @@ -137,7 +137,7 @@ pub(super) const WS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Client"), runtime: "js_ws_on_client_i64", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, NativeModSig { module: "ws", @@ -146,7 +146,7 @@ pub(super) const WS_EVENTS_ROWS: &[NativeModSig] = &[ class_filter: Some("Client"), runtime: "js_ws_on_client_i64", args: &[NA_STR, NA_PTR], - ret: NR_PTR, + ret: NR_HANDLE_ID, }, // Server-side helpers — the user receives a client handle as a plain // f64 number from `wss.on('connection', (handle) => …)`, then passes diff --git a/crates/perry-runtime/src/async_hooks.rs b/crates/perry-runtime/src/async_hooks.rs index e05f322da0..54ba59d03a 100644 --- a/crates/perry-runtime/src/async_hooks.rs +++ b/crates/perry-runtime/src/async_hooks.rs @@ -218,6 +218,14 @@ pub(crate) fn is_async_resource_handle(handle: i64) -> bool { && ASYNC_RESOURCE_HANDLES.lock().unwrap().contains(&handle) } +/// Diagnostic-only exact membership check for the old raw-box hook handle. +#[inline] +pub(crate) fn is_async_hook_handle(handle: i64) -> bool { + ASYNC_HOOK_HANDLE_COUNT.load(Ordering::Relaxed) != 0 + && handle != 0 + && ASYNC_HOOK_HANDLES.lock().unwrap().contains(&handle) +} + /// Resolve either a native `AsyncResource` handle or the ordinary object used /// for a source-compiled subclass to its native backing allocation. pub(crate) fn resolve_async_resource_handle(receiver: i64) -> Option { @@ -560,6 +568,11 @@ pub extern "C" fn js_async_hooks_create_hook(options: f64) -> i64 { let handle = Box::into_raw(Box::new(AsyncHookHandle { index })) as i64; ASYNC_HOOK_HANDLES.lock().unwrap().insert(handle); ASYNC_HOOK_HANDLE_COUNT.fetch_add(1, Ordering::Relaxed); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::AsyncHook, + ); + } handle } @@ -1234,6 +1247,11 @@ fn new_async_resource_with_public_value( })) as i64; ASYNC_RESOURCE_HANDLES.lock().unwrap().insert(handle); ASYNC_RESOURCE_HANDLE_COUNT.fetch_add(1, Ordering::Relaxed); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::AsyncResource, + ); + } let resource_value = public_resource.unwrap_or_else(|| crate::value::js_nanbox_pointer(handle)); let ids = init_resource_with_trigger(&type_name, resource_value, true, trigger_async_id); unsafe { (*(handle as *mut AsyncResourceHandle)).ids = ids }; diff --git a/crates/perry-runtime/src/buffer/header.rs b/crates/perry-runtime/src/buffer/header.rs index d1224faefc..b7a42a48b0 100644 --- a/crates/perry-runtime/src/buffer/header.rs +++ b/crates/perry-runtime/src/buffer/header.rs @@ -49,6 +49,16 @@ fn external_buffers() -> &'static Mutex> { EXTERNAL_BUFFER_REGISTRY.get_or_init(|| Mutex::new(HashSet::new())) } +/// Diagnostic-only exact membership check for the legacy external-buffer ABI. +#[inline] +pub fn is_external_buffer(addr: usize) -> bool { + EXTERNAL_BUFFERS_NONEMPTY.load(std::sync::atomic::Ordering::Acquire) + && external_buffers() + .lock() + .map(|r| r.contains(&addr)) + .unwrap_or(false) +} + fn external_uint8arrays() -> &'static Mutex> { EXTERNAL_UINT8ARRAY_REGISTRY.get_or_init(|| Mutex::new(HashSet::new())) } @@ -603,6 +613,11 @@ pub extern "C" fn js_buffer_register_external(addr: usize) { if let Ok(mut r) = external_buffers().lock() { r.insert(addr); } + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::ExternalBuffer, + ); + } } #[no_mangle] diff --git a/crates/perry-runtime/src/buffer/mod.rs b/crates/perry-runtime/src/buffer/mod.rs index 1a93fd3f93..10b495cc81 100644 --- a/crates/perry-runtime/src/buffer/mod.rs +++ b/crates/perry-runtime/src/buffer/mod.rs @@ -56,8 +56,8 @@ pub(crate) use header::note_buffer_like_registered; pub use header::{ asymmetric_key_meta, buffer_ab_alias, buffer_alloc, buffer_backing_array_buffer, buffer_byte_offset, buffer_data, buffer_data_mut, crypto_key_meta, ensure_buffer_ab_alias, - is_any_array_buffer, is_array_buffer, is_data_view, is_registered_buffer, is_secret_key, - is_shared_array_buffer, is_uint8array_buffer, js_set_crypto_key_death_hook, + is_any_array_buffer, is_array_buffer, is_data_view, is_external_buffer, is_registered_buffer, + is_secret_key, is_shared_array_buffer, is_uint8array_buffer, js_set_crypto_key_death_hook, mark_as_array_buffer, mark_as_asymmetric_key, mark_as_crypto_key, mark_as_data_view, mark_as_secret_key, mark_as_shared_array_buffer, mark_as_uint8array, register_buffer, resolve_buffer_ab_alias, set_buffer_ab_alias, CryptoKeyDeathHookFn, @@ -71,9 +71,9 @@ pub(crate) use header::{ pub(crate) use header::rebind_foreign_buffer; #[cfg(test)] pub(crate) use header::{ - test_buffer_addr_window_bounds, test_buffer_registry_probe_count, test_data_view_registry_len, - test_shared_array_buffer_registry_len, test_uint8array_addr_window_bounds, - test_uint8array_registry_probe_count, + js_buffer_register_external, test_buffer_addr_window_bounds, test_buffer_registry_probe_count, + test_data_view_registry_len, test_shared_array_buffer_registry_len, + test_uint8array_addr_window_bounds, test_uint8array_registry_probe_count, }; // ---- Re-exports: ArrayBuffer detach / transfer (ES2024) ---- diff --git a/crates/perry-runtime/src/hot_diag.rs b/crates/perry-runtime/src/hot_diag.rs index 51615783ca..4695e6743b 100644 --- a/crates/perry-runtime/src/hot_diag.rs +++ b/crates/perry-runtime/src/hot_diag.rs @@ -22,12 +22,12 @@ use std::time::Instant; /// How the diag output is delivered. #[derive(Clone)] -enum Sink { +pub(crate) enum Sink { Stderr, File(String), } -fn sink_from_env(name: &str) -> Option { +pub(crate) fn sink_from_env(name: &str) -> Option { let raw = std::env::var(name).ok()?; let raw = raw.trim(); match raw { @@ -44,7 +44,7 @@ fn sink_from_env(name: &str) -> Option { /// missing-exit-line trap in a second form, and it cost a lane a measurement /// run. Report the first failure on stderr, naming the path and the error, /// and keep writing there. -fn write_sink(sink: &Sink, text: &str) { +pub(crate) fn write_sink(sink: &Sink, text: &str) { match sink { Sink::Stderr => eprint!("{text}"), Sink::File(path) => { @@ -65,6 +65,16 @@ fn write_sink(sink: &Sink, text: &str) { const TICK_EVERY: u32 = 256; const DUMP_INTERVAL_MS: u128 = 1000; +mod receiver_repr; +pub use receiver_repr::{ + receiver_repr_note_constructed, receiver_repr_note_decoded_pointer, receiver_repr_note_value, + receiver_repr_note_wrapped, receiver_repr_on, ReceiverReprFamily, +}; +#[cfg(test)] +pub(crate) use receiver_repr::{ + receiver_repr_test_arm, receiver_repr_test_classification_entries, receiver_repr_test_reset, +}; + // --------------------------------------------------------------------------- // RegExp // --------------------------------------------------------------------------- diff --git a/crates/perry-runtime/src/hot_diag/receiver_repr.rs b/crates/perry-runtime/src/hot_diag/receiver_repr.rs new file mode 100644 index 0000000000..d00cce363a --- /dev/null +++ b/crates/perry-runtime/src/hot_diag/receiver_repr.rs @@ -0,0 +1,509 @@ +//! Receiver-representation migration ledger. +//! +//! The classifiers in this module are deliberately diagnostics-only. Every +//! caller first tests [`receiver_repr_on`], so an unarmed process pays one +//! relaxed load and enters none of the range, registry, or ownership probes. + +use super::{sink_from_env, write_sink, Sink}; +use std::fmt::Write as _; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +const FAMILY_COUNT: usize = 13; +const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; +const TAG_MASK: u64 = 0xFFFF_0000_0000_0000; +const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; +const SNAPSHOT_EVERY: u64 = 4096; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum ReceiverReprFamily { + Common, + Fetch, + Zlib, + Proxy, + Timer, + Text, + Tui, + AsyncHook, + AsyncResource, + SymbolGlobal, + ExternalBuffer, + Sab, + NullStub, +} + +impl ReceiverReprFamily { + #[cfg(test)] + const ALL: [Self; FAMILY_COUNT] = [ + Self::Common, + Self::Fetch, + Self::Zlib, + Self::Proxy, + Self::Timer, + Self::Text, + Self::Tui, + Self::AsyncHook, + Self::AsyncResource, + Self::SymbolGlobal, + Self::ExternalBuffer, + Self::Sab, + Self::NullStub, + ]; + + const NAMES: [&'static str; FAMILY_COUNT] = [ + "common", + "fetch", + "zlib", + "proxy", + "timer", + "text", + "tui", + "async_hook", + "async_resource", + "symbol_global", + "external_buffer", + "sab", + "null_stub", + ]; + + #[inline] + const fn index(self) -> usize { + self as usize + } +} + +struct FamilyCounters { + constructed: [AtomicU64; FAMILY_COUNT], + observed_old: [AtomicU64; FAMILY_COUNT], + observed_wrapped: [AtomicU64; FAMILY_COUNT], +} + +impl FamilyCounters { + const fn new() -> Self { + Self { + constructed: [const { AtomicU64::new(0) }; FAMILY_COUNT], + observed_old: [const { AtomicU64::new(0) }; FAMILY_COUNT], + observed_wrapped: [const { AtomicU64::new(0) }; FAMILY_COUNT], + } + } +} + +static COUNTERS: FamilyCounters = FamilyCounters::new(); +static BARE_MANAGED: AtomicU64 = AtomicU64::new(0); +static INVALID_POINTER_ZERO: AtomicU64 = AtomicU64::new(0); +static DIRECT_MISMATCH: AtomicU64 = AtomicU64::new(0); +static EVENTS: AtomicU64 = AtomicU64::new(0); +static RECEIVER_REPR_SINK: OnceLock> = OnceLock::new(); +static RECEIVER_REPR_ON: AtomicBool = AtomicBool::new(false); +static LAST_DUMP: Mutex> = Mutex::new(None); + +#[cfg(test)] +static TEST_FORCE_ON: AtomicBool = AtomicBool::new(false); +#[cfg(test)] +static TEST_CLASSIFICATION_ENTRIES: AtomicU64 = AtomicU64::new(0); + +fn receiver_repr_sink() -> &'static Option { + RECEIVER_REPR_SINK.get_or_init(|| { + let sink = sink_from_env("PERRY_RECEIVER_REPR_DIAG"); + RECEIVER_REPR_ON.store(sink.is_some(), Ordering::Relaxed); + sink + }) +} + +/// One relaxed load after the environment has been parsed once. +#[inline] +pub fn receiver_repr_on() -> bool { + if RECEIVER_REPR_SINK.get().is_none() { + receiver_repr_sink(); + } + let armed = RECEIVER_REPR_ON.load(Ordering::Relaxed); + #[cfg(test)] + let armed = armed || TEST_FORCE_ON.load(Ordering::Relaxed); + armed +} + +#[inline] +pub fn receiver_repr_note_constructed(family: ReceiverReprFamily) { + COUNTERS.constructed[family.index()].fetch_add(1, Ordering::Relaxed); + maybe_dump(); +} + +/// Reserved for the later wrapper PRs; PR 1 records the zero baseline. +#[inline] +pub fn receiver_repr_note_wrapped(family: ReceiverReprFamily) { + COUNTERS.observed_wrapped[family.index()].fetch_add(1, Ordering::Relaxed); + maybe_dump(); +} + +/// Observe a dynamic receiver before a NaN-box tag has been stripped. +/// +/// This function intentionally does not repeat the armed test. Its callers are +/// the audited funnels, each with exactly one [`receiver_repr_on`] guard. +#[inline] +pub fn receiver_repr_note_value(value: f64) { + test_note_classification_entry(); + let bits = value.to_bits(); + if bits & TAG_MASK == POINTER_TAG { + let addr = (bits & POINTER_MASK) as usize; + if addr == 0 { + INVALID_POINTER_ZERO.fetch_add(1, Ordering::Relaxed); + } else { + observe_pointer(addr); + } + } else if bits >> 48 == 0 && bits != 0 { + // This is the compatibility shape PR 10 removes: an allocator-owned + // address bitcast directly to f64 with no pointer tag. + if unsafe { crate::value::addr_class::try_read_tracked_gc_header(bits as usize) }.is_some() + { + BARE_MANAGED.fetch_add(1, Ordering::Relaxed); + } + } + maybe_dump(); +} + +/// Observe a funnel which has already stripped `POINTER_TAG` by contract. +#[inline] +pub fn receiver_repr_note_decoded_pointer(addr: usize) { + test_note_classification_entry(); + if addr == 0 { + INVALID_POINTER_ZERO.fetch_add(1, Ordering::Relaxed); + } else { + observe_pointer(addr); + } + maybe_dump(); +} + +#[inline] +fn mark_old(family: ReceiverReprFamily) { + COUNTERS.observed_old[family.index()].fetch_add(1, Ordering::Relaxed); +} + +fn observe_pointer(addr: usize) { + // The small bands are disjoint, except that timer/text/TUI registries use + // small ids too. Count every matching semantic family: the old encoding + // contains no provenance bit with which to choose one of colliding id 1s. + if crate::value::addr_class::is_common_handle_band(addr) { + mark_old(ReceiverReprFamily::Common); + } + if crate::value::addr_class::is_fetch_handle_band(addr) { + mark_old(ReceiverReprFamily::Fetch); + } + if crate::value::addr_class::is_zlib_handle_band(addr) { + mark_old(ReceiverReprFamily::Zlib); + } + if crate::value::addr_class::is_proxy_id_band(addr) { + let boxed = f64::from_bits(POINTER_TAG | addr as u64); + if crate::proxy::js_proxy_is_proxy(boxed) != 0 { + mark_old(ReceiverReprFamily::Proxy); + } + } + if crate::timer::is_known_timer_id(addr as i64) { + mark_old(ReceiverReprFamily::Timer); + } + if addr as i64 == crate::text::TEXT_ENCODER_SENTINEL_ID + || crate::text::is_known_text_decoder_id(addr as i64) + { + mark_old(ReceiverReprFamily::Text); + } + if crate::tui::is_known_handle(addr as i64) { + mark_old(ReceiverReprFamily::Tui); + } + if crate::async_hooks::is_async_hook_handle(addr as i64) { + mark_old(ReceiverReprFamily::AsyncHook); + } + if crate::async_hooks::is_async_resource_handle(addr as i64) { + mark_old(ReceiverReprFamily::AsyncResource); + } + if crate::object::is_null_stub_address(addr) { + mark_old(ReceiverReprFamily::NullStub); + } + if crate::shared_sab::is_shared_sab(addr) { + mark_old(ReceiverReprFamily::Sab); + } + + let tracked = unsafe { crate::value::addr_class::try_read_tracked_gc_header(addr) }; + if tracked.is_none() { + if crate::symbol::is_registered_symbol(addr) { + mark_old(ReceiverReprFamily::SymbolGlobal); + } + if crate::buffer::is_external_buffer(addr) { + mark_old(ReceiverReprFamily::ExternalBuffer); + } + return; + } + + // Debug-only trust-the-tag audit. The ownership-derived header makes the + // direct byte readable; release builds carry no direct-load probe at all. + #[cfg(debug_assertions)] + unsafe { + let derived = tracked.unwrap(); + let derived_ptr = derived.as_ptr() as *const crate::gc::GcHeader; + let direct = + (addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; + if direct != derived_ptr || (*direct).obj_type != (*derived_ptr).obj_type { + DIRECT_MISMATCH.fetch_add(1, Ordering::Relaxed); + } + } +} + +#[inline] +fn maybe_dump() { + let event = EVENTS.fetch_add(1, Ordering::Relaxed); + if event != 0 && event % SNAPSHOT_EVERY != 0 { + return; + } + let mut last = LAST_DUMP.lock().unwrap(); + if last.is_some_and(|instant| instant.elapsed() < Duration::from_secs(1)) { + return; + } + *last = Some(Instant::now()); + if let Some(sink) = receiver_repr_sink() { + write_sink(sink, &render()); + } +} + +fn append_family_counts(out: &mut String, values: &[AtomicU64; FAMILY_COUNT]) { + for (index, name) in ReceiverReprFamily::NAMES.iter().enumerate() { + if index != 0 { + out.push(' '); + } + let _ = write!(out, "{name}={}", values[index].load(Ordering::Relaxed)); + } +} + +fn render() -> String { + let mut out = String::with_capacity(768); + out.push_str("[receiver-repr-diag] constructed "); + append_family_counts(&mut out, &COUNTERS.constructed); + out.push_str("; observed_old "); + append_family_counts(&mut out, &COUNTERS.observed_old); + out.push_str("; observed_wrapped "); + append_family_counts(&mut out, &COUNTERS.observed_wrapped); + let _ = writeln!( + out, + "; bare_managed={}; invalid_pointer_zero={}; direct_mismatch={}", + BARE_MANAGED.load(Ordering::Relaxed), + INVALID_POINTER_ZERO.load(Ordering::Relaxed), + DIRECT_MISMATCH.load(Ordering::Relaxed), + ); + out +} + +#[cfg(test)] +#[inline] +fn test_note_classification_entry() { + TEST_CLASSIFICATION_ENTRIES.fetch_add(1, Ordering::Relaxed); +} + +#[cfg(not(test))] +#[inline] +fn test_note_classification_entry() {} + +#[cfg(test)] +pub(crate) fn receiver_repr_test_arm(armed: bool) { + receiver_repr_sink(); + RECEIVER_REPR_ON.store(armed, Ordering::Relaxed); + TEST_FORCE_ON.store(armed, Ordering::Relaxed); +} + +#[cfg(test)] +pub(crate) fn receiver_repr_test_classification_entries() -> u64 { + TEST_CLASSIFICATION_ENTRIES.load(Ordering::Relaxed) +} + +#[cfg(test)] +pub(crate) fn receiver_repr_test_reset() { + for family in ReceiverReprFamily::ALL { + COUNTERS.constructed[family.index()].store(0, Ordering::Relaxed); + COUNTERS.observed_old[family.index()].store(0, Ordering::Relaxed); + COUNTERS.observed_wrapped[family.index()].store(0, Ordering::Relaxed); + } + BARE_MANAGED.store(0, Ordering::Relaxed); + INVALID_POINTER_ZERO.store(0, Ordering::Relaxed); + DIRECT_MISMATCH.store(0, Ordering::Relaxed); + EVENTS.store(0, Ordering::Relaxed); + TEST_CLASSIFICATION_ENTRIES.store(0, Ordering::Relaxed); + *LAST_DUMP.lock().unwrap() = None; +} + +#[cfg(test)] +pub(crate) fn receiver_repr_test_snapshot(family: ReceiverReprFamily) -> (u64, u64, u64) { + let index = family.index(); + ( + COUNTERS.constructed[index].load(Ordering::Relaxed), + COUNTERS.observed_old[index].load(Ordering::Relaxed), + COUNTERS.observed_wrapped[index].load(Ordering::Relaxed), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn assert_fixture(family: ReceiverReprFamily, construct: impl FnOnce() -> (usize, bool)) { + receiver_repr_test_reset(); + receiver_repr_test_arm(true); + let (value, already_nanboxed) = construct(); + if already_nanboxed { + receiver_repr_note_value(f64::from_bits(value as u64)); + } else { + receiver_repr_note_decoded_pointer(value); + } + let (constructed, observed, wrapped) = receiver_repr_test_snapshot(family); + assert!( + constructed > 0, + "{family:?} constructor did not move its bucket" + ); + assert!( + observed > 0, + "{family:?} receiver did not move observed_old" + ); + assert_eq!(wrapped, 0, "PR 1 must not create wrappers"); + } + + #[test] + fn receiver_repr_family_fixtures_move_constructed_and_observed_old() { + // These three producers live in perry-stdlib, below perry-runtime in + // the dependency graph. Their exact producer calls are pinned by the + // source-witness test below; the fixtures exercise their audited bands. + assert_fixture(ReceiverReprFamily::Common, || { + receiver_repr_note_constructed(ReceiverReprFamily::Common); + (2, false) + }); + assert_fixture(ReceiverReprFamily::Fetch, || { + receiver_repr_note_constructed(ReceiverReprFamily::Fetch); + (crate::value::addr_class::FETCH_HANDLE_BAND_START, false) + }); + assert_fixture(ReceiverReprFamily::Zlib, || { + receiver_repr_note_constructed(ReceiverReprFamily::Zlib); + (crate::value::addr_class::ZLIB_HANDLE_BAND_START, false) + }); + assert_fixture(ReceiverReprFamily::Proxy, || { + let object = || { + let ptr = crate::object::js_object_alloc(0, 0); + f64::from_bits(crate::value::JSValue::pointer(ptr.cast()).bits()) + }; + ( + crate::proxy::js_proxy_new(object(), object()).to_bits() as usize, + true, + ) + }); + assert_fixture(ReceiverReprFamily::Timer, || { + ( + crate::timer::js_set_timeout_callback(0, 60_000.0) as usize, + false, + ) + }); + assert_fixture(ReceiverReprFamily::Text, || { + (crate::text::js_text_encoder_new() as usize, false) + }); + assert_fixture(ReceiverReprFamily::Tui, || { + let mut handle = crate::tui::state::js_perry_tui_state_alloc(0.0); + if handle == 0 { + handle = crate::tui::state::js_perry_tui_state_alloc(0.0); + } + (handle as usize, false) + }); + assert_fixture(ReceiverReprFamily::AsyncHook, || { + let options = crate::object::js_object_alloc(0, 0); + let value = f64::from_bits(crate::value::JSValue::pointer(options.cast()).bits()); + ( + crate::async_hooks::js_async_hooks_create_hook(value) as usize, + false, + ) + }); + assert_fixture(ReceiverReprFamily::AsyncResource, || { + let name = crate::string::js_string_from_bytes(b"receiver-repr".as_ptr(), 13); + let type_value = f64::from_bits(crate::value::js_nanbox_string(name as i64).to_bits()); + let options = f64::from_bits(crate::value::TAG_UNDEFINED); + ( + crate::async_hooks::js_async_resource_new(type_value, options) as usize, + false, + ) + }); + assert_fixture(ReceiverReprFamily::SymbolGlobal, || { + ( + crate::symbol::well_known_symbol("receiverReprFixture") as usize, + false, + ) + }); + assert_fixture(ReceiverReprFamily::ExternalBuffer, || { + let buffer = Box::into_raw(Box::new(crate::buffer::BufferHeader { + length: 0, + capacity: 0, + })); + crate::buffer::js_buffer_register_external(buffer as usize); + (buffer as usize, false) + }); + assert_fixture(ReceiverReprFamily::Sab, || { + (crate::shared_sab::alloc_shared_sab(1) as usize, false) + }); + assert_fixture(ReceiverReprFamily::NullStub, || { + ( + crate::object::js_unresolved_namespace_stub().to_bits() as usize, + true, + ) + }); + + let line = render(); + assert!(line.starts_with("[receiver-repr-diag] constructed common=0")); + assert!(line.contains("null_stub=1; observed_old")); + assert!(line.contains("null_stub=1; observed_wrapped")); + assert!(line.ends_with("bare_managed=0; invalid_pointer_zero=0; direct_mismatch=0\n")); + receiver_repr_test_arm(false); + } + + /// Source witnesses bind the fixture above to every audited producer. If a + /// producer-side bump is dropped, its exact occurrence floor fails here. + #[test] + fn receiver_repr_every_family_producer_keeps_its_constructed_bump() { + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest.parent().and_then(Path::parent).unwrap(); + let witnesses = [ + ("crates/perry-stdlib/src/common/handle.rs", "Common", 2), + ("crates/perry-stdlib/src/fetch/mod.rs", "Fetch", 1), + ("crates/perry-stdlib/src/zlib.rs", "Zlib", 1), + ("crates/perry-runtime/src/proxy.rs", "Proxy", 1), + ("crates/perry-runtime/src/timer.rs", "Timer", 1), + ("crates/perry-runtime/src/text.rs", "Text", 2), + ("crates/perry-runtime/src/tui/tree.rs", "Tui", 1), + ("crates/perry-runtime/src/tui/state.rs", "Tui", 1), + ("crates/perry-runtime/src/tui/hooks.rs", "Tui", 4), + ("crates/perry-runtime/src/async_hooks.rs", "AsyncHook", 1), + ( + "crates/perry-runtime/src/async_hooks.rs", + "AsyncResource", + 1, + ), + ("crates/perry-runtime/src/symbol.rs", "SymbolGlobal", 2), + ( + "crates/perry-runtime/src/symbol/constructors.rs", + "SymbolGlobal", + 1, + ), + ( + "crates/perry-runtime/src/buffer/header.rs", + "ExternalBuffer", + 1, + ), + ("crates/perry-runtime/src/shared_sab.rs", "Sab", 1), + ( + "crates/perry-runtime/src/object/null_stub.rs", + "NullStub", + 1, + ), + ]; + for (relative, family, expected) in witnesses { + let text = std::fs::read_to_string(workspace.join(relative)).unwrap(); + let needle = format!("ReceiverReprFamily::{family}"); + assert_eq!( + text.matches(&needle).count(), + expected, + "producer-side diagnostic bump changed in {relative}" + ); + } + } +} diff --git a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs index dfa7a37746..0eb3ea84d0 100644 --- a/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs +++ b/crates/perry-runtime/src/object/field_get_set/get_field_by_name_tail.rs @@ -8,6 +8,10 @@ pub(crate) fn get_field_by_name_object_tail( obj: *const ObjectHeader, key: *const crate::StringHeader, ) -> JSValue { + if crate::hot_diag::receiver_repr_on() { + let addr = (obj as u64 & 0x0000_FFFF_FFFF_FFFF) as usize; + crate::hot_diag::receiver_repr_note_decoded_pointer(addr); + } // An elements-backed Array-subclass instance answers its indices and // `length` from its store; an absent index falls through to the ordinary // lookup, which reaches the prototype chain (the shape has no index keys). diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index 5df83c2564..a288bae136 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -538,6 +538,9 @@ pub extern "C" fn js_object_get_field_ic_miss( cache_slot: *mut PicCacheSlot, ) -> f64 { use crate::hot_diag::IcMissReason as R; + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_decoded_pointer(obj as usize); + } let diag = crate::hot_diag::ic_on(); // SSO receiver — never cacheable. Route through the SSO-aware // `js_object_get_field_by_name` which handles `.length` inline diff --git a/crates/perry-runtime/src/object/mod.rs b/crates/perry-runtime/src/object/mod.rs index 77a02c25a4..4d247a88a9 100644 --- a/crates/perry-runtime/src/object/mod.rs +++ b/crates/perry-runtime/src/object/mod.rs @@ -108,8 +108,8 @@ pub(crate) use live_slots::set_object_live_slot_count; pub use live_slots::{ js_object_live_slot_count, object_live_slot_count, perry_object_header_abi_revision, }; +pub(crate) use null_stub::{is_null_stub_address, NullObjectBytes, NULL_OBJECT_BYTES}; pub use null_stub::{js_unresolved_default_call, js_unresolved_namespace_stub}; -pub(crate) use null_stub::{NullObjectBytes, NULL_OBJECT_BYTES}; #[cfg(test)] pub(crate) use side_table_roots::test_transition_cache_insert; pub(crate) use side_table_roots::{ diff --git a/crates/perry-runtime/src/object/native_call_method.rs b/crates/perry-runtime/src/object/native_call_method.rs index c7452709a7..175adf6fd4 100644 --- a/crates/perry-runtime/src/object/native_call_method.rs +++ b/crates/perry-runtime/src/object/native_call_method.rs @@ -1022,6 +1022,9 @@ fn throw_object_to_string_not_function() -> ! { #[inline] unsafe fn gc_pointer_and_type_from_value(value: f64) -> Option<(*const u8, u8)> { + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_value(value); + } let jsval = JSValue::from_bits(value.to_bits()); let ptr = if jsval.is_pointer() { jsval.as_pointer::() @@ -2761,3 +2764,62 @@ mod primitive_dataprop_recovery_tests { ); } } + +#[cfg(test)] +mod receiver_repr_guard_tests { + use super::*; + + /// An unarmed ledger must stop at each funnel's single guard. The + /// diagnostic helper increments a test-only entry counter before doing any + /// classification, so removing any guard below makes this test fail. + #[test] + fn receiver_repr_unarmed_funnels_never_enter_classification() { + crate::hot_diag::receiver_repr_test_reset(); + crate::hot_diag::receiver_repr_test_arm(false); + + unsafe { + assert!(gc_pointer_and_type_from_value(1.25).is_none()); + let scope = crate::gc::RuntimeHandleScope::new(); + let object = scope.root_nanbox_f64(1.25); + assert!(primitive_methods::dispatch_primitive( + &scope, + &object, + &[], + 1.25, + "receiver_repr_miss", + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ) + .is_none()); + } + assert_eq!( + crate::object::prototype_chain::object_static_prototype(1), + None + ); + assert_eq!( + crate::object::field_get_set::get_field_by_name_object_tail( + std::ptr::null(), + std::ptr::null(), + ) + .bits(), + crate::value::TAG_UNDEFINED, + ); + assert_eq!( + crate::object::field_get_set::js_object_get_field_ic_miss( + std::ptr::null(), + std::ptr::null(), + std::ptr::null_mut(), + ) + .to_bits(), + crate::value::TAG_UNDEFINED, + ); + + assert_eq!( + crate::hot_diag::receiver_repr_test_classification_entries(), + 0, + "an unarmed funnel entered receiver-representation classification" + ); + } +} diff --git a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs index 47ac13f0c5..25f6984852 100644 --- a/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs +++ b/crates/perry-runtime/src/object/native_call_method/primitive_methods.rs @@ -12,6 +12,9 @@ pub(super) unsafe fn dispatch_primitive( args_ptr: *const f64, args_len: usize, ) -> Option { + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_value(object); + } let jsval = JSValue::from_bits(object.to_bits()); let raw_bits = object.to_bits(); let refreshed_args = || crate::gc::RuntimeHandleScope::refreshed_nanbox_f64_slice(arg_handles); diff --git a/crates/perry-runtime/src/object/null_stub.rs b/crates/perry-runtime/src/object/null_stub.rs index e2c6cb0d84..ce8ef3476c 100644 --- a/crates/perry-runtime/src/object/null_stub.rs +++ b/crates/perry-runtime/src/object/null_stub.rs @@ -36,6 +36,11 @@ const _: () = #[no_mangle] pub extern "C" fn js_unresolved_namespace_stub() -> f64 { let null_obj_ptr = &NULL_OBJECT_BYTES as *const NullObjectBytes as *mut u8; + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::NullStub, + ); + } f64::from_bits(crate::JSValue::pointer(null_obj_ptr).bits()) } @@ -69,3 +74,8 @@ pub(crate) static NULL_OBJECT_BYTES: NullObjectBytes = NullObjectBytes { parent_class_id: 0, meta_and_padding: 0, }; + +#[inline] +pub(crate) fn is_null_stub_address(addr: usize) -> bool { + addr == &NULL_OBJECT_BYTES as *const NullObjectBytes as usize +} diff --git a/crates/perry-runtime/src/object/prototype_chain.rs b/crates/perry-runtime/src/object/prototype_chain.rs index 29a9ce8084..f5ee37dffa 100644 --- a/crates/perry-runtime/src/object/prototype_chain.rs +++ b/crates/perry-runtime/src/object/prototype_chain.rs @@ -302,6 +302,9 @@ fn object_set_static_prototype_impl(obj_ptr: usize, proto_bits: u64, link_kind: /// when no explicit prototype has been recorded (the object still has its /// default prototype); `Some(TAG_NULL)` when it was explicitly set to `null`. pub fn object_static_prototype(obj_ptr: usize) -> Option { + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_decoded_pointer(obj_ptr); + } // #6759 Phase B: a shaped object answers from its own meta record — two // dependent loads, no global latch, no mutex — and NEVER has a residual // registry entry (the write path classifies identically), so a meta diff --git a/crates/perry-runtime/src/proxy.rs b/crates/perry-runtime/src/proxy.rs index 849c0c32d4..bf7b2e2757 100644 --- a/crates/perry-runtime/src/proxy.rs +++ b/crates/perry-runtime/src/proxy.rs @@ -479,6 +479,11 @@ pub extern "C" fn js_proxy_new(target: f64, handler: f64) -> f64 { PROXY_FULL_TRACE_ACTIVE.with(|active| active.set(true)); } let encoded = encode_proxy_id(id) as u64; + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::Proxy, + ); + } f64::from_bits(POINTER_TAG | (encoded & POINTER_MASK)) }) } diff --git a/crates/perry-runtime/src/shared_sab.rs b/crates/perry-runtime/src/shared_sab.rs index 60a4ac6c64..bf584c9649 100644 --- a/crates/perry-runtime/src/shared_sab.rs +++ b/crates/perry-runtime/src/shared_sab.rs @@ -84,6 +84,9 @@ pub fn alloc_shared_sab(size: u32) -> *mut BufferHeader { .lock() .unwrap_or_else(|e| e.into_inner()) .insert(buf as usize); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Sab); + } buf } diff --git a/crates/perry-runtime/src/symbol.rs b/crates/perry-runtime/src/symbol.rs index 3abb55867f..6725d3a010 100644 --- a/crates/perry-runtime/src/symbol.rs +++ b/crates/perry-runtime/src/symbol.rs @@ -332,6 +332,11 @@ pub fn well_known_symbol(short_name: &str) -> *mut SymbolHeader { id: next_id(), }); let sym_ptr = Box::into_raw(boxed); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::SymbolGlobal, + ); + } // Fully initialize the symbol's side tables BEFORE publishing it in // the cache. A concurrent reader that observes the pointer via the // cache must already see a complete view (description present, @@ -650,6 +655,11 @@ pub fn intl_legacy_constructed_symbol() -> f64 { id: next_id(), }); let sym_ptr = Box::into_raw(boxed) as usize; + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::SymbolGlobal, + ); + } record_registered_symbol_description(sym_ptr, "IntlLegacyConstructedSymbol"); register_symbol_pointer(sym_ptr); *guard = Some(sym_ptr); diff --git a/crates/perry-runtime/src/symbol/constructors.rs b/crates/perry-runtime/src/symbol/constructors.rs index 34c8fa7026..e071523a34 100644 --- a/crates/perry-runtime/src/symbol/constructors.rs +++ b/crates/perry-runtime/src/symbol/constructors.rs @@ -107,6 +107,11 @@ pub unsafe extern "C" fn js_symbol_for(key_f64: f64) -> f64 { id: next_id(), }); let sym_ptr = Box::into_raw(boxed); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed( + crate::hot_diag::ReceiverReprFamily::SymbolGlobal, + ); + } // Fully initialize the side tables BEFORE publishing the pointer in // the registry. Otherwise a concurrent `Symbol.for("same_key")` on // another thread can see the pointer via the registry but get None diff --git a/crates/perry-runtime/src/text.rs b/crates/perry-runtime/src/text.rs index cb1b098a79..98fb16f1ae 100644 --- a/crates/perry-runtime/src/text.rs +++ b/crates/perry-runtime/src/text.rs @@ -104,6 +104,9 @@ pub(crate) fn text_encoder_string_ptr(value: f64) -> *const StringHeader { /// decoder sentinel purely for debuggability. #[no_mangle] pub extern "C" fn js_text_encoder_new() -> i64 { + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Text); + } TEXT_ENCODER_SENTINEL_ID } @@ -172,6 +175,9 @@ fn register_decoder( id }; DECODER_REGISTRY.lock().unwrap().insert(id, state); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Text); + } id } diff --git a/crates/perry-runtime/src/timer.rs b/crates/perry-runtime/src/timer.rs index d0bd6ce00b..e07fb14677 100644 --- a/crates/perry-runtime/src/timer.rs +++ b/crates/perry-runtime/src/timer.rs @@ -530,6 +530,9 @@ fn next_timer_id() -> i64 { let mut next = NEXT_TIMER_ID.lock().unwrap(); let current = *next; *next += 1; + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Timer); + } current } diff --git a/crates/perry-runtime/src/tui/hooks.rs b/crates/perry-runtime/src/tui/hooks.rs index 7d9f8ebdc9..8e660ac249 100644 --- a/crates/perry-runtime/src/tui/hooks.rs +++ b/crates/perry-runtime/src/tui/hooks.rs @@ -71,6 +71,10 @@ enum HookSlot { } static SLOTS: Mutex> = Mutex::new(Vec::new()); + +pub(crate) fn contains_handle(handle: i64) -> bool { + handle > 0 && (handle as usize) <= crate::gc::lock_gc_root_registry(&SLOTS).len() +} /// Per-frame hook index, reset by the run loop before each component call. static NEXT_HOOK_IDX: AtomicUsize = AtomicUsize::new(0); @@ -547,6 +551,9 @@ pub extern "C" fn js_perry_tui_use_ref(initial: f64) -> i64 { value_bits: initial.to_bits(), }; } + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); + } (idx as i64) + 1 } @@ -593,6 +600,9 @@ const APP_HANDLE: i64 = 1; /// class_filter: Some("App") rows. #[no_mangle] pub extern "C" fn js_perry_tui_use_app() -> i64 { + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); + } APP_HANDLE } @@ -630,6 +640,9 @@ const STDOUT_HANDLE: i64 = 2; /// Some("Stdout") rows. #[no_mangle] pub extern "C" fn js_perry_tui_use_stdout() -> i64 { + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); + } STDOUT_HANDLE } @@ -813,6 +826,9 @@ const FOCUS_MANAGER_HANDLE: i64 = 3; #[no_mangle] pub extern "C" fn js_perry_tui_use_focus_manager() -> i64 { + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); + } FOCUS_MANAGER_HANDLE } diff --git a/crates/perry-runtime/src/tui/mod.rs b/crates/perry-runtime/src/tui/mod.rs index 88aa7d8fb1..963a233728 100644 --- a/crates/perry-runtime/src/tui/mod.rs +++ b/crates/perry-runtime/src/tui/mod.rs @@ -43,3 +43,9 @@ pub mod run; pub mod state; pub mod style; pub mod tree; + +pub(crate) fn is_known_handle(handle: i64) -> bool { + tree::contains_handle(handle) + || state::contains_handle(handle) + || hooks::contains_handle(handle) +} diff --git a/crates/perry-runtime/src/tui/state.rs b/crates/perry-runtime/src/tui/state.rs index c61158191b..9cf3a3e563 100644 --- a/crates/perry-runtime/src/tui/state.rs +++ b/crates/perry-runtime/src/tui/state.rs @@ -101,9 +101,16 @@ pub extern "C" fn js_perry_tui_state_alloc(initial: f64) -> i64 { let mut s = crate::gc::lock_gc_root_registry(&SLOTS); let h = s.len() as i64; s.push(initial.to_bits()); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); + } h } +pub(crate) fn contains_handle(handle: i64) -> bool { + handle >= 0 && (handle as usize) < crate::gc::lock_gc_root_registry(&SLOTS).len() +} + /// Read a state slot. Returns the stored NaN-boxed value. Out-of-range /// handles return undefined. #[no_mangle] diff --git a/crates/perry-runtime/src/tui/tree.rs b/crates/perry-runtime/src/tui/tree.rs index 59c5ef508e..2d71c0ad9d 100644 --- a/crates/perry-runtime/src/tui/tree.rs +++ b/crates/perry-runtime/src/tui/tree.rs @@ -51,6 +51,9 @@ per_test_global! { pub fn register(node: Node) -> i64 { let h = NEXT_HANDLE.fetch_add(1, Ordering::AcqRel); REGISTRY.lock().unwrap().push((h, node)); + if crate::hot_diag::receiver_repr_on() { + crate::hot_diag::receiver_repr_note_constructed(crate::hot_diag::ReceiverReprFamily::Tui); + } h } @@ -62,6 +65,10 @@ pub fn lookup(handle: i64) -> Option { .find_map(|(h, n)| if *h == handle { Some(n.clone()) } else { None }) } +pub(crate) fn contains_handle(handle: i64) -> bool { + REGISTRY.lock().unwrap().iter().any(|(h, _)| *h == handle) +} + /// Append a child handle to a Box node. No-op if the handle isn't a /// Box (silently ignored — matches the "we accept anything, you check /// at the call site" convention from the rest of Perry's FFI). diff --git a/crates/perry-runtime/src/value/addr_class.rs b/crates/perry-runtime/src/value/addr_class.rs index b128e48802..dd5796d312 100644 --- a/crates/perry-runtime/src/value/addr_class.rs +++ b/crates/perry-runtime/src/value/addr_class.rs @@ -91,6 +91,21 @@ pub fn is_small_handle(addr: usize) -> bool { (1..HANDLE_BAND_MAX).contains(&addr) } +#[inline(always)] +pub fn is_common_handle_band(addr: usize) -> bool { + (1..COMMON_HANDLE_BAND_END).contains(&addr) +} + +#[inline(always)] +pub fn is_fetch_handle_band(addr: usize) -> bool { + (FETCH_HANDLE_BAND_START..FETCH_HANDLE_BAND_END).contains(&addr) +} + +#[inline(always)] +pub fn is_zlib_handle_band(addr: usize) -> bool { + (ZLIB_HANDLE_BAND_START..ZLIB_HANDLE_BAND_END).contains(&addr) +} + /// Complement of [`is_handle_band`]: the payload is above the handle band and /// may be treated as a candidate heap address (subject to /// [`is_valid_obj_ptr`] / registry checks as the call site requires). Note diff --git a/crates/perry-stdlib/src/common/handle.rs b/crates/perry-stdlib/src/common/handle.rs index 254b03f015..62fd3ddd15 100644 --- a/crates/perry-stdlib/src/common/handle.rs +++ b/crates/perry-stdlib/src/common/handle.rs @@ -43,12 +43,22 @@ fn next_handle_id() -> Handle { pub fn register_handle(value: T) -> Handle { let handle = next_handle_id(); HANDLES.insert(handle, Box::new(value)); + if perry_runtime::hot_diag::receiver_repr_on() { + perry_runtime::hot_diag::receiver_repr_note_constructed( + perry_runtime::hot_diag::ReceiverReprFamily::Common, + ); + } handle } /// Register an object with a specific ID pub fn register_handle_with_id(value: T, handle: Handle) -> Handle { HANDLES.insert(handle, Box::new(value)); + if perry_runtime::hot_diag::receiver_repr_on() { + perry_runtime::hot_diag::receiver_repr_note_constructed( + perry_runtime::hot_diag::ReceiverReprFamily::Common, + ); + } handle } diff --git a/crates/perry-stdlib/src/fetch/mod.rs b/crates/perry-stdlib/src/fetch/mod.rs index a467c21ffb..20af346cb9 100644 --- a/crates/perry-stdlib/src/fetch/mod.rs +++ b/crates/perry-stdlib/src/fetch/mod.rs @@ -227,6 +227,11 @@ fn alloc_fetch_handle_id() -> usize { panic!("Web Fetch handle id range exhausted"); } *id_guard += 1; + if perry_runtime::hot_diag::receiver_repr_on() { + perry_runtime::hot_diag::receiver_repr_note_constructed( + perry_runtime::hot_diag::ReceiverReprFamily::Fetch, + ); + } id } diff --git a/crates/perry-stdlib/src/zlib.rs b/crates/perry-stdlib/src/zlib.rs index 14e4aac6e6..b254838b3f 100644 --- a/crates/perry-stdlib/src/zlib.rs +++ b/crates/perry-stdlib/src/zlib.rs @@ -726,6 +726,11 @@ fn next_zlib_id() -> i64 { panic!("zlib stream handle id range exhausted"); } *g += 1; + if perry_runtime::hot_diag::receiver_repr_on() { + perry_runtime::hot_diag::receiver_repr_note_constructed( + perry_runtime::hot_diag::ReceiverReprFamily::Zlib, + ); + } id } diff --git a/scripts/native_result_ledger.py b/scripts/native_result_ledger.py new file mode 100644 index 0000000000..5b3e166c1a --- /dev/null +++ b/scripts/native_result_ledger.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Check the typed ledger for the native-table rows formerly named NR_PTR. + +The Rust table is the executable declaration. The TSV is the provider-side +inventory: it pins each runtime symbol to the result class and to the source +file whose implementation was read. Keeping both sides checked prevents a +new row from silently inheriting the old, storage-free `NR_PTR` contract. +""" + +from __future__ import annotations + +import argparse +import re +import tempfile +from dataclasses import dataclass +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +TABLE_DIR = Path("crates/perry-codegen/src/lower_call/native_table") +LEDGER = Path("scripts/native_result_ledger.tsv") +# The campaign's textual census reported 372 `ret: NR_PTR` hits. Two were +# prose comments in fastify.rs, while one real row uses the positional `cr(...)` +# helper, leaving 371 executable declarations. The scanner parses declarations, +# not comments, and includes that helper row. +EXPECTED_ROWS = 371 +EXPECTED_PROVIDERS = 322 +KINDS = { + "NR_GCPTR", + "NR_NULLABLE_GCPTR", + "NR_HANDLE_ID", + "NR_FOREIGN_PTR", + "NR_JS_VALUE", +} +RET_RE = re.compile(r"\bret:\s*(NR_[A-Z0-9_]+)\b") +RUNTIME_RE = re.compile(r'\bruntime:\s*"([^"]+)"') + + +class LedgerError(RuntimeError): + pass + + +@dataclass(frozen=True) +class Row: + source: Path + line: int + runtime: str + kind: str + + +@dataclass(frozen=True) +class Provider: + kind: str + source: Path + rust_return: str + + +def scan_rows(root: Path, table_dir: Path) -> list[Row]: + rows: list[Row] = [] + for source in sorted((root / table_dir).rglob("*.rs")): + lines = source.read_text().splitlines() + for index, line in enumerate(lines): + if line.lstrip().startswith("//"): + continue + ret = RET_RE.search(line) + positional = re.fullmatch(r"\s*(NR_[A-Z0-9_]+),\s*", line) + if not ret and not positional: + continue + kind = (ret or positional).group(1) + if kind == "NR_PTR": + raise LedgerError( + f"{source.relative_to(root)}:{index + 1}: legacy NR_PTR row" + ) + if kind not in KINDS: + continue + runtime = None + if ret: + for previous in reversed(lines[:index]): + match = RUNTIME_RE.search(previous) + if match: + runtime = match.group(1) + break + if "NativeModSig {" in previous: + break + else: + strings: list[str] = [] + for previous in reversed(lines[:index]): + strings.extend(re.findall(r'"([^"]+)"', previous)) + if re.search(r"\bcr\s*\(", previous): + break + if len(strings) >= 2: + runtime = strings[-2] + if runtime is None: + raise LedgerError( + f"{source.relative_to(root)}:{index + 1}: typed row has no runtime symbol" + ) + rows.append(Row(source.relative_to(root), index + 1, runtime, kind)) + return rows + + +def read_providers(root: Path, ledger_path: Path) -> dict[str, Provider]: + path = root / ledger_path + providers: dict[str, Provider] = {} + for line_number, line in enumerate(path.read_text().splitlines(), 1): + if not line or line.startswith("#"): + continue + fields = line.split("\t") + if len(fields) != 4: + raise LedgerError(f"{ledger_path}:{line_number}: expected four TSV fields") + symbol, kind, source_text, rust_return = fields + if symbol in providers: + raise LedgerError(f"{ledger_path}:{line_number}: duplicate provider {symbol}") + if kind not in KINDS: + raise LedgerError( + f"{ledger_path}:{line_number}: provider {symbol} lacks a result class" + ) + source = Path(source_text) + provider_path = root / source + if not provider_path.is_file(): + raise LedgerError( + f"{ledger_path}:{line_number}: provider source does not exist: {source}" + ) + definition = re.compile(rf"\bfn\s+{re.escape(symbol)}\s*\(") + if not definition.search(provider_path.read_text()): + raise LedgerError( + f"{ledger_path}:{line_number}: {source} does not declare {symbol}" + ) + providers[symbol] = Provider(kind, source, rust_return) + return providers + + +def check( + root: Path, + table_dir: Path = TABLE_DIR, + ledger_path: Path = LEDGER, + expected_rows: int = EXPECTED_ROWS, + expected_providers: int = EXPECTED_PROVIDERS, +) -> dict[str, int]: + rows = scan_rows(root, table_dir) + providers = read_providers(root, ledger_path) + if len(rows) != expected_rows: + raise LedgerError(f"expected {expected_rows} classified rows, found {len(rows)}") + if len(providers) != expected_providers: + raise LedgerError( + f"expected {expected_providers} classified providers, found {len(providers)}" + ) + + used: set[str] = set() + counts = {kind: 0 for kind in sorted(KINDS)} + for row in rows: + provider = providers.get(row.runtime) + if provider is None: + raise LedgerError( + f"{row.source}:{row.line}: provider {row.runtime} lacks a class" + ) + if provider.kind != row.kind: + raise LedgerError( + f"{row.source}:{row.line}: {row.runtime} is {row.kind}, " + f"provider ledger says {provider.kind}" + ) + used.add(row.runtime) + counts[row.kind] += 1 + + stale = sorted(set(providers) - used) + if stale: + raise LedgerError(f"stale provider ledger entries: {', '.join(stale)}") + return {kind: count for kind, count in counts.items() if count} + + +def self_test() -> None: + with tempfile.TemporaryDirectory(prefix="native-result-ledger-") as tmp: + root = Path(tmp) + tables = root / "tables" + providers = root / "providers" + tables.mkdir() + providers.mkdir() + provider = providers / "fixture.rs" + provider.write_text('pub extern "C" fn fixture_provider() -> *mut u8 { panic!() }\n') + ledger = root / "ledger.tsv" + ledger.write_text( + "# runtime_symbol\tresult_kind\tprovider_source\tprovider_return\n" + "fixture_provider\tNR_GCPTR\tproviders/fixture.rs\t*mut u8\n" + ) + good = ( + 'NativeModSig {\n runtime: "fixture_provider",\n' + " ret: NR_GCPTR,\n}\n" + ) + table = tables / "fixture.rs" + table.write_text(good) + check(root, Path("tables"), Path("ledger.tsv"), 1, 1) + + # Sabotage: restoring the erased result kind must turn the gate red. + table.write_text(good.replace("NR_GCPTR", "NR_PTR")) + try: + check(root, Path("tables"), Path("ledger.tsv"), 1, 1) + except LedgerError as error: + if "legacy NR_PTR" not in str(error): + raise + else: + raise LedgerError("self-test accepted a planted NR_PTR row") + + # Sabotage: a table/provider disagreement must also turn it red. + table.write_text(good.replace("NR_GCPTR", "NR_HANDLE_ID")) + try: + check(root, Path("tables"), Path("ledger.tsv"), 1, 1) + except LedgerError as error: + if "provider ledger says" not in str(error): + raise + else: + raise LedgerError("self-test accepted an unclassified provider declaration") + print("native_result_ledger self-test passed (NR_PTR and provider-class sabotages rejected)") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--self-test", action="store_true") + args = parser.parse_args() + try: + if args.self_test: + self_test() + else: + counts = check(REPO) + rendered = " ".join(f"{kind}={count}" for kind, count in sorted(counts.items())) + print( + "native_result_ledger passed: " + f"{EXPECTED_ROWS} rows, {EXPECTED_PROVIDERS} providers; {rendered}" + ) + except (LedgerError, OSError) as error: + print(f"native_result_ledger FAILED: {error}") + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/native_result_ledger.tsv b/scripts/native_result_ledger.tsv new file mode 100644 index 0000000000..45bcc5c586 --- /dev/null +++ b/scripts/native_result_ledger.tsv @@ -0,0 +1,323 @@ +# runtime_symbol result_kind provider_source provider_return +backOff NR_GCPTR crates/perry-ext-exponential-backoff/src/lib.rs *mut Promise +js_argon2_hash NR_GCPTR crates/perry-ext-argon2/src/lib.rs *mut Promise +js_argon2_verify NR_GCPTR crates/perry-ext-argon2/src/lib.rs *mut Promise +js_async_hook_disable NR_FOREIGN_PTR crates/perry-runtime/src/async_hooks.rs i64 +js_async_hook_enable NR_FOREIGN_PTR crates/perry-runtime/src/async_hooks.rs i64 +js_async_hooks_create_hook NR_FOREIGN_PTR crates/perry-runtime/src/async_hooks.rs i64 +js_async_resource_bind NR_NULLABLE_GCPTR crates/perry-runtime/src/async_hooks.rs i64 +js_async_resource_emit_destroy NR_FOREIGN_PTR crates/perry-runtime/src/async_hooks.rs i64 +js_bcrypt_compare NR_GCPTR crates/perry-ext-bcrypt/src/lib.rs *mut Promise +js_bcrypt_hash NR_GCPTR crates/perry-ext-bcrypt/src/lib.rs *mut Promise +js_buffer_copy_bytes_from NR_GCPTR crates/perry-runtime/src/buffer/copy_bytes.rs *mut BufferHeader +js_buffer_transcode NR_GCPTR crates/perry-runtime/src/buffer/transcode.rs *mut BufferHeader +js_bun_build NR_GCPTR crates/perry-ext-typescript/src/bun.rs *mut Promise +js_bun_serve NR_HANDLE_ID crates/perry-ext-http/src/server/bun_server.rs i64 +js_bun_sqlite_database_call NR_HANDLE_ID crates/perry-stdlib/src/sqlite/bun.rs Handle +js_bun_sqlite_database_query NR_HANDLE_ID crates/perry-stdlib/src/sqlite/bun.rs Handle +js_bun_sqlite_database_run NR_GCPTR crates/perry-stdlib/src/sqlite/bun.rs *mut ObjectHeader +js_bun_sqlite_database_transaction NR_GCPTR crates/perry-stdlib/src/sqlite/bun.rs *mut ClosureHeader +js_bun_sqlite_statement_values NR_GCPTR crates/perry-stdlib/src/sqlite/bun.rs *mut ArrayHeader +js_bun_tcp_listen NR_HANDLE_ID crates/perry-ext-net/src/bun_tcp.rs i64 +js_bun_transpiler_new NR_HANDLE_ID crates/perry-ext-typescript/src/bun.rs Handle +js_cheerio_load NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle +js_cheerio_select NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle +js_cheerio_selection_children NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle +js_cheerio_selection_eq NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle +js_cheerio_selection_find NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle +js_cheerio_selection_first NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle +js_cheerio_selection_last NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle +js_cheerio_selection_parent NR_HANDLE_ID crates/perry-ext-cheerio/src/lib.rs Handle +js_commander_action NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_args_array NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_argument NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_command NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_description NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_name NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_option NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_opts NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_parse NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_required_option NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_commander_version NR_HANDLE_ID crates/perry-ext-commander/src/lib.rs Handle +js_cron_schedule NR_HANDLE_ID crates/perry-ext-cron/src/lib.rs Handle +js_decimal_abs NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle +js_decimal_ceil NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle +js_decimal_div_value NR_HANDLE_ID crates/perry-stdlib/src/decimal.rs Handle +js_decimal_floor NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle +js_decimal_minus_value NR_HANDLE_ID crates/perry-stdlib/src/decimal.rs Handle +js_decimal_mod_value NR_HANDLE_ID crates/perry-stdlib/src/decimal.rs Handle +js_decimal_neg NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle +js_decimal_plus_value NR_HANDLE_ID crates/perry-stdlib/src/decimal.rs Handle +js_decimal_pow NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle +js_decimal_round NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle +js_decimal_sqrt NR_HANDLE_ID crates/perry-ext-decimal/src/lib.rs Handle +js_decimal_times_value NR_HANDLE_ID crates/perry-stdlib/src/decimal.rs Handle +js_domain_add NR_HANDLE_ID crates/perry-stdlib/src/domain.rs Handle +js_domain_create NR_HANDLE_ID crates/perry-stdlib/src/domain.rs Handle +js_domain_on NR_HANDLE_ID crates/perry-stdlib/src/domain.rs Handle +js_domain_remove NR_HANDLE_ID crates/perry-stdlib/src/domain.rs Handle +js_ethers_wallet_create_random NR_JS_VALUE crates/perry-ext-ethers/src/lib.rs JsValue +js_event_emitter_event_names NR_GCPTR crates/perry-ext-events/src/lib.rs *mut ArrayHeader +js_event_emitter_listeners NR_GCPTR crates/perry-ext-events/src/lib.rs *mut ArrayHeader +js_event_emitter_new NR_HANDLE_ID crates/perry-ext-events/src/lib.rs Handle +js_event_emitter_on NR_HANDLE_ID crates/perry-ext-events/src/lib.rs Handle +js_event_emitter_once NR_HANDLE_ID crates/perry-ext-events/src/lib.rs Handle +js_event_emitter_prepend_listener NR_HANDLE_ID crates/perry-ext-events/src/lib.rs Handle +js_event_emitter_prepend_once_listener NR_HANDLE_ID crates/perry-ext-events/src/lib.rs Handle +js_event_emitter_raw_listeners NR_GCPTR crates/perry-ext-events/src/lib.rs *mut ArrayHeader +js_event_emitter_remove_all_listeners NR_HANDLE_ID crates/perry-ext-events/src/lib.rs Handle +js_event_emitter_remove_listener NR_HANDLE_ID crates/perry-ext-events/src/lib.rs Handle +js_event_emitter_set_max_listeners NR_HANDLE_ID crates/perry-ext-events/src/lib.rs Handle +js_events_add_abort_listener NR_GCPTR crates/perry-ext-events/src/module_on.rs i64 +js_events_get_event_listeners NR_GCPTR crates/perry-ext-events/src/module_helpers.rs *mut ArrayHeader +js_events_on NR_GCPTR crates/perry-ext-events/src/module_on.rs *mut ArrayHeader +js_events_once NR_GCPTR crates/perry-ext-events/src/lib.rs *mut Promise +js_ext_net_create_server NR_HANDLE_ID crates/perry-ext-net/src/lib.rs i64 +js_ext_net_socket_connect NR_HANDLE_ID crates/perry-ext-net/src/lib.rs i64 +js_ext_net_socket_once NR_HANDLE_ID crates/perry-ext-net/src/handle_exports.rs i64 +js_ext_tls_connect NR_HANDLE_ID crates/perry-ext-net/src/tls.rs i64 +js_fastify_app_server NR_HANDLE_ID crates/perry-ext-fastify/src/app.rs Handle +js_fastify_create_with_opts NR_HANDLE_ID crates/perry-ext-fastify/src/app.rs Handle +js_fastify_reply_header NR_HANDLE_ID crates/perry-ext-fastify/src/context.rs Handle +js_fastify_reply_status NR_HANDLE_ID crates/perry-ext-fastify/src/context.rs Handle +js_fastify_reply_type NR_HANDLE_ID crates/perry-ext-fastify/src/context.rs Handle +js_fastify_req_headers NR_JS_VALUE crates/perry-ext-fastify/src/context.rs i64 +js_http_agent_create_connection NR_HANDLE_ID crates/perry-ext-http/src/agent.rs i64 +js_http_agent_create_socket NR_HANDLE_ID crates/perry-ext-http/src/agent.rs i64 +js_http_agent_destroy NR_HANDLE_ID crates/perry-ext-http/src/agent.rs Handle +js_http_agent_free_sockets NR_JS_VALUE crates/perry-ext-http/src/agent.rs f64 +js_http_agent_new NR_HANDLE_ID crates/perry-ext-http/src/agent.rs Handle +js_http_agent_noop_self NR_HANDLE_ID crates/perry-ext-http/src/agent.rs Handle +js_http_agent_requests NR_JS_VALUE crates/perry-ext-http/src/agent.rs f64 +js_http_agent_sockets NR_JS_VALUE crates/perry-ext-http/src/agent.rs f64 +js_http_client_request_destroy NR_HANDLE_ID crates/perry-ext-http/src/client_request_surface.rs Handle +js_http_client_request_end_full NR_HANDLE_ID crates/perry-ext-http/src/client_outgoing.rs Handle +js_http_get_overload NR_HANDLE_ID crates/perry-ext-http/src/lib.rs Handle +js_http_incoming_message_set_encoding NR_HANDLE_ID crates/perry-ext-http/src/client_surface.rs Handle +js_http_on NR_HANDLE_ID crates/perry-ext-http/src/lib.rs Handle +js_http_once NR_HANDLE_ID crates/perry-ext-http/src/lib.rs Handle +js_http_request_overload NR_HANDLE_ID crates/perry-ext-http/src/lib.rs Handle +js_http_set_header NR_HANDLE_ID crates/perry-ext-http/src/lib.rs Handle +js_http_set_timeout_full NR_HANDLE_ID crates/perry-ext-http/src/client_outgoing.rs Handle +js_https_agent_new NR_HANDLE_ID crates/perry-ext-http/src/agent.rs Handle +js_https_get_overload NR_HANDLE_ID crates/perry-ext-http/src/lib.rs Handle +js_https_request_overload NR_HANDLE_ID crates/perry-ext-http/src/lib.rs Handle +js_ioredis_decr NR_GCPTR crates/perry-ext-ioredis/src/lib.rs *mut Promise +js_ioredis_del NR_GCPTR crates/perry-ext-ioredis/src/lib.rs *mut Promise +js_ioredis_exists NR_GCPTR crates/perry-ext-ioredis/src/lib.rs *mut Promise +js_ioredis_expire NR_GCPTR crates/perry-ext-ioredis/src/lib.rs *mut Promise +js_ioredis_get NR_GCPTR crates/perry-ext-ioredis/src/lib.rs *mut Promise +js_ioredis_incr NR_GCPTR crates/perry-ext-ioredis/src/lib.rs *mut Promise +js_ioredis_new NR_HANDLE_ID crates/perry-ext-ioredis/src/lib.rs Handle +js_ioredis_quit NR_GCPTR crates/perry-ext-ioredis/src/lib.rs *mut Promise +js_ioredis_set NR_GCPTR crates/perry-ext-ioredis/src/lib.rs *mut Promise +js_lodash_chunk NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_compact NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_drop NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_flatten NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_range NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_reverse NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_tail NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_take NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_times NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lodash_uniq NR_GCPTR crates/perry-stdlib/src/lodash.rs *mut ArrayHeader +js_lru_cache_new NR_HANDLE_ID crates/perry-ext-lru-cache/src/lib.rs Handle +js_lru_cache_set NR_HANDLE_ID crates/perry-ext-lru-cache/src/lib.rs Handle +js_mongodb_client_close NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_client_db NR_HANDLE_ID crates/perry-ext-mongodb/src/lib.rs Handle +js_mongodb_collection_count_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_collection_delete_many_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_collection_delete_one_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_collection_find_one_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_collection_find_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_collection_insert_many_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_collection_insert_one_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_collection_update_many_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_collection_update_one_value NR_GCPTR crates/perry-ext-mongodb/src/lib.rs *mut Promise +js_mongodb_db_collection NR_HANDLE_ID crates/perry-ext-mongodb/src/lib.rs Handle +js_mysql2_connection_begin_transaction NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_connection_commit NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_connection_end NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_connection_execute NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_connection_query NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_connection_rollback NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_create_connection NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_create_pool NR_HANDLE_ID crates/perry-ext-mysql2/src/lib.rs Handle +js_mysql2_pool_connection_execute NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_pool_connection_query NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_pool_end NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_pool_execute NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_pool_get_connection NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_mysql2_pool_query NR_GCPTR crates/perry-ext-mysql2/src/lib.rs *mut Promise +js_net_block_list_new NR_HANDLE_ID crates/perry-ext-net/src/classes.rs i64 +js_net_block_list_rules NR_GCPTR crates/perry-ext-net/src/classes.rs *mut ArrayHeader +js_net_server_listeners NR_GCPTR crates/perry-ext-net/src/lifecycle.rs i64 +js_net_server_noop_self NR_HANDLE_ID crates/perry-ext-net/src/option_setters.rs i64 +js_net_server_once NR_HANDLE_ID crates/perry-ext-net/src/lifecycle.rs i64 +js_net_server_raw_listeners NR_GCPTR crates/perry-ext-net/src/lifecycle.rs i64 +js_net_server_remove_all_listeners NR_HANDLE_ID crates/perry-ext-net/src/lifecycle.rs i64 +js_net_server_remove_listener NR_HANDLE_ID crates/perry-ext-net/src/lifecycle.rs i64 +js_net_socket_address_new NR_HANDLE_ID crates/perry-ext-net/src/classes.rs i64 +js_net_socket_alloc NR_HANDLE_ID crates/perry-ext-net/src/lib.rs i64 +js_net_socket_listeners NR_GCPTR crates/perry-ext-net/src/lifecycle.rs i64 +js_net_socket_noop_self NR_HANDLE_ID crates/perry-ext-net/src/option_setters.rs i64 +js_net_socket_raw_listeners NR_GCPTR crates/perry-ext-net/src/lifecycle.rs i64 +js_net_socket_ref NR_HANDLE_ID crates/perry-ext-net/src/option_setters.rs i64 +js_net_socket_remove_all_listeners NR_HANDLE_ID crates/perry-ext-net/src/lifecycle.rs i64 +js_net_socket_remove_listener NR_HANDLE_ID crates/perry-ext-net/src/lifecycle.rs i64 +js_net_socket_reset_and_destroy NR_HANDLE_ID crates/perry-ext-net/src/lifecycle.rs i64 +js_net_socket_set_encoding NR_HANDLE_ID crates/perry-ext-net/src/option_setters.rs i64 +js_net_socket_set_timeout NR_HANDLE_ID crates/perry-ext-net/src/option_setters.rs i64 +js_net_socket_set_type_of_service NR_HANDLE_ID crates/perry-ext-net/src/option_setters.rs i64 +js_net_socket_unref NR_HANDLE_ID crates/perry-ext-net/src/option_setters.rs i64 +js_node_forge_certificate_from_pem NR_JS_VALUE crates/perry-ext-node-forge/src/lib.rs JsValue +js_node_forge_create_certificate NR_JS_VALUE crates/perry-ext-node-forge/src/lib.rs JsValue +js_node_forge_generate_key_pair NR_JS_VALUE crates/perry-ext-node-forge/src/lib.rs JsValue +js_node_forge_md_sha256_create NR_JS_VALUE crates/perry-ext-node-forge/src/lib.rs JsValue +js_node_forge_private_key_from_pem NR_JS_VALUE crates/perry-ext-node-forge/src/lib.rs JsValue +js_node_http2_connect NR_HANDLE_ID crates/perry-ext-http/src/server/http2_server/session.rs i64 +js_node_http2_create_secure_server NR_HANDLE_ID crates/perry-ext-http/src/server/http2_server.rs i64 +js_node_http2_create_server NR_HANDLE_ID crates/perry-ext-http/src/server/http2_server.rs i64 +js_node_http2_get_packed_settings NR_GCPTR crates/perry-ext-http/src/server/http2_settings.rs *mut BufferHeader +js_node_http2_server_listen NR_HANDLE_ID crates/perry-ext-http/src/server/http2_server.rs i64 +js_node_http_create_server_with_options NR_HANDLE_ID crates/perry-ext-http/src/server/server.rs i64 +js_node_http_im_pause_self NR_HANDLE_ID crates/perry-ext-http/src/server/request.rs i64 +js_node_http_im_resume_self NR_HANDLE_ID crates/perry-ext-http/src/server/request.rs i64 +js_node_http_im_set_timeout NR_HANDLE_ID crates/perry-ext-http/src/server/request.rs i64 +js_node_http_res_append_header NR_HANDLE_ID crates/perry-ext-http/src/server/response.rs i64 +js_node_http_res_set_header_self NR_HANDLE_ID crates/perry-ext-http/src/server/response.rs i64 +js_node_http_res_set_headers NR_HANDLE_ID crates/perry-ext-http/src/server/response.rs i64 +js_node_http_res_set_timeout NR_HANDLE_ID crates/perry-ext-http/src/server/response.rs i64 +js_node_http_server_listen NR_HANDLE_ID crates/perry-ext-http/src/server/server.rs i64 +js_node_http_server_ref NR_HANDLE_ID crates/perry-ext-http/src/server/server.rs i64 +js_node_http_server_set_timeout_method NR_HANDLE_ID crates/perry-ext-http/src/server/server.rs i64 +js_node_http_server_unref NR_HANDLE_ID crates/perry-ext-http/src/server/server.rs i64 +js_node_https_create_server NR_HANDLE_ID crates/perry-ext-http/src/server/https_server.rs i64 +js_node_https_server_listen NR_HANDLE_ID crates/perry-ext-http/src/server/https_server.rs i64 +js_node_https_server_ref NR_HANDLE_ID crates/perry-ext-http/src/server/https_server.rs i64 +js_node_https_server_set_timeout_method NR_HANDLE_ID crates/perry-ext-http/src/server/https_server.rs i64 +js_node_https_server_unref NR_HANDLE_ID crates/perry-ext-http/src/server/https_server.rs i64 +js_node_sqlite_database_sync_call NR_HANDLE_ID crates/perry-stdlib/src/sqlite/node_db.rs Handle +js_node_sqlite_database_sync_create_session NR_HANDLE_ID crates/perry-stdlib/src/sqlite/node_stmt_session.rs Handle +js_node_sqlite_database_sync_create_tag_store NR_HANDLE_ID crates/perry-stdlib/src/sqlite/node_tag_store.rs Handle +js_node_sqlite_database_sync_limits NR_HANDLE_ID crates/perry-stdlib/src/sqlite/node_stmt_session.rs Handle +js_node_sqlite_database_sync_prepare NR_HANDLE_ID crates/perry-stdlib/src/sqlite/node_db.rs Handle +js_node_sqlite_database_sync_serialize NR_GCPTR crates/perry-stdlib/src/sqlite/node_db.rs *mut BufferHeader +js_node_sqlite_session_call NR_HANDLE_ID crates/perry-stdlib/src/sqlite/node_stmt_session.rs Handle +js_node_sqlite_session_changeset NR_GCPTR crates/perry-stdlib/src/sqlite/node_stmt_session.rs *mut BufferHeader +js_node_sqlite_session_patchset NR_GCPTR crates/perry-stdlib/src/sqlite/node_stmt_session.rs *mut BufferHeader +js_node_sqlite_sql_tag_store_all NR_GCPTR crates/perry-stdlib/src/sqlite/node_tag_store.rs *mut ArrayHeader +js_node_sqlite_sql_tag_store_db NR_HANDLE_ID crates/perry-stdlib/src/sqlite/node_tag_store.rs Handle +js_node_sqlite_sql_tag_store_run NR_GCPTR crates/perry-stdlib/src/sqlite/node_tag_store.rs *mut ObjectHeader +js_node_sqlite_statement_sync_all NR_GCPTR crates/perry-stdlib/src/sqlite/node_stmt_session.rs *mut ArrayHeader +js_node_sqlite_statement_sync_call NR_HANDLE_ID crates/perry-stdlib/src/sqlite/node_stmt_session.rs Handle +js_node_sqlite_statement_sync_columns NR_GCPTR crates/perry-stdlib/src/sqlite/node_stmt_session.rs *mut ArrayHeader +js_node_sqlite_statement_sync_run NR_GCPTR crates/perry-stdlib/src/sqlite/node_stmt_session.rs *mut ObjectHeader +js_node_stream_method_event_names NR_GCPTR crates/perry-runtime/src/node_stream_event_emitter.rs i64 +js_node_stream_method_listeners NR_GCPTR crates/perry-runtime/src/node_stream_event_emitter.rs i64 +js_node_stream_method_raw_listeners NR_GCPTR crates/perry-runtime/src/node_stream_event_emitter.rs i64 +js_nodemailer_send_mail NR_GCPTR crates/perry-ext-nodemailer/src/lib.rs *mut Promise +js_nodemailer_verify NR_GCPTR crates/perry-ext-nodemailer/src/lib.rs *mut Promise +js_os_user_info_options NR_GCPTR crates/perry-runtime/src/os.rs *mut ObjectHeader +js_perry_embedded_files NR_GCPTR crates/perry-runtime/src/embedded.rs *mut crate::array::ArrayHeader +js_perry_read_embedded NR_NULLABLE_GCPTR crates/perry-runtime/src/embedded.rs *mut crate::buffer::BufferHeader +js_perry_tui_animated_spinner NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_box NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_input NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_input_at NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_list NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_progress_bar NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_select NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_spacer NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_spinner NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_state_alloc NR_HANDLE_ID crates/perry-runtime/src/tui/state.rs i64 +js_perry_tui_table NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_tabs NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_text NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_text_area NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_text_styled NR_HANDLE_ID crates/perry-runtime/src/tui/ffi.rs i64 +js_perry_tui_use_app NR_HANDLE_ID crates/perry-runtime/src/tui/hooks.rs i64 +js_perry_tui_use_focus_manager NR_HANDLE_ID crates/perry-runtime/src/tui/hooks.rs i64 +js_perry_tui_use_ref NR_HANDLE_ID crates/perry-runtime/src/tui/hooks.rs i64 +js_perry_tui_use_state_tuple NR_GCPTR crates/perry-runtime/src/tui/hooks.rs i64 +js_perry_tui_use_stdout NR_HANDLE_ID crates/perry-runtime/src/tui/hooks.rs i64 +js_pg_client_connect NR_GCPTR crates/perry-ext-pg/src/lib.rs *mut Promise +js_pg_client_end NR_GCPTR crates/perry-ext-pg/src/lib.rs *mut Promise +js_pg_client_query NR_GCPTR crates/perry-ext-pg/src/lib.rs *mut Promise +js_pg_connect NR_GCPTR crates/perry-ext-pg/src/lib.rs *mut Promise +js_pg_create_pool NR_GCPTR crates/perry-ext-pg/src/lib.rs *mut Promise +js_pg_pool_end NR_GCPTR crates/perry-ext-pg/src/lib.rs *mut Promise +js_pg_pool_query NR_GCPTR crates/perry-ext-pg/src/lib.rs *mut Promise +js_process_event_names NR_GCPTR crates/perry-runtime/src/os/os_process_emitter.rs *mut ArrayHeader +js_process_listeners NR_GCPTR crates/perry-runtime/src/os/os_process_emitter.rs *mut ArrayHeader +js_process_raw_listeners NR_GCPTR crates/perry-runtime/src/os/os_process_emitter.rs *mut ArrayHeader +js_querystring_parse NR_GCPTR crates/perry-stdlib/src/querystring.rs *mut ObjectHeader +js_querystring_unescape_buffer NR_GCPTR crates/perry-stdlib/src/querystring.rs *mut BufferHeader +js_readline_create_interface NR_HANDLE_ID crates/perry-stdlib/src/readline/mod.rs i64 +js_readline_get_cursor_pos NR_GCPTR crates/perry-stdlib/src/readline/mod.rs i64 +js_readline_iterator NR_HANDLE_ID crates/perry-stdlib/src/readline/mod.rs i64 +js_readline_pause NR_HANDLE_ID crates/perry-stdlib/src/readline/mod.rs i64 +js_readline_resume NR_HANDLE_ID crates/perry-stdlib/src/readline/mod.rs i64 +js_sharp_auto_orient NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_avif NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_blur NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_composite NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_extend NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_extract NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_flip NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_flop NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_from_input NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_grayscale NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_jpeg NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_metadata NR_GCPTR crates/perry-ext-sharp/src/lib.rs *mut Promise +js_sharp_png NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_resize NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_rotate NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_sharpen NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_to_buffer NR_GCPTR crates/perry-ext-sharp/src/lib.rs *mut Promise +js_sharp_to_file NR_GCPTR crates/perry-ext-sharp/src/lib.rs *mut Promise +js_sharp_trim NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sharp_webp NR_HANDLE_ID crates/perry-ext-sharp/src/lib.rs Handle +js_sqlite_open NR_HANDLE_ID crates/perry-ext-better-sqlite3/src/lib.rs Handle +js_sqlite_prepare NR_HANDLE_ID crates/perry-ext-better-sqlite3/src/lib.rs Handle +js_sqlite_stmt_all NR_GCPTR crates/perry-ext-better-sqlite3/src/lib.rs *mut ArrayHeader +js_sqlite_stmt_raw NR_HANDLE_ID crates/perry-ext-better-sqlite3/src/lib.rs Handle +js_sqlite_stmt_run NR_GCPTR crates/perry-ext-better-sqlite3/src/lib.rs *mut ObjectHeader +js_tls_create_server NR_HANDLE_ID crates/perry-stdlib/src/tls.rs i64 +js_tls_server_close NR_HANDLE_ID crates/perry-stdlib/src/tls.rs i64 +js_tls_server_listen NR_HANDLE_ID crates/perry-stdlib/src/tls.rs i64 +js_tls_server_on NR_HANDLE_ID crates/perry-stdlib/src/tls.rs i64 +js_tls_server_once NR_HANDLE_ID crates/perry-stdlib/src/tls.rs i64 +js_tls_server_remove_all_listeners NR_HANDLE_ID crates/perry-stdlib/src/tls.rs i64 +js_tls_server_remove_listener NR_HANDLE_ID crates/perry-stdlib/src/tls.rs i64 +js_tls_tlssocket_constructor NR_HANDLE_ID crates/perry-stdlib/src/tls.rs i64 +js_u8_from_base64 NR_GCPTR crates/perry-runtime/src/buffer/u8_codec.rs *mut BufferHeader +js_u8_from_hex NR_GCPTR crates/perry-runtime/src/buffer/u8_codec.rs *mut BufferHeader +js_undici_agent_new NR_HANDLE_ID crates/perry-ext-undici/src/lib.rs Handle +js_undici_get_global_dispatcher NR_HANDLE_ID crates/perry-ext-undici/src/lib.rs Handle +js_undici_proxy_agent_new NR_HANDLE_ID crates/perry-ext-undici/src/lib.rs Handle +js_ws_connect NR_GCPTR crates/perry-ext-ws/src/lib.rs *mut perry_ffi::Promise +js_ws_on_client_i64 NR_HANDLE_ID crates/perry-ext-ws/src/lib.rs i64 +js_ws_server_new NR_HANDLE_ID crates/perry-ext-ws/src/server.rs Handle +js_zlib_brotli_compress_sync NR_GCPTR crates/perry-ext-zlib/src/stream.rs *mut BufferHeader +js_zlib_brotli_decompress_sync NR_GCPTR crates/perry-ext-zlib/src/stream.rs *mut BufferHeader +js_zlib_create_brotli_compress NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_brotli_decompress NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_deflate NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_deflate_raw NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_gunzip NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_gzip NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_inflate NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_inflate_raw NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_unzip NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_zstd_compress NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_create_zstd_decompress NR_HANDLE_ID crates/perry-stdlib/src/zlib.rs i64 +js_zlib_deflate_raw_sync NR_GCPTR crates/perry-ext-zlib/src/lib.rs *mut BufferHeader +js_zlib_deflate_sync NR_GCPTR crates/perry-ext-zlib/src/lib.rs *mut BufferHeader +js_zlib_gunzip_sync NR_GCPTR crates/perry-ext-zlib/src/lib.rs *mut BufferHeader +js_zlib_gzip_sync NR_GCPTR crates/perry-ext-zlib/src/lib.rs *mut BufferHeader +js_zlib_inflate_raw_sync NR_GCPTR crates/perry-ext-zlib/src/lib.rs *mut BufferHeader +js_zlib_inflate_sync NR_GCPTR crates/perry-ext-zlib/src/lib.rs *mut BufferHeader +js_zlib_unzip_sync NR_GCPTR crates/perry-ext-zlib/src/lib.rs *mut BufferHeader +js_zlib_zstd_compress_sync NR_GCPTR crates/perry-ext-zlib/src/stream.rs *mut BufferHeader +js_zlib_zstd_decompress_sync NR_GCPTR crates/perry-ext-zlib/src/stream.rs *mut BufferHeader From 909360e2a3222a76829a55371e9b5772b7b3616e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 19:47:42 +0200 Subject: [PATCH 19/20] docs: report receiver representation ledger Record the producer map, corrected executable-row census, sabotage proofs, gate results, and the perrymaster measurement request for campaign PR 1. Claude-Session: https://claude.ai/code/session_011dhBmdn4vGgNibjo3oZqTo (cherry picked from commit ecbfba30af0dbde3539ce5db7e57a31186b91437) --- .../codex/REPORT_receiver_repr_ledger.md | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 cc-perf-campaign/codex/REPORT_receiver_repr_ledger.md diff --git a/cc-perf-campaign/codex/REPORT_receiver_repr_ledger.md b/cc-perf-campaign/codex/REPORT_receiver_repr_ledger.md new file mode 100644 index 0000000000..09f121772e --- /dev/null +++ b/cc-perf-campaign/codex/REPORT_receiver_repr_ledger.md @@ -0,0 +1,178 @@ +# Receiver-representation campaign PR 1: diagnostics and typed ledger + +Date: 2026-09-07 CEST + +Branch: `perf/receiver-repr-ledger` + +## Revisions + +- base `origin/main`: `8b7dc3342b22fe6270739c8d51585c3d2cdfa618` +- implementation: `9b6884e214beb93873f21dd76afe49e42f3f25df` +- report: this final branch commit (the pushed head named in the handoff) + +The merge-base is exactly the base SHA above. No #9937 commit or tree was used. + +## Result + +PR 1 adds measurement and metadata only. It does not change a JS-visible +representation, a release-path classifier, a wrapper, or native-result boxing. +All five new pointer-result classes enter the same `LoweredValue::native_handle` +branch used by the removed `NativeRetKind::Ptr`, so materialization is byte-for- +byte the existing pointer-tag path. `observed_wrapped` is therefore intentionally +zero in this PR. + +`PERRY_RECEIVER_REPR_DIAG=` reuses `hot_diag`'s sink parser and atomic +temporary-file rename. The first event publishes a snapshot; later events test +every 4,096 events and replace the file after at least one second. The line is: + +```text +[receiver-repr-diag] constructed common=… fetch=… zlib=… proxy=… timer=… text=… tui=… async_hook=… async_resource=… symbol_global=… external_buffer=… sab=… null_stub=…; observed_old common=… fetch=… zlib=… proxy=… timer=… text=… tui=… async_hook=… async_resource=… symbol_global=… external_buffer=… sab=… null_stub=…; observed_wrapped common=… fetch=… zlib=… proxy=… timer=… text=… tui=… async_hook=… async_resource=… symbol_global=… external_buffer=… sab=… null_stub=…; bare_managed=…; invalid_pointer_zero=…; direct_mismatch=… +``` + +Every producer and funnel has one `receiver_repr_on()` guard. After one-time +environment parsing, the test is a relaxed atomic load. When unarmed, no range, +registry, ownership, mutex, header, counter, or snapshot work runs. The only +release-path cost is that single guard. `direct_mismatch`'s direct header-byte +probe is compiled only with `debug_assertions`; even there it runs only when the +sink is armed. + +## Instrumented producers + +The current branch locations are: + +| Family | Producer(s) and diagnostic bump | +| --- | --- | +| common | `register_handle` at `crates/perry-stdlib/src/common/handle.rs:43` (bump `:47`); `register_handle_with_id` at `:55` (bump `:58`) | +| fetch | `alloc_fetch_handle_id` at `crates/perry-stdlib/src/fetch/mod.rs:223` (bump `:231`) | +| zlib | `next_zlib_id` at `crates/perry-stdlib/src/zlib.rs:722` (bump `:730`) | +| proxy | `js_proxy_new` at `crates/perry-runtime/src/proxy.rs:451` (bump `:483`) | +| timer | `next_timer_id` at `crates/perry-runtime/src/timer.rs:529` (bump `:534`) | +| text | `js_text_encoder_new` at `crates/perry-runtime/src/text.rs:106` (bump `:108`); decoder `register_decoder` at `:159` (bump `:179`) | +| tui | tree `register` at `crates/perry-runtime/src/tui/tree.rs:51` (bump `:55`); `js_perry_tui_state_alloc` at `tui/state.rs:100` (bump `:105`); `use_ref`, `use_app`, `use_stdout`, and `use_focus_manager` at `tui/hooks.rs:541,602,642,828` (bumps `:555,604,644,830`) | +| async_hook | `js_async_hooks_create_hook` at `crates/perry-runtime/src/async_hooks.rs:558` (bump `:572`) | +| async_resource | `new_async_resource_with_public_value` at `crates/perry-runtime/src/async_hooks.rs:1222` (bump `:1251`) | +| symbol_global | `well_known_symbol` at `crates/perry-runtime/src/symbol.rs:313` (bump `:336`); `intl_legacy_constructed_symbol` at `:642` (bump `:659`); `js_symbol_for` at `symbol/constructors.rs:44` (bump `:111`) | +| external_buffer | `js_buffer_register_external` at `crates/perry-runtime/src/buffer/header.rs:607` (bump `:617`) | +| sab | `alloc_shared_sab` at `crates/perry-runtime/src/shared_sab.rs:60` (bump `:88`) | +| null_stub | `js_unresolved_namespace_stub` at `crates/perry-runtime/src/object/null_stub.rs:37` (bump `:40`) | + +The five receiver/property observations are guarded at +`gc_pointer_and_type_from_value` (`object/native_call_method.rs:1025`), +`dispatch_primitive` (`object/native_call_method/primitive_methods.rs:15`), +`object_static_prototype` (`object/prototype_chain.rs:305`), the object field +tail (`object/field_get_set/get_field_by_name_tail.rs:11`), and `ic_miss` +(`object/field_get_set/ic_miss.rs:541`). + +Common/fetch/zlib use their audited reserved bands. Proxy, timer, decoder, TUI, +async hook/resource, symbol, external-buffer, SAB, and null-stub classification +uses authoritative registries or exact singleton identity. Because the old +encoding has no provenance and small integer registries overlap, one payload +may increment every semantic family whose registry/range claims it. That is an +intentional measurement of the old representation's ambiguity, not a new +release classifier. + +## Typed native-result ledger + +The executable declaration census on current `origin/main` is: + +| Kind | Rows | Meaning | +| --- | ---: | --- | +| `NR_GCPTR` | 131 | non-null managed Perry allocation with `GcHeader` | +| `NR_NULLABLE_GCPTR` | 2 | managed allocation, with zero retaining the provider's current null/failure behavior | +| `NR_HANDLE_ID` | 221 | integer registry id or sentinel | +| `NR_FOREIGN_PTR` | 4 | headerless native `Box` address (AsyncHook/AsyncResource backing) | +| `NR_JS_VALUE` | 13 | raw NaN-boxed JS bits in the integer ABI slot | +| **total** | **371** | every executable declaration formerly using the erased pointer kind | + +The campaign's quoted “372 rows” came from a textual `ret: NR_PTR` census. On +this base that text consists of 370 executable `NativeModSig.ret` fields and +two Fastify prose comments. Conversely, `http_client.rs` has one real result +kind in the positional `cr(...)` helper that the textual census missed. +Therefore the executable total is 371, not 372: 370 fields plus the helper. +Both comments were corrected and the missed helper is explicitly +`NR_HANDLE_ID`. No executable `NR_PTR` remains. + +`scripts/native_result_ledger.tsv` records 322 distinct runtime symbols, their +class, provider source, and provider Rust return type. The 81 symbols/rows that +the design's earlier signature census left unresolved were checked against +their provider implementations while constructing this inventory: **81/81 +resolved, zero unknown**. The checked provider set spans runtime, stdlib, and +the in-tree `perry-ext-*` implementations (rather than Android stubs or +force-link declarations). + +`scripts/native_result_ledger.py` checks both sides: every typed executable row +must agree with one provider ledger entry, every provider source must exist and +declare the symbol, no provider entry may be stale or classless, and a legacy +`NR_PTR` declaration is an immediate failure. A dedicated path-filtered CI +workflow runs `--self-test` and the real inventory. + +## Fail-capable tests and sabotage + +- `receiver_repr_family_fixtures_move_constructed_and_observed_old` constructs + real runtime proxy, timer, text, TUI, AsyncHook, AsyncResource, global-symbol, + external-buffer, SAB, and null-stub values, then sends each through the armed + receiver classifier. Common/fetch/zlib are dependency-lower producers, so + the fixture exercises their exact reserved bands and the companion source + witness binds them to their real producer bumps. Every family asserts + `constructed > 0`, `observed_old > 0`, and `observed_wrapped == 0`. +- `receiver_repr_every_family_producer_keeps_its_constructed_bump` pins the + exact number of bump sites in every audited producer file. Sabotage actually + performed: changed the first common producer's family token to `Fetch` and + ran the already-built release test. It failed exit 101 with `left: 1, + right: 2`; restoring the token returned the test to green. Dropping the bump + has the same failing count. +- `receiver_repr_unarmed_funnels_never_enter_classification` invokes all five + real funnels with the sink forced off and asserts the test-only classifier + entry count stays zero. Sabotage: delete any funnel guard; that funnel calls + `receiver_repr_note_*`, the entry counter becomes nonzero, and the equality + fails. +- `native_result_ledger.py --self-test` plants a legacy `NR_PTR` row and sees + the checker reject it, then plants a table/provider class disagreement and + sees that rejected too. Sabotage: re-add any erased row or remove/change its + provider class; the CI command exits 1. + +## Gates + +Each Cargo command used the campaign build lock and had `df -g /` checked +immediately before launch. Available space was 23 GB (runtime release test), +17 GB (codegen test), 17 GB (wasm-host build), and 14 GB (CLI build), all above +the required 12 GB launch floor. + +- `cargo test -p perry-runtime --release --lib -j6 -- --test-threads=1`: + PASS in 8m11s build + 7.02s tests; 3,262 passed, 4 ignored, 0 failed. +- `cargo test -p perry-codegen --lib -j6`: PASS; 1,450 passed, 1 ignored, + 0 failed (latest incremental build 16.49s, tests 2.83s). +- `python3 scripts/native_result_ledger.py --self-test`: PASS; planted erased + row and provider mismatch both rejected. +- `python3 scripts/native_result_ledger.py`: PASS; 371 rows, 322 providers, + counts exactly as above. +- `python3 scripts/gc_runtime_root_holders.py`: PASS; 1,363 declarations, + 1,160 identity-ratcheted, 593 scanner-reached, 350 inventory-classified, + 415 frontier-pinned, 152 registered scanners. +- `python3 scripts/check_thread_locals.py`: PASS; 405 hot declarations, 273 + cold declarations in 83 recorded files, capacity 768. No new TLS was added. +- `cargo build --release -p perry-runtime --features wasm-host -j6`: PASS in + 2m32s. +- `cargo build --release -p perry -j6`: PASS in 11m33s. Seven pre-existing + feature-dependent dead-code warnings were emitted from GC barrier/regexp + code; none names a changed diagnostic or ledger item. +- direct `rustfmt` over every touched Rust file: PASS. +- `git diff --check`: PASS. +- `scripts/check_file_size.sh`: PASS, “no Rust source files exceed 2000 + lines.” The touched field tail is 1,997 lines. + +## Perrymaster measurement request + +Relink the best cc bundle against implementation +`9b6884e214beb93873f21dd76afe49e42f3f25df`'s runtime. Run exactly one 3,300 +reply with `PERRY_RECEIVER_REPR_DIAG=stderr`, and report the final complete +`[receiver-repr-diag]` line together with the bundle SHA/build identity. Do not +average or omit zero buckets. + +That single line is the PR 2 pricing input: `constructed` gives wrapper +creations per reply by family, `observed_old` shows which families reach the +dynamic receiver/property paths, and the zero/nonzero relationship settles +the design's open question 1 for the best cc workload. It also provides the +required baselines for `observed_wrapped`, `bare_managed`, +`invalid_pointer_zero`, and debug-only `direct_mismatch` before any +representation change. From 3f57261a84d8186ab02d32cd55878f017c59a1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 7 Sep 2026 23:39:16 +0200 Subject: [PATCH 20/20] fix(train): two failures the newly-honest lint driver exposed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #9969 makes run_lint_gates.sh derive every command of every run: step (80 now, up from 67). Two of the newly-run gates were red on this train: - addr_class_inventory rejected #9973's two GcHeader casts in hot_diag/receiver_repr.rs. Both are allowlisted with reasons rather than converted: the block is a #[cfg(debug_assertions)] trust-the-tag audit that compares the ownership-derived header against a direct byte-offset read and counts disagreements. Routing the raw side through try_read_gc_header would validate the address first and return None for exactly the implausible cases the audit exists to catch, so the canonical predicate cannot stand in there. - RUSTFLAGS="-D warnings" cargo check --workspace --all-targets rejected #9861's doc comment on a thread_local! macro invocation, which cannot carry one. Moved inside the macro onto the static it describes. This is a warning, not an error, so it only fails under -D warnings — which the driver never replayed before #9969. --- crates/perry-runtime/src/gc/copying.rs | 10 +++++----- scripts/addr_class_allowlist.txt | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/gc/copying.rs b/crates/perry-runtime/src/gc/copying.rs index 85f04241b2..ae667d992d 100644 --- a/crates/perry-runtime/src/gc/copying.rs +++ b/crates/perry-runtime/src/gc/copying.rs @@ -1950,13 +1950,13 @@ pub(super) fn run_copied_minor_attempt( })) } -/// Test-only witness for the #9851 follow-up: the whole-space pair against the -/// fresh-cohort pair, as the copier computed them for one cycle. Without this -/// the change is unfalsifiable from a test — the two quantities are equal on -/// every heap whose survivor space holds a single generation, which is every -/// heap at a threshold of 2 or below. #[cfg(test)] thread_local! { + /// Test-only witness for the #9851 follow-up: the whole-space pair against the + /// fresh-cohort pair, as the copier computed them for one cycle. Without this + /// the change is unfalsifiable from a test — the two quantities are equal on + /// every heap whose survivor space holds a single generation, which is every + /// heap at a threshold of 2 or below. static LAST_COHORT_SPLIT: std::cell::Cell<(usize, usize, usize, usize)> = const { std::cell::Cell::new((0, 0, 0, 0)) }; } diff --git a/scripts/addr_class_allowlist.txt b/scripts/addr_class_allowlist.txt index 0b74a93015..a8f9427108 100644 --- a/scripts/addr_class_allowlist.txt +++ b/scripts/addr_class_allowlist.txt @@ -172,3 +172,5 @@ crates/perry-runtime/src/box/release_tests.rs | * | async-box release tests: the crates/perry-ext-typescript/src/bun.rs | const HANDLE_BAND_MAX: usize = 0x100000; | #9219: raw_heap_address must reject the fetch/zlib/proxy handle bands (real addresses on Linux; macOS hides it), but this crate links only perry-ffi and cannot import value::addr_class::HANDLE_BAND_MAX. The literal is a documented mirror of that constant, used solely as the >= floor — no other band arithmetic here. Delete it if perry-ffi ever re-exports the predicate. crates/perry-runtime/src/arena/page_meta/tests.rs | (0x1000_0000, 0x1010_0000, 7, 0x10_0000), | #9779 test fixture, not classification: two synthetic 1 MiB block ranges with a 1 MiB hole between them, inside `#[test] fn block_range_lookup_respects_gaps_and_ends`. They are inputs to `old_arena_block_range_index`, asserting a gap is not attributed to the block below it — no runtime address is classified against them. crates/perry-runtime/src/arena/page_meta/tests.rs | (0x1020_0000, 0x1030_0000, 9, 0x10_0000), | #9779 test fixture — the second of the two synthetic block ranges above. +crates/perry-runtime/src/hot_diag/receiver_repr.rs | let derived_ptr = derived.as_ptr() as *const crate::gc::GcHeader; | #9973 debug-only trust-the-tag audit. `derived` is already the canonical ownership-derived header pointer; this only re-types it so the two sides of the comparison have one type. `#[cfg(debug_assertions)]`, so release builds carry no such probe. +crates/perry-runtime/src/hot_diag/receiver_repr.rs | (addr as *const u8).sub(crate::gc::GC_HEADER_SIZE) as *const crate::gc::GcHeader; | #9973 debug-only trust-the-tag audit, and the raw read is the POINT of it: the block compares the ownership-derived header against a direct byte-offset read and counts disagreements in DIRECT_MISMATCH. Routing this side through `try_read_gc_header` would validate the address first and return None for exactly the implausible cases the audit exists to catch, so the canonical predicate cannot stand in here. `#[cfg(debug_assertions)]`; absent from release builds.