diff --git a/.github/workflows/gc-native-roots.yml b/.github/workflows/gc-native-roots.yml index 5532fc6705..e070db053d 100644 --- a/.github/workflows/gc-native-roots.yml +++ b/.github/workflows/gc-native-roots.yml @@ -419,25 +419,10 @@ jobs: for probe in benchmarks/gc_ratchet/probes/*.ts; do total=$((total+1)) name=$(basename "$probe" .ts) - if [ "$RUNNER_OS" = "Windows" ] && [ "$name" = "09_try_catch_roots" ]; then - # #7354 measured negative, pinned as a REFUSAL: windows-msvc - # `try` lowers to WinEH funclet pads, which crash LLVM's - # rewrite-statepoints-for-gc outright (access violation on opt - # 22.1.3, reproducible from an eight-line module). Perry refuses - # the module before the pass runs; this arm pins that it STAYS a - # refusal — never a crash, never a silently rootless binary. It - # goes red the day the pass learns funclet EH, which is the - # prompt to fold 09 into this matrix. - if PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" \ - -o "/tmp/rs4gc-$name" > "/tmp/rs4gc-$name.compile.log" 2>&1; then - echo "::error::$name compiled under RS4GC on Windows — the funclet refusal is gone: either rewrite-statepoints-for-gc learned funclet EH (fold 09 into the matrix) or the refusal was lost" - exit 1 - fi - grep -q "funclet" "/tmp/rs4gc-$name.compile.log" \ - || { echo "::error::$name failed for a reason other than the funclet refusal:"; cat "/tmp/rs4gc-$name.compile.log"; exit 1; } - pass=$((pass+1)) - continue - fi + # #10385 replaced Windows funclets with Perry's landing-pad + # personality. Probe 09 must now execute with precise roots on + # Windows too. Actual funclet IR remains refused by linker.rs's + # rs4gc_funclet_refusal and its unit test (#7354). node --expose-gc --experimental-strip-types "$probe" > "/tmp/rs4gc-$name.oracle" PERRY_RS4GC=1 ./target/perry-dev/perry "$probe" -o "/tmp/rs4gc-$name" # perry appends the platform default extension to an -o with none. @@ -461,11 +446,17 @@ jobs: readelf -S "$out" | grep -q "\.llvm_stackmaps" \ && { echo "::error::$name still carries .llvm_stackmaps — the compact rewrite did not run"; exit 1; } fi + PERRY_GC_DIAG=1 \ PERRY_RS4GC=1 PERRY_GC_FORCE_EVACUATE=1 PERRY_GC_VERIFY_EVACUATION=1 \ PERRY_GC_HEAP_LIMIT=8 PERRY_GC_INCREMENTAL=0 PERRY_CONSERVATIVE_STACK_SCAN=off \ "$out" > "/tmp/rs4gc-$name.out" 2> "/tmp/rs4gc-$name.err" diff "/tmp/rs4gc-$name.oracle" "/tmp/rs4gc-$name.out" \ || { echo "::error::$name diverged from the pinned oracle under RS4GC"; exit 1; } + if [ "$name" = "09_try_catch_roots" ]; then + py=python3; command -v python3 >/dev/null 2>&1 || py=python + "$py" scripts/gc_evacuation_liveness_assert.py "/tmp/rs4gc-$name.err" \ + --probe "$name ($RUNNER_OS RS4GC)" + fi errs="$errs /tmp/rs4gc-$name.err" pass=$((pass+1)) done @@ -485,14 +476,9 @@ jobs: # windows-latest exposes the toolcache python as `python`, not python3. py=python3; command -v python3 >/dev/null 2>&1 || py=python - # The PORTABLE assertion, on every arm. `11_collect_at_depth` is - # deliberate: it contains no `try`, so it compiles under RS4GC - # everywhere. `09_try_catch_roots` does NOT — RS4GC cannot rewrite - # WinEH funclet pads, so `linker.rs`'s `rs4gc_funclet_refusal` rejects - # it on windows-msvc, and the probe loop above only tolerates that - # because it greps the compile log for "funclet". A report assertion - # pinned to a probe that cannot compile on one arm is a gate that - # fails for a reason unrelated to its subject. + # The recursive-depth assertion, on every arm. This probe carries + # live roots across a deep stack; the separate try probe below + # covers roots across normal and unwinding exception edges. # # --only-backend proves the lowering ran on every function; the two # --require-positive checks prove it PRODUCED something. Those counts @@ -510,19 +496,18 @@ jobs: --require-positive records \ --require-positive roots - # The try-specific arm, everywhere RS4GC can compile a `try`. This is + # The try-specific arm on every target, including Windows since + # #10385 replaced funclets with Perry's landing-pad personality. This is # the coverage the probe above cannot give: 128 of 479 gap tests # contain `try {}`, and RS4GC being the only backend that handles them # is the reason the bridge could be deleted (#7339, #7348). - if [ "$RUNNER_OS" != "Windows" ]; then - PERRY_RS4GC=1 ./target/perry-dev/perry \ - benchmarks/gc_ratchet/probes/09_try_catch_roots.ts \ - -o /tmp/rs4gc-try-probe --statepoint-report=json 2> /tmp/rs4gc-try.json - "$py" scripts/statepoint_report_assert.py /tmp/rs4gc-try.json \ - --only-backend rs4gc \ - --require-positive records \ - --require-positive roots - fi + PERRY_RS4GC=1 ./target/perry-dev/perry \ + benchmarks/gc_ratchet/probes/09_try_catch_roots.ts \ + -o /tmp/rs4gc-try-probe --statepoint-report=json 2> /tmp/rs4gc-try.json + "$py" scripts/statepoint_report_assert.py /tmp/rs4gc-try.json \ + --only-backend rs4gc \ + --require-positive records \ + --require-positive roots # Walker liveness, on EVERY arm. A walker that visits zero frames # still lets most probes print the right answer, because other root diff --git a/.github/workflows/gc-ratchet.yml b/.github/workflows/gc-ratchet.yml index f99ace093a..2297811369 100644 --- a/.github/workflows/gc-ratchet.yml +++ b/.github/workflows/gc-ratchet.yml @@ -168,7 +168,7 @@ jobs: # deliberately broad; it exists only to spare docs-only PRs a build. # If the listing is empty or the API failed, `set -e` already aborted # — the job does not silently fall through to "not relevant". - if grep -qE '^(crates/|benchmarks/gc_ratchet/|Cargo\.(toml|lock)$|\.github/workflows/gc-ratchet\.yml$|tests/test_gc_ratchet\.py$)' changed.txt; then + if grep -qE '^(crates/|benchmarks/gc_ratchet/|Cargo\.(toml|lock)$|\.github/workflows/gc-ratchet\.yml$|tests/(test_gc_ratchet|test_large_eden_gc_ratchet)\.py$)' changed.txt; then echo "run=true" >> "$GITHUB_OUTPUT" echo "Change touches collector-relevant paths; measuring." else @@ -227,6 +227,7 @@ jobs: || { echo "::error::expected Node $expected, found $actual"; exit 1; } - name: Measure + id: measurement if: steps.relevance.outputs.run == 'true' env: PERRY_RUNTIME_DIR: ${{ github.workspace }}/target/release @@ -261,6 +262,18 @@ jobs: --current .bench-results/gc-ratchet-current.json \ --profile shared_ci + # Run even when the ordinary fingerprint is red, retaining both verdicts. + - name: Check large-Eden relocation separately + if: always() && steps.measurement.outcome == 'success' + env: + PERRY_RUNTIME_DIR: ${{ github.workspace }}/target/release + PERRY_NO_AUTO_OPTIMIZE: "1" + run: | + python3 benchmarks/gc_ratchet/check_large_eden_relocation.py \ + --perry target/release/perry \ + --node "$(command -v node)" \ + --output .bench-results/gc-ratchet-relocation.json + - name: Upload measurement if: always() && steps.relevance.outputs.run == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -268,6 +281,7 @@ jobs: name: gc-ratchet-${{ github.sha }} path: | .bench-results/gc-ratchet-current.json + .bench-results/gc-ratchet-relocation.json benchmarks/gc_ratchet/baseline/gc-ratchet-v1.json retention-days: 90 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6d3feee96..00f535a502 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -928,6 +928,7 @@ jobs: if: ${{ !cancelled() }} run: | ./scripts/gc_repsel_matrix.sh --self-test-liveness-parser + ./scripts/gc_repsel_matrix.sh --self-test-fixture-env python3 scripts/gc_repsel_matrix_merge.py --self-test python3 scripts/gc_matrix_liveness_check.py --self-test python3 scripts/gc_matrix_liveness_check.py --check-registry diff --git a/benchmarks/gc_ratchet/README.md b/benchmarks/gc_ratchet/README.md index 88484e6864..33775d66a2 100644 --- a/benchmarks/gc_ratchet/README.md +++ b/benchmarks/gc_ratchet/README.md @@ -154,10 +154,40 @@ probes**, and 21.8 MB on `12_large_live_set`, whose tenured-proportional cap term (`gc/tenuring.rs`, `max(influx x scale, tenured/2)`) already raises its Eden a little. That last row is worth noticing — it is the shipped path by which a large Eden is reached without any knob, and it tops out around 22 MB on the -biggest workload the suite has. The guard that keeps this honest is `check`'s existing -liveness rule (`minor_cycles > 0` and `copied_objects + promoted_objects > 0`): -a future change that stops reaching the copying minor at this cadence cannot be -pinned, it fails. +biggest workload the suite had at that measurement. These are historical cadence +measurements, not invariants of the current adaptive nursery policy. + +### Relocation coverage alongside the normal policy measurement + +Promotion can now retain whole nursery blocks in place. Consequently, +`minor_cycles > 0` and `copied_objects + promoted_objects > 0` prove minor +activity but do not prove a reference crossed physical relocation. Probe 13's +normal configuration still measures that shipping policy, with all original +counter bands and baseline rows intact. + +`check_large_eden_relocation.py` additionally compiles the unchanged probe and +runs it twice with its declared 64 MB nursery setting plus +`PERRY_GC_PROMOTE_IN_PLACE=0` and `PERRY_GC_DIAG=1`. Each run must exit normally, +match the exact pinned Node oracle, and report positive `minor_cycles`, +`copied_objects`, and +`copied_bytes`. The JSON retains both complete traces and verdicts. The workflow +runs this check even if the ordinary counter comparison fails, and uploads both +artifacts. Its result never accepts a changed normal-policy counter. + +The 64 MB setting is a base for the adaptive ladder, not a promise of a fixed +collection cadence. At #11645's child, the separate arm measured eight minors, +335,661 copied objects / 19,017,288 copied bytes and 109,455,704 freed bytes; +the original parent had three minors. This restores evacuation of the original +survivor graph, fresh note edges and strings at the large nursery setting; it +does not reproduce the parent's exact timing. Its timing and memory costs are +not shipping-policy measurements. + +```bash +PERRY_RUNTIME_DIR=target/release PERRY_NO_AUTO_OPTIMIZE=1 \ + python3 benchmarks/gc_ratchet/check_large_eden_relocation.py \ + --perry target/release/perry --node "$(command -v node)" \ + --output .bench-results/gc-ratchet-relocation.json +``` ## Why wall time is excluded from the shared-CI gate @@ -447,8 +477,10 @@ minors — was being reported as passing. **Why the second probe is a sum (#7558).** It used to be `copied_objects` alone. Both counters come from the same `[gc-copy-minor] ran` line: they are the evacuating minor's own accounting of *where* it put each survivor — survivor -space, or straight to old-gen. Either one alone names a destination; only the -sum answers "did the copying minor move anything". #7558 produced the +space, or straight to old-gen. At #7558 both destinations required relocation, so the sum then +answered "did the copying minor move anything". In-place promotion subsequently +made the sum a minor-activity check only; the separate probe-13 check above +requires actual copying. #7558 produced the distinction for real: with the conservative scan gone, the adaptive-tenuring seed (`gc/tenuring.rs`, which deliberately refuses input from a conservatively scanned cycle) started receiving data on `gc()`-driven workloads, diff --git a/benchmarks/gc_ratchet/check_large_eden_relocation.py b/benchmarks/gc_ratchet/check_large_eden_relocation.py new file mode 100644 index 0000000000..b5fa9bfc9d --- /dev/null +++ b/benchmarks/gc_ratchet/check_large_eden_relocation.py @@ -0,0 +1,73 @@ +"""Exercise probe 13's relocation path separately from its policy fingerprint.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import tempfile + +if __package__: + from . import gc_ratchet as ratchet +else: + import gc_ratchet as ratchet + + +SOURCE = Path(__file__).resolve().parent / "probes/13_large_eden_survivors.ts" + + +def check_relocation(binary: Path, node: Path, source: Path = SOURCE) -> dict: + """Keep both raw runs, including failures; promotion alone is not movement.""" + run_env = ratchet.probe_run_env(source) + if run_env.get("PERRY_GC_SCAVENGE_NURSERY_MB") != "64": + raise ratchet.RatchetError("probe 13 must retain its 64 MB nursery setting") + run_env.update(PERRY_GC_PROMOTE_IN_PLACE="0", PERRY_GC_DIAG="1") + oracle_version = "v" + (ratchet.REPO_ROOT / ".node-version").read_text().strip().lstrip("v") + result = { + "probe": source.stem, + "source_sha256": hashlib.sha256(source.read_bytes()).hexdigest(), + "binary_sha256": hashlib.sha256(binary.read_bytes()).hexdigest(), + "run_env": run_env, + "runs": [], + "failures": [], + } + for index in range(2): + run = ratchet.run_once([str(binary)], extra_env=run_env) + counters = ratchet.parse_gc_diag(run["stderr"]) + correctness = ratchet._check_against_node(node, source, run["stdout"]) + result["runs"].append({**run, "counters": counters, "correctness": correctness}) + if run["returncode"] != 0: + result["failures"].append(f"run {index + 1}: exited {run['returncode']}") + if correctness.get("status") != "pass": + result["failures"].append(f"run {index + 1}: Node parity was not verified") + if correctness.get("oracle_version") != oracle_version: + result["failures"].append(f"run {index + 1}: expected Node {oracle_version}") + # These are positive on actual evacuation into survivor space, whereas + # promoted_objects also counts survivors whose blocks stayed in place. + for metric in ("minor_cycles", "copied_objects", "copied_bytes"): + if counters[metric] <= 0: + result["failures"].append(f"run {index + 1}: {metric} must be positive") + result["status"] = "fail" if result["failures"] else "pass" + return result + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--perry", required=True, type=Path) + parser.add_argument("--node", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args(argv) + with tempfile.TemporaryDirectory(prefix="gc-ratchet-relocation-") as tmp: + binary = ratchet.compile_probe(args.perry.resolve(), SOURCE, Path(tmp)) + result = check_relocation(binary, args.node.resolve()) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + print(f"probe 13 relocation: {result['status']}") + for failure in result["failures"]: + print(failure) + return int(result["status"] != "pass") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/repsel_census/baseline.json b/benchmarks/repsel_census/baseline.json index 3a60cc4f1a..9da5d2b112 100644 --- a/benchmarks/repsel_census/baseline.json +++ b/benchmarks/repsel_census/baseline.json @@ -1,6 +1,38 @@ { "schema_version": 1, "workloads": [ + { + "name": "fixture_ptr_shape_straight_line", + "role": "liveness", + "source": "benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts", + "floors": { + "ptr-shape": 1, + "ptr-shape-consumed": 1, + "ptr-numarray": 0, + "canonical-i32": 0, + "canonical-u32": 0, + "canonical-str": 0, + "int-valued-ta": 0, + "spec-abi-entry": 0, + "spec-abi-taptr-slot": 0 + }, + "candidates": { + "ptr-shape": 1, + "ptr-numarray": 0, + "canonical-slot": 0, + "int-valued-ta": 0, + "spec-abi": 1 + }, + "unconsumed_mechanisms": {}, + "consumption_sites": { + "ptr_shape_get_number": 1, + "class_field_get_number.shape_proven_load": 1, + "ptr_shape_set": 1, + "ptr_shape_update": 1, + "ptr_shape_method": 1 + }, + "alloc_buckets": {} + }, { "name": "fixture_ptr_shape", "role": "liveness", @@ -23,11 +55,13 @@ "int-valued-ta": 0, "spec-abi": 1 }, - "unconsumed_mechanisms": {}, + "unconsumed_mechanisms": { + "region_shape_authority": 1 + }, "consumption_sites": { - "ptr_shape_get_number": 2, - "ptr_shape_set": 1, - "ptr_shape_method": 1 + "ptr_shape_get_number": 1, + "ptr_shape_region_get": 1, + "ptr_shape_region_set": 1 }, "alloc_contexts": {}, "alloc_buckets": {} @@ -54,13 +88,13 @@ "int-valued-ta": 0, "spec-abi": 1 }, - "unconsumed_mechanisms": {}, + "unconsumed_mechanisms": { + "region_shape_authority": 1 + }, "consumption_sites": { "ptr_shape_get_number": 1, - "class_field_get_number.shape_proven_load": 1, - "ptr_shape_set": 1, - "ptr_shape_update": 1, - "ptr_shape_method": 1 + "ptr_shape_region_get": 1, + "ptr_shape_region_set": 1 }, "alloc_contexts": {}, "alloc_buckets": {} @@ -73,7 +107,7 @@ "ptr-shape": 3, "ptr-shape-consumed": 3, "ptr-numarray": 0, - "canonical-i32": 1, + "canonical-i32": 2, "canonical-u32": 0, "canonical-str": 0, "int-valued-ta": 0, @@ -82,17 +116,19 @@ }, "candidates": { "ptr-shape": 3, - "ptr-numarray": 0, - "canonical-slot": 2, + "ptr-numarray": 1, + "canonical-slot": 3, "int-valued-ta": 0, "spec-abi": 1 }, - "unconsumed_mechanisms": {}, + "unconsumed_mechanisms": { + "region_shape_authority": 2 + }, "consumption_sites": { - "ptr_shape_get_number": 3, + "class_field_get.shape_proven_load": 2, "ptr_shape_method": 1, - "class_field_get.shape_proven_load": 1, - "ptr_shape_set": 2 + "ptr_shape_region_get": 2, + "ptr_shape_region_set": 2 }, "alloc_contexts": {}, "alloc_buckets": {} @@ -119,10 +155,12 @@ "int-valued-ta": 0, "spec-abi": 0 }, - "unconsumed_mechanisms": {}, + "unconsumed_mechanisms": { + "region_shape_authority": 1 + }, "consumption_sites": { - "class_field_get.shape_proven_load": 1, - "ptr_shape_set": 1 + "ptr_shape_region_get": 1, + "ptr_shape_region_set": 1 }, "alloc_contexts": {}, "alloc_buckets": { @@ -838,5 +876,5 @@ "alloc_buckets": {} } ], - "generated_at": "2026-08-02T04:34:29.412488Z" + "generated_at": "2026-10-02T20:15:04.218376Z" } diff --git a/benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts b/benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts new file mode 100644 index 0000000000..92abbba7ff --- /dev/null +++ b/benchmarks/repsel_census/fixtures/fixture_ptr_shape_straight_line.ts @@ -0,0 +1,25 @@ +// The surviving guard-free numeric load/update sites need independent +// coverage now that P8 routes loop bodies through live shape guards. +// Keep the original loop fixtures unchanged: their floors cover that handoff. +class StraightCounter { + v: number; + w: number; + constructor() { + this.v = 0; + this.w = 1; + } + mix(): number { + return this.v * this.v + this.w * this.w; + } +} + +function straightLine(): number { + const c = new StraightCounter(); + // A self-reading store prevents scalar replacement. There is deliberately + // no loop, so region handoff cannot consume these accesses instead. + c.v = c.v + 1; + c.w++; + return c.v * c.w + c.mix(); +} + +console.log("ptr_shape_straight_line:" + straightLine()); diff --git a/changelog.d/11680-constfn-handle-scopes.md b/changelog.d/11680-constfn-handle-scopes.md new file mode 100644 index 0000000000..89274608bd --- /dev/null +++ b/changelog.d/11680-constfn-handle-scopes.md @@ -0,0 +1,14 @@ +Use scoped runtime handles in ConstFn finalization and its moving-GC, key-add, +worker-seed and transfer tests. Calls that need the receiver after a collection +reload it through `across_mut`; leaf and self-rooting entries receive scoped +arguments. This removes 154 new raw-handle debt sites without raising ceilings. + +Worker-launch tests now carry independently owned observations in their closure +captures instead of sharing asserted process globals. An escaped Arc protects +the launch even if a body runs too often or a test times out; workers retain an +owned observation for late preparation callbacks. The existing ON/OFF, ordering, +worker-identity, output and moving-GC assertions remain. + +Raw-handle, global-isolation, address-class and root-holder source checks pass, +along with formatting. The 34 existing tests still require execution on the +updated build; source checks do not establish runtime correctness. diff --git a/changelog.d/11680-constfn-static-key-bytes.md b/changelog.d/11680-constfn-static-key-bytes.md new file mode 100644 index 0000000000..c5c9093227 --- /dev/null +++ b/changelog.d/11680-constfn-static-key-bytes.md @@ -0,0 +1,8 @@ +### Fix ConstFn finalizer metadata allocating heap strings + +Emit packed property names for ConstFn finalizers as read-only byte constants. +Previously these metadata blobs entered the JavaScript string pool, allocating +and permanently rooting strings whose handles were never used. The Zod +enabled/disabled comparison exposed three extra strings totaling 168 heap bytes. +The finalizer still receives the same packed bytes and length; shape identity, +receiver validation, and closure rooting are unchanged. diff --git a/changelog.d/11680-gc-large-eden-relocation.md b/changelog.d/11680-gc-large-eden-relocation.md new file mode 100644 index 0000000000..96986c1dbe --- /dev/null +++ b/changelog.d/11680-gc-large-eden-relocation.md @@ -0,0 +1 @@ +Keep large-nursery relocation coverage live alongside the GC policy ratchet. The unchanged survivor-graph workload now also runs with in-place promotion disabled, requiring actual copied objects and bytes plus parity with the pinned Node oracle. Retain both traces and separate verdicts; the normal measurements, baseline, and counter tolerances remain unchanged. diff --git a/changelog.d/11680-gc-loop-witness-environments.md b/changelog.d/11680-gc-loop-witness-environments.md new file mode 100644 index 0000000000..addbc46408 --- /dev/null +++ b/changelog.d/11680-gc-loop-witness-environments.md @@ -0,0 +1,13 @@ +The GC representation matrix now honors each fixture's existing `parity-env` +settings in the explicit `loop_polls` arm at both compilation and execution. +Seeded and protected witnesses therefore select the instrumented runtime through +the normal auto-optimize path. Fixture metadata is restricted to validated GC +settings; all other arms keep their original environments. Reports and progress +logs record each cell's compile and run assignments. A CI routing self-test +checks control isolation and rejects planted compile/run dispatch defects. + +The call-argument and packed-global-cache witnesses now carry their recorded +seeded collection settings, including the 4 KiB allocation interval and protected +from-space. Their TypeScript workloads remain unchanged. This makes the existing +moving arm exercise these short fixtures instead of accepting oracle parity +without any collection; the per-cell movement check remains mandatory. diff --git a/changelog.d/11680-one-shape-campaign.md b/changelog.d/11680-one-shape-campaign.md new file mode 100644 index 0000000000..f72adabeef --- /dev/null +++ b/changelog.d/11680-one-shape-campaign.md @@ -0,0 +1,44 @@ +Complete the remaining one-shape compiler paths: shape-record lookup reads the +published per-agent directory directly; immutable method slots use shape-owned +ConstFn metadata with worker transfer and unload handling. Cyclic module startup +prepares literal pools and closed literal layouts before eager bodies execute. + +Numeric receiver regions use shape and F64 field proofs for loop arithmetic and +individual comparisons, preserving evaluation order, exception handlers and +generic fallback. Read-only regions also accept classless runtime records without +granting store permission; virtual namespace and per-object reads remain excluded. +Delete the separate numeric class-field loop emitter and its cached raw-pointer +facts, retaining ordinary property fallbacks and specialized array/element paths. + +Keep receivers, assignment results and dynamic-add operands rooted across +collecting fallbacks. Correct synchronous IteratorClose ordering and nested catch +completion handling: return operands evaluate before close, cleanup runs once, +and exceptions bypass catches already exited by the pending completion. + +Add compiler, runtime and executable regression coverage for numeric route +admission and refusal, closure identity, worker metadata, moving collections, +cyclic initialization, operand ordering and nested iterator cleanup. + +Worker executable fixtures allocate and recheck captures across scheduled +collections; the fixed seeds retain positive-copying and moved-object assertions. + +Worker programs keep the 32 cached results of each literal-prefix string concatenation in thread-local cells. Each worker now owns and roots its own strings, so repeated worker launches and simultaneous workers cannot reuse another agent's cached heap handles. The single-agent cache and its checked miss/root-registration path are preserved. Added graph-level IR ownership coverage and an executable two-launch regression that checks exact concatenation results. + +Fix synchronous for-of IteratorClose when an inner break or continue from finally cancels a pending return. Captured exits restore the completion inherited at their target, so cleanup loops retain an outer pending return and normal iterator exhaustion does not call return(). Preserve generated preludes around labeled control targets. Recognize every label in a label chain as targeting its terminal loop when deciding which finally blocks a captured exit crosses. + +Preserve normal call boundaries for closure-bearing inline candidates inside loops that perform receiver field arithmetic. This keeps closure creation out of the body that guarded numeric regions must version, while retaining codegen's refusal to duplicate closures and its recheck after calls. Tiny callees, calls outside these loops, and loops without receiver field arithmetic continue to inline. Focused tests cover all loop forms, nested helper inlining, and both controls. + +Run the try/catch native-root probe on Windows after its exception lowering moved to landing pads. Require Node-equivalent execution, a native root map, nonzero evacuation and RS4GC root records; keep the linker refusal for actual WinEH funclet IR. Use the runtime TLS declaration macro for worker launch test observations. + +Reuse cached key-add transitions for an exact safe ConstFn body when its traced +Any intermediate is still live. Store the current receiver's closure with the +ordinary barrier before publishing its body-specific shape, using one existing +cache probe. Missing intermediates, deprecated facts and unsupported targets +retain the rooted slow publication path. Regressions cover distinct captures, +actual moving collections, pointer-key fallback and publication ordering. + +Extend the existing field-representation verifier to check SPECIAL ConstFn +slots against their shape-owned body identity, including deprecated carriers. +Resolve validated forwarding before reading closure metadata during collection, +and diagnose stale body facts at the existing cold method-prime refusal. Add +valid, stale, deprecated, revoked and moving-collection regression coverage. diff --git a/changelog.d/11680-region-proof-consumption.md b/changelog.d/11680-region-proof-consumption.md new file mode 100644 index 0000000000..2333a60758 --- /dev/null +++ b/changelog.d/11680-region-proof-consumption.md @@ -0,0 +1,19 @@ +Report how pointer-shape proofs pass into the one-shape region path. Static +region suppliers explicitly use available receiver-class provenance, while the +live ShapeId guard remains the authority for offsets and field representation. +Consumption is recorded only when that supplier serves an emitted read or +write. Learned suppliers and type hints do not count as proof consumption; +refused unguarded routes name the region handoff. + +A single-receiver body region (one loop-local receiver) built its guard from +the learned word alone and never asked for the static supplier, so a receiver +whose class names a static ShapeId was still primed and guarded by a learned +word. That path now takes the static guard whenever one exists, as every other +region guard already did (DESIGN §4.1, static-exclusive). + +Preserve every existing promotion floor and fixture. Add a straight-line +fixture for the surviving guard-free load/update sites and five compiler tests +covering actual region accesses, removal of the static supplier, absence of a +selected proof, reporting OFF, and the surviving straight-line sites. The +census baseline is re-measured with `census --update` on Linux x86_64; no +floor is lowered. diff --git a/changelog.d/11680-shape-authority-inventories.md b/changelog.d/11680-shape-authority-inventories.md new file mode 100644 index 0000000000..db3a6e52fc --- /dev/null +++ b/changelog.d/11680-shape-authority-inventories.md @@ -0,0 +1,10 @@ +Keep the one-shape source checks aligned with the completed migrations. Remove +the four header-offset callsites deleted with the legacy class-loop path, lower +the SSO debt inventory for the removed heap-only key reader, and retire the two +deleted class-guard constant registrations. + +The descriptor census now follows ConstFn's wrappers to the shared mint and +checks that the descriptor is published before both reverse indexes. Two +negative controls reverse those orderings and must fail. The original shape +census, header-constant check and its negative control, and SSO inventory pass; +no debt ceiling or runtime performance tolerance is raised. diff --git a/changelog.d/11762-macos-provider-frameworks.md b/changelog.d/11762-macos-provider-frameworks.md new file mode 100644 index 0000000000..d23f87a03a --- /dev/null +++ b/changelog.d/11762-macos-provider-frameworks.md @@ -0,0 +1,3 @@ +Link CoreFoundation and Foundation when building the macOS stdlib provider fixture with the runtime rlib, matching the existing runtime dylib build. This lets locale helpers resolve their framework and Objective-C dependencies before the provider GC gate executes. Linux and links without a runtime rlib retain their existing flags. + +Retain and export the stdlib feature-installation and registration entry points used by later-loaded apps. The macOS provider GC phase previously passed, but the following Response image failed to load because the compiled-feature installer was absent from the provider export list. diff --git a/crates/perry-abi/src/lib.rs b/crates/perry-abi/src/lib.rs index d61afce88b..9c1706a0b0 100644 --- a/crates/perry-abi/src/lib.rs +++ b/crates/perry-abi/src/lib.rs @@ -20,7 +20,7 @@ pub const ARRAY_HEADER_SIZE: usize = 8; /// `agent_ptrs::PERRY_AGENT_PTRS`: the number of per-agent pointer slots. pub const AGENT_PTR_SLOTS: usize = 4; /// Slot 0: the address of this agent's ordinary shape-directory mirror -/// (`shapes_store::ORDINARY_DIR`), which a generic read site passes to its +/// (`shapes_store::AGENT_SHAPE_DIR[0]`), which a generic read site passes to its /// GC-leaf miss front (`js_object_get_field_ic_front`) so the front reads no /// thread-local. Slot 1 held the implicit-`this` cell's address until /// this-as-a-parameter deleted the cell, and is free; slot 2 is the stack limit. @@ -344,6 +344,10 @@ pub const FN_NON_CONSTRUCTOR: u32 = 1 << 9; pub const FN_BUILTIN: u32 = 1 << 10; /// `declared` is valid. pub const FN_HAS_DECLARED: u32 = 1 << 11; +/// Body metadata and code are linked into a permanent executable image. +/// Dylib bodies omit this bit: a shape must not retain their info address +/// beyond `dlclose` or mistake a reused address for the same body. +pub const FN_PERMANENT_IMAGE: u32 = 1 << 12; /// Byte offsets of the fields codegen emits and emitted code reads. pub const JS_FUNCTION_INFO_CODE_OFFSET: usize = 0; @@ -564,8 +568,12 @@ pub const METHOD_SITE_SPILL: u64 = 1 << 62; /// The entry `slot` bit for an own key of a function-object receiver: an /// inline slot of the object at `ClosureHeader::props`. pub const METHOD_SITE_FUNCTION_BAG: u64 = 1 << 61; -/// The index bits of an entry's `slot` word (bit 60 is reserved for the -/// accessor entry kind). -pub const METHOD_SITE_INDEX_MASK: u64 = (1 << 60) - 1; +/// An own inline method whose ShapeId fixes one static body. The hit loads +/// the receiver's current closure slot for captures, but needs no closure +/// kind or info load after the shape compare. +pub const METHOD_SITE_CONSTFN: u64 = 1 << 59; +/// The index bits of an entry's `slot` word (bit 60 remains reserved for the +/// accessor entry kind; bit 59 is ConstFn). +pub const METHOD_SITE_INDEX_MASK: u64 = (1 << 59) - 1; /// `object::ObjectMeta::spill` (the object-owned overflow buffer). pub const OBJECT_META_SPILL_OFFSET: usize = 32; diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index a92cb82755..0601abfd59 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -30,7 +30,10 @@ use super::string_pool::emit_string_pool; /// function, string pool. Mirrors the in-prelude execution order of /// the original `compile_module`. #[allow(clippy::too_many_arguments)] -pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { +pub(super) fn emit_module_artifacts( + c: ModuleArtifactsCtx<'_>, + agent_strings_tls: bool, +) -> Result<()> { // Destructure so the verbatim block below reads against the // original local names. `llmod` / `strings` are `&mut` bindings // (auto-reborrowed on each per-function call site below); the @@ -1006,6 +1009,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { llmod, strings, module_prefix, + agent_strings_tls, output_type, class_keys_init_data, class_header_image_inits, diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 09e7b9ae73..19194e584e 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -123,7 +123,7 @@ fn emit_public_typed_closure_trampoline( )) } }; - let public_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let public_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let typed_name = match kind { TypedFunctionTrampolineKind::F64 => typed_f64_closure_name(&public_name), TypedFunctionTrampolineKind::I32 => typed_i32_closure_name(&public_name), @@ -235,7 +235,7 @@ pub(super) fn compile_typed_string_closure( } }; - let generic_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let generic_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let llvm_name = typed_string_closure_name(&generic_name); let mut llvm_params: Vec<(LlvmType, String)> = Vec::with_capacity(params.len() + 1); llvm_params.push((I64, "%this_closure".to_string())); @@ -283,7 +283,7 @@ pub(super) fn compile_typed_f64_closure( _ => return Err(anyhow!("compile_typed_f64_closure: expected Expr::Closure")), }; - let generic_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let generic_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let llvm_name = typed_f64_closure_name(&generic_name); let mut llvm_params: Vec<(LlvmType, String)> = Vec::with_capacity(params.len() + 1); llvm_params.push((I64, "%this_closure".to_string())); @@ -328,7 +328,7 @@ pub(super) fn compile_typed_i1_closure( _ => return Err(anyhow!("compile_typed_i1_closure: expected Expr::Closure")), }; - let generic_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let generic_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let llvm_name = typed_i1_closure_name(&generic_name); let param_reps = typed_param_reps_for_params(params) .ok_or_else(|| anyhow!("typed-i1 closure '{}' has unsupported parameter", func_id))?; @@ -373,7 +373,7 @@ pub(super) fn compile_typed_i32_closure( _ => return Err(anyhow!("compile_typed_i32_closure: expected Expr::Closure")), }; - let generic_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let generic_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let llvm_name = typed_i32_closure_name(&generic_name); let mut llvm_params: Vec<(LlvmType, String)> = Vec::with_capacity(params.len() + 1); llvm_params.push((I64, "%this_closure".to_string())); @@ -510,7 +510,7 @@ pub(super) fn compile_closure( closure_relevant_ids.extend(params.iter().map(|p| p.id)); closure_relevant_ids.extend(captures.iter().copied()); - let public_llvm_name = format!("perry_closure_{}__{}", module_prefix, func_id); + let public_llvm_name = crate::fn_info::closure_body_symbol(module_prefix, func_id); let regex_factory_identity = (!is_async && !is_generator && params.is_empty() @@ -1182,7 +1182,6 @@ pub(super) fn compile_closure( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/emission_order_tests.rs b/crates/perry-codegen/src/codegen/emission_order_tests.rs index 3f3a286ec5..1f9480fe8b 100644 --- a/crates/perry-codegen/src/codegen/emission_order_tests.rs +++ b/crates/perry-codegen/src/codegen/emission_order_tests.rs @@ -73,6 +73,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index 4910e86420..94f4711445 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -147,6 +147,7 @@ pub(super) fn compile_module_entry( // resolves the symbols at link time. for prefix in non_entry_module_prefixes { llmod.declare_function(&format!("{}__init", prefix), VOID, &[]); + llmod.declare_function(&format!("__perry_prepare_literals_{}", prefix), VOID, &[]); } // Issue #753: emit a no-op `__init` stub so the // dispatch site in some other module that does `await @@ -547,6 +548,19 @@ pub(super) fn compile_module_entry( if crate::collectors::is_cjs_wrapped_module(hir) { blk.call_void("js_bootstrap_cjs_main_module_placeholder", &[]); } + // The topo sort intentionally drops cyclic evaluation back-edges. + // A first eager module can therefore call a later module's hoisted + // factory without ever reaching that module's __init wrapper. All + // eager literal pools must be ready before ANY eager body runs. + // Deferred pools remain lazy and prepare in their own wrapper. + for prefix in non_entry_module_prefixes { + if cross_module.deferred_module_prefixes.contains(prefix) { + continue; + } + let prepare_addr = + format!("ptrtoint (ptr @__perry_prepare_literals_{} to i64)", prefix); + blk.call_void("js_run_module_init_catching", &[(I64, &prepare_addr)]); + } for (index, prefix) in non_entry_module_prefixes.iter().enumerate() { if cross_module.deferred_module_prefixes.contains(prefix) { continue; @@ -780,7 +794,6 @@ pub(super) fn compile_module_entry( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), @@ -1354,6 +1367,14 @@ pub(super) fn compile_module_entry( { let blk = wrap_fn.block_mut(2).unwrap(); blk.store(I8, "1", &format!("@{}", done_global)); + // Cyclic dependencies may call our hoisted functions before + // our body. Prepare literal infrastructure without evaluating + // declared classes or any user statement ahead of dependencies. + let prepare_addr = format!( + "ptrtoint (ptr @__perry_prepare_literals_{} to i64)", + module_prefix + ); + blk.call_void("js_run_module_init_catching", &[(I64, &prepare_addr)]); // Trigger init of static-dep + re-export source modules // before the body runs. Each `__init` is itself // wrapped by the same guard pattern, so this short- @@ -1646,7 +1667,6 @@ pub(super) fn compile_module_entry( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/entry/tests.rs b/crates/perry-codegen/src/codegen/entry/tests.rs index 0d78b4a9c1..b7fb7052cf 100644 --- a/crates/perry-codegen/src/codegen/entry/tests.rs +++ b/crates/perry-codegen/src/codegen/entry/tests.rs @@ -8,6 +8,7 @@ fn entry_opts(output_type: &str) -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), @@ -642,3 +643,22 @@ fn set_bun_platform_marker_lowers_to_the_runtime_flag_setter() { // Control: the default (node) platform never emits the call. assert!(!emitted_ir("executable").contains("call void @js_set_bun_platform")); } + +#[test] +fn eager_literal_pools_precede_all_bodies_but_deferred_pools_stay_lazy() { + let mut opts = entry_opts("executable"); + opts.non_entry_module_prefixes = vec!["first_ts".into(), "later_ts".into(), "lazy_ts".into()]; + opts.deferred_module_prefixes.insert("lazy_ts".into()); + let ir = String::from_utf8(compile_module(&empty_module(), opts).unwrap()).unwrap(); + let first_body = ir.find("call void @first_ts__init()").unwrap(); + let later_body = ir.find("call void @later_ts__init()").unwrap(); + let first_pool = ir + .find("ptr @__perry_prepare_literals_first_ts to i64") + .unwrap(); + let later_pool = ir + .find("ptr @__perry_prepare_literals_later_ts to i64") + .unwrap(); + assert!(first_pool < first_body && later_pool < first_body && first_body < later_body); + assert!(!ir.contains("ptr @__perry_prepare_literals_lazy_ts to i64")); + assert!(!ir.contains("call void @lazy_ts__init()")); +} diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 9bf2c13c69..71e76369cf 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -1318,7 +1318,6 @@ pub(super) fn compile_function( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/literal_method_this_tests.rs b/crates/perry-codegen/src/codegen/literal_method_this_tests.rs index 93083d75fd..423e8feffb 100644 --- a/crates/perry-codegen/src/codegen/literal_method_this_tests.rs +++ b/crates/perry-codegen/src/codegen/literal_method_this_tests.rs @@ -366,3 +366,67 @@ fn anon_shape_is_registered_before_its_shape_id_is_minted() { "js_register_anon_shape_class_id must run before the class ShapeId is minted" ); } + +/// Cyclic imports can invoke hoisted functions before dependency bodies finish. +/// Literal infrastructure must be ready then, without publishing declared-class +/// keys/prototypes/constructors ahead of their existing evaluation boundary. +#[test] +fn cyclic_literal_bootstrap_precedes_dependencies_without_prewarming_user_classes() { + let mut hir = literal_module(method(METHOD, false, false)); + hir.classes.push(anon_shape(2, "Declared", 91, 70)); + let opts = CompileOptions { + emit_ir_only: true, + is_entry_module: false, + module_init_deps: vec!["consumer_ts".into()], + ..Default::default() + }; + let ir = String::from_utf8(compile_module(&hir, opts).unwrap()).unwrap(); + let body = |symbol: &str| { + let start = ir + .lines() + .find(|line| line.starts_with("define ") && line.contains(&format!("@{symbol}("))) + .unwrap(); + let at = ir.find(start).unwrap(); + &ir[at..at + ir[at..].find("\n}\n").unwrap()] + }; + let wrapper = body("literal_method_this_test__init"); + let prepare = wrapper + .find("@__perry_prepare_literals_literal_method_this_test") + .unwrap(); + let dependency = wrapper.find("@consumer_ts__init()").unwrap(); + let module_body = wrapper + .find("@literal_method_this_test__init_body") + .unwrap(); + assert!( + prepare < dependency && dependency < module_body, + "{wrapper}" + ); + assert_eq!(wrapper.matches("@js_run_module_init_catching(").count(), 2); + let prepare = body("__perry_prepare_literals_literal_method_this_test"); + assert!( + prepare.contains("load i8") && prepare.contains("br i1"), + "{prepare}" + ); + assert!(!prepare.contains("_class_chunk"), "{prepare}"); + let literal_chunks = ir + .split("\n}\n") + .filter(|chunk| { + chunk.lines().any(|line| { + line.starts_with("define ") + && line.contains("__perry_init_strings_literal_method_this_test_literal_chunk") + }) + }) + .collect::>() + .join("\n"); + assert!(literal_chunks.contains("call void @js_register_anon_shape_class_id")); + assert!(literal_chunks.contains("@perry_class_keys_literal_method_this_test____AnonShape_")); + assert!(!literal_chunks.contains("@perry_class_keys_literal_method_this_test__Declared")); + assert!(!literal_chunks.contains("call void @js_register_class_constructor")); + assert!(!literal_chunks.contains("call void @js_register_class_getter")); + let strings = body("__perry_init_strings_literal_method_this_test"); + assert!( + strings.find("@__perry_prepare_literals_").unwrap() < strings.find("_class_chunk").unwrap() + ); + assert!(body("literal_method_this_test__init_body") + .contains("call void @__perry_init_strings_literal_method_this_test()")); +} diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index 87fd794db5..88fecf49a7 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -672,7 +672,6 @@ pub(super) fn compile_method( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method_static.rs b/crates/perry-codegen/src/codegen/method_static.rs index f7c7f7a203..d75e79b6b4 100644 --- a/crates/perry-codegen/src/codegen/method_static.rs +++ b/crates/perry-codegen/src/codegen/method_static.rs @@ -351,7 +351,6 @@ pub(in crate::codegen) fn compile_static_method( array_length_snapshots: HashMap::new(), string_window_array_facts: Vec::new(), suppressed_cleared_shadow_slots: std::collections::HashSet::new(), - class_field_loop_facts: Vec::new(), region_loops: Vec::new(), region_loop_facts: Vec::new(), element_shape_loop_facts: Vec::new(), diff --git a/crates/perry-codegen/src/codegen/method_trampolines.rs b/crates/perry-codegen/src/codegen/method_trampolines.rs index bb36463600..a9d436d8f8 100644 --- a/crates/perry-codegen/src/codegen/method_trampolines.rs +++ b/crates/perry-codegen/src/codegen/method_trampolines.rs @@ -330,7 +330,14 @@ pub(super) fn emit_guarded_nonnegative_index( let expected_shape_i64 = blk.zext(I32, &expected_shape, I64); let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32"); let expected = blk.or(I64, &expected_shape_high, &expected_class_id.to_string()); - let shape_matches = blk.icmp_eq(I64, &class_shape, &expected); + let shape_matches = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &class_shape, + &expected_class_id.to_string(), + &expected_shape, + &expected, + &[], + ); let shape_rel = blk.add(I32, &expected_shape, "-2147483648"); let shape_valid = blk.icmp_ult(I32, &shape_rel, "1073741824"); let exact_layout = blk.and(I1, &gc_ok, &shape_matches); diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index e4aa216e01..66c0110105 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -251,15 +251,18 @@ mod spec_preserve_none_tests; mod spec_return_proof; #[cfg(test)] mod spec_self_recursion_tests; +pub(crate) mod static_constfn; +pub(crate) mod static_constfn_class; pub(crate) mod static_fields; mod static_shape_ids; pub use static_shape_ids::{ assign_static_shape_ids, decode_static_seed, encode_static_seed, take_module_static_seeds, - BirthProto, BirthShape, DefinedClassShape, ModuleBirth, ProgramClassShapeIds, TypedMasks, - STATIC_SEED_FORMAT, + BirthProto, BirthShape, ConstFnBirth, DefinedClassShape, ModuleBirth, ProgramClassShapeIds, + TypedMasks, STATIC_SEED_FORMAT, }; pub(crate) use static_shape_ids::{ - static_region_slots, static_shape_id_for_foreign_global, static_shape_id_for_keys_global, + compatible_final_shape_ids, slot_may_be_constfn, static_region_slots, + static_shape_id_for_foreign_global, static_shape_id_for_keys_global, }; mod string_pool; #[cfg(test)] @@ -469,10 +472,11 @@ fn compile_module_impl( let (live_cjs_hir, cjs_property_exports) = cjs_exports::prepare(hir); let hir = live_cjs_hir.as_ref(); // The driver sets the whole-program perry/thread flag before any module - // codegen. A direct compile_module caller has no graph, so also detect a - // launch in this module without changing shared compiler state. + // codegen. A direct compile_module caller without callback prefixes also + // needs local launch detection for its string-preparation callback, even + // when the process flag is already set. Do not change shared compiler state. let mut local_thread_use = false; - if !program_has_thread_agents() { + if opts.thread_literal_module_prefixes.is_empty() { perry_hir::for_each_module_expr(hir, &mut |expr| { if matches!(expr, perry_hir::Expr::NativeMethodCall { module, method, .. } if module == "perry/thread" @@ -482,7 +486,10 @@ fn compile_module_impl( } }); } - let thread_agents = program_has_thread_agents() || local_thread_use; + let thread_agents = program_has_thread_agents() + || !opts.thread_literal_module_prefixes.is_empty() + || local_thread_use; + let agent_strings_tls = program_has_worker() || thread_agents; let progress = CompileProgress::new(&hir.name, module_callable_count(hir)); let triple = opts.target.clone().unwrap_or_else(default_target_triple); if let Some(refusal) = crate::target_layout::ilp32_codegen_refusal(&triple) { @@ -577,7 +584,12 @@ fn compile_module_impl( // checker — the pool lives outside LlModule. The module prefix // becomes part of every emitted global so multi-module programs // don't collide on `.str.0.handle`. + let thread_literal_callback_prefix = opts + .thread_literal_module_prefixes + .first() + .unwrap_or(&module_prefix); let mut strings = StringPool::with_prefix(module_prefix.clone()); + strings.set_thread_literal_callback_prefix(thread_literal_callback_prefix.clone()); strings.tdz_binding_names = tdz_names::collect(hir); // #5247: install per-module source-location context for the dynamic // call-dispatch throw path, but only under `--debug-symbols` (which sets @@ -2475,6 +2487,29 @@ fn compile_module_impl( &class_birth_reps_map, &class_ids, ); + if static_constfn::enabled(&opts) { + let reps = class_keys_globals_map + .iter() + .filter_map(|(name, keys)| { + class_birth_reps_map + .get(keys) + .map(|rep| (name.clone(), *rep)) + }) + .collect(); + let class_finals = static_constfn_class::module_class_finals( + hir, + &module_prefix, + births, + &class_keys_globals_map, + &class_ids, + ); + births.extend(class_finals); + births.extend(static_constfn::module_literal_finals( + hir, + &module_prefix, + &reps, + )); + } return Ok(Vec::new()); } static_shape_ids::set_module_static_ids( @@ -2486,6 +2521,9 @@ fn compile_module_impl( &opts.static_shape_ids, &opts.program_class_shape_ids, ); + if !static_constfn::enabled(&opts) { + static_shape_ids::disable_static_final_shapes(); + } let class_header_images_map: std::collections::HashMap = class_keys_globals_map .iter() @@ -3797,50 +3835,104 @@ fn compile_module_impl( // entry-fn emission, string-pool init) lives in // `artifacts::emit_module_artifacts`. Behavior is unchanged — // see the doc on that fn for the split rationale. - emit_module_artifacts(ModuleArtifactsCtx { - progress: &progress, - llmod: &mut llmod, - target_triple: &triple, - strings: &mut strings, - hir, - import_function_prefixes: &opts.import_function_prefixes, - imported_classes: &opts.imported_classes, - constructor_param_counts: &opts.constructor_param_counts, - is_entry_module: opts.is_entry_module, - non_entry_module_prefixes: &opts.non_entry_module_prefixes, - output_type: &opts.output_type, - module_prefix: &module_prefix, - class_table: &class_table, - class_ids: &class_ids, - enum_table: &enum_table, - module_globals: &module_globals, - module_global_types: &module_global_types, - static_field_globals: &static_field_globals, - method_names: &method_names, - func_names: &func_names, - func_signatures: &func_signatures, - func_synthetic_arguments: &func_synthetic_arguments, - module_boxed_vars: &module_boxed_vars, - module_local_types: &module_local_types, - module_receiver_types: &module_receiver_types, - closure_rest_params: &closure_rest_params, - closure_synthetic_arguments: &closure_synthetic_arguments, - closure_rest_and_arguments: &closure_rest_and_arguments, - closure_arities: &closure_arities, - closure_lengths: &closure_lengths, - closure_arrow_functions: &closure_arrow_functions, - trusted_box_closures: &trusted_box_closures, - versioned_loop_callbacks: &versioned_loop_callbacks, - closures: &closures, - class_keys_init_data: &class_keys_init_data, - class_header_image_inits: &class_header_image_inits, - imported_class_stubs: &imported_class_stubs, - cross_module: &cross_module, - })?; + emit_module_artifacts( + ModuleArtifactsCtx { + progress: &progress, + llmod: &mut llmod, + target_triple: &triple, + strings: &mut strings, + hir, + import_function_prefixes: &opts.import_function_prefixes, + imported_classes: &opts.imported_classes, + constructor_param_counts: &opts.constructor_param_counts, + is_entry_module: opts.is_entry_module, + non_entry_module_prefixes: &opts.non_entry_module_prefixes, + output_type: &opts.output_type, + module_prefix: &module_prefix, + class_table: &class_table, + class_ids: &class_ids, + enum_table: &enum_table, + module_globals: &module_globals, + module_global_types: &module_global_types, + static_field_globals: &static_field_globals, + method_names: &method_names, + func_names: &func_names, + func_signatures: &func_signatures, + func_synthetic_arguments: &func_synthetic_arguments, + module_boxed_vars: &module_boxed_vars, + module_local_types: &module_local_types, + module_receiver_types: &module_receiver_types, + closure_rest_params: &closure_rest_params, + closure_synthetic_arguments: &closure_synthetic_arguments, + closure_rest_and_arguments: &closure_rest_and_arguments, + closure_arities: &closure_arities, + closure_lengths: &closure_lengths, + closure_arrow_functions: &closure_arrow_functions, + trusted_box_closures: &trusted_box_closures, + versioned_loop_callbacks: &versioned_loop_callbacks, + closures: &closures, + class_keys_init_data: &class_keys_init_data, + class_header_image_inits: &class_header_image_inits, + imported_class_stubs: &imported_class_stubs, + cross_module: &cross_module, + }, + agent_strings_tls, + )?; + + // The graph's first prefix owns its only preparation callback. All launch + // sites reference that symbol, including launches in other modules. With + // no graph, only a local launcher emits a local-only callback. Neither path + // evaluates module bodies; preparation precedes worker deserialization. + if thread_literal_callback_prefix == &module_prefix + && (!opts.thread_literal_module_prefixes.is_empty() || local_thread_use) + { + // Normalize only in the owner, never once per module. Preserve the + // owner as the first entry; the remaining string-only calls are pure + // preparation and have no module-evaluation ordering dependencies. + let mut prefixes: Vec<_> = opts + .thread_literal_module_prefixes + .iter() + .skip(1) + .filter(|prefix| *prefix != &module_prefix) + .cloned() + .collect(); + prefixes.sort(); + prefixes.dedup(); + prefixes.insert(0, module_prefix.clone()); + for prefix in &prefixes { + llmod.declare_function( + &format!("__perry_prepare_agent_strings_{}", prefix), + crate::types::VOID, + &[], + ); + } + let callback = llmod.define_function( + format!("__perry_prepare_thread_strings_{}", module_prefix), + crate::types::VOID, + vec![], + ); + let blk = callback.create_block("entry"); + for prefix in &prefixes { + blk.call_void(&format!("__perry_prepare_agent_strings_{}", prefix), &[]); + } + blk.ret_void(); + } // One `JsFunctionInfo` per body a function object runs (`crate::fn_info`), // after every function — and so every allocation site — exists. - llmod.emit_fn_infos(); + // Step 5C is opt-in until its GC/image and performance gates pass. + // A dylib never advertises a permanent body, even with the knob set. + let constfn_body_metadata = opts.output_type == "executable" + && std::env::var("PERRY_CONSTFN_SHAPE").as_deref() == Ok("1"); + if constfn_body_metadata { + // Omitted/dead literals must not leave body-info relocations behind. + static_constfn::emit_final_entries( + &mut llmod, + &module_prefix, + &static_shape_ids::module_final_seeds(), + ); + } + llmod.emit_fn_infos(constfn_body_metadata); // Emit the buffer alias-scope metadata once per module, covering every // scope id allocated across compile_function / compile_closure / diff --git a/crates/perry-codegen/src/codegen/number_exactness_tests.rs b/crates/perry-codegen/src/codegen/number_exactness_tests.rs index 93e4cc38ac..723fb8b85c 100644 --- a/crates/perry-codegen/src/codegen/number_exactness_tests.rs +++ b/crates/perry-codegen/src/codegen/number_exactness_tests.rs @@ -32,6 +32,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: false, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index a3a402253b..480d0b316f 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -136,6 +136,15 @@ pub struct CompileOptions { /// order matches Perry's existing topological sort (set up by the /// CLI driver in `crates/perry/src/commands/compile.rs`). pub non_entry_module_prefixes: Vec, + /// Complete native module graph, supplied identically to every module when + /// perry/thread can launch agents. The first prefix owns the graph's single + /// preparation callback (the CLI chooses the actual entry module); remaining + /// prefixes are unique and sorted, excluding the owner. Owner order affects + /// symbols and object cache identity. Every compiled module must be included. + /// Empty permits a direct local launcher to prepare only its own pool. + /// String preparation has no module-evaluation effects; deferred bodies and + /// declared-class registration remain lazy. + pub thread_literal_module_prefixes: Vec, /// For each imported function name in this module, the prefix of the /// source module that exports it. Used by `ExternFuncRef` lowering /// in `lower_call` to generate the correct cross-module call to diff --git a/crates/perry-codegen/src/codegen/static_constfn.rs b/crates/perry-codegen/src/codegen/static_constfn.rs new file mode 100644 index 0000000000..ea8f5a197a --- /dev/null +++ b/crates/perry-codegen/src/codegen/static_constfn.rs @@ -0,0 +1,372 @@ +//! Static ConstFn describes a completed object. Allocation ids remain Any/F64. +use super::{BirthProto, BirthShape, CompileOptions, ConstFnBirth, ModuleBirth}; +use perry_hir::{Class, Expr, Module, Stmt}; +use std::collections::{BTreeSet, HashMap}; + +pub(crate) fn enabled(opts: &CompileOptions) -> bool { + opts.output_type == "executable" && std::env::var("PERRY_CONSTFN_SHAPE").as_deref() == Ok("1") +} + +pub(crate) fn literal_final( + prefix: &str, + props: &[(String, Expr)], + base_rep: u64, +) -> Option { + if props.is_empty() || props.len() > 32 { + return None; + } + let mut seen = BTreeSet::new(); + let mut keys = Vec::new(); + let mut rep = base_rep; + let mut constfn = Vec::new(); + for (slot, (key, value)) in props.iter().enumerate() { + if key.is_empty() || key.contains('\0') || key == "__proto__" || !seen.insert(key) { + return None; + } + keys.extend_from_slice(key.as_bytes()); + keys.push(0); + if let Expr::Closure { + func_id, + params, + is_async: false, + is_generator: false, + captures_this, + is_arrow, + .. + } = value + { + // Rebindable method clones cannot satisfy the direct body ABI. + if (*captures_this && !*is_arrow) + || params + .iter() + .any(|p| p.is_rest || p.arguments_object.is_some()) + { + continue; + } + if (base_rep >> (slot * 2)) & 3 != 0 { + return None; + } + rep |= 3 << (slot * 2); + constfn.push(ConstFnBirth { + slot: slot as u8, + symbol: crate::fn_info::info_symbol(&crate::fn_info::closure_body_symbol( + prefix, *func_id, + )), + }); + } + } + if constfn.is_empty() { + return None; + } + Some(BirthShape { + keys, + key_count: props.len() as u32, + live: props.len() as u32, + proto: BirthProto::Literal, + typed: None, + rep, + constfn, + }) +} + +/// Only the exact synthetic record constructor is admitted. Arbitrary classes, +/// heritage, descriptors, computed keys and early returns fail closed. +pub(crate) fn anon_props(class: &Class, args: &[Expr]) -> Option> { + if !class.is_literal_shape() || class.fields.len() != args.len() { + return None; + } + Some( + class + .fields + .iter() + .zip(args) + .map(|(f, value)| (f.name.clone(), value.clone())) + .collect(), + ) +} + +pub(crate) fn module_literal_finals( + module: &Module, + prefix: &str, + class_reps: &HashMap, +) -> Vec { + fn expr( + e: &Expr, + module: &Module, + prefix: &str, + reps: &HashMap, + out: &mut BTreeSet, + ) { + let shape = match e { + Expr::Object(props) => literal_final(prefix, props, 0), + Expr::New { + class_name, + args, + cap_args_appended: 0, + .. + } => module + .classes + .iter() + .find(|c| &c.name == class_name) + .and_then(|c| anon_props(c, args)) + .and_then(|props| literal_final(prefix, &props, *reps.get(class_name)?)), + _ => None, + }; + if let Some(shape) = shape { + out.insert(shape); + } + if let Expr::Closure { body, .. } = e { + stmts(body, module, prefix, reps, out); + } + perry_hir::walker::walk_expr_children(e, &mut |child| { + expr(child, module, prefix, reps, out) + }); + } + fn stmts( + body: &[Stmt], + m: &Module, + p: &str, + r: &HashMap, + out: &mut BTreeSet, + ) { + for stmt in body { + match stmt { + Stmt::Let { init, .. } | Stmt::Return(init) => { + if let Some(e) = init { + expr(e, m, p, r, out); + } + } + Stmt::Expr(e) | Stmt::Throw(e) => expr(e, m, p, r, out), + Stmt::If { + condition, + then_branch, + else_branch, + } => { + expr(condition, m, p, r, out); + stmts(then_branch, m, p, r, out); + if let Some(b) = else_branch { + stmts(b, m, p, r, out); + } + } + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + expr(condition, m, p, r, out); + stmts(body, m, p, r, out); + } + Stmt::For { + init, + condition, + update, + body, + } => { + if let Some(s) = init { + stmts(std::slice::from_ref(s), m, p, r, out); + } + for e in [condition, update].into_iter().flatten() { + expr(e, m, p, r, out); + } + stmts(body, m, p, r, out); + } + Stmt::Labeled { body, .. } => stmts(std::slice::from_ref(body), m, p, r, out), + Stmt::Try { + body, + catch, + finally, + } => { + stmts(body, m, p, r, out); + if let Some(c) = catch { + stmts(&c.body, m, p, r, out); + } + if let Some(b) = finally { + stmts(b, m, p, r, out); + } + } + Stmt::Switch { + discriminant, + cases, + } => { + expr(discriminant, m, p, r, out); + for c in cases { + if let Some(e) = &c.test { + expr(e, m, p, r, out); + } + stmts(&c.body, m, p, r, out); + } + } + Stmt::Break + | Stmt::Continue + | Stmt::LabeledBreak(_) + | Stmt::LabeledContinue(_) + | Stmt::PreallocateBoxes(_) + | Stmt::PreallocateTdzBoxes(_) + | Stmt::ReleaseBoxes(_) => {} + } + } + } + let mut out = BTreeSet::new(); + stmts(&module.init, module, prefix, class_reps, &mut out); + for global in &module.globals { + if let Some(e) = &global.init { + expr(e, module, prefix, class_reps, &mut out); + } + } + for f in &module.functions { + stmts(&f.body, module, prefix, class_reps, &mut out); + } + for c in &module.classes { + for f in c + .constructor + .iter() + .chain(&c.methods) + .chain(&c.static_methods) + .chain(c.getters.iter().map(|(_, f)| f)) + .chain(c.setters.iter().map(|(_, f)| f)) + { + stmts(&f.body, module, prefix, class_reps, &mut out); + } + for f in c.fields.iter().chain(&c.static_fields) { + if let Some(e) = &f.init { + expr(e, module, prefix, class_reps, &mut out); + } + } + } + out.into_iter() + .map(|shape| ModuleBirth { + keys_global: format!("perry_constfn_final_{}", shape.constfn[0].symbol), + class_id: 0, + defined: false, + shape, + }) + .collect() +} + +pub(crate) fn entries_symbol(prefix: &str, id: u32) -> String { + format!("perry_constfn_final_{prefix}__{id}") +} + +pub(crate) fn emit_final_entries( + module: &mut crate::module::LlModule, + prefix: &str, + shapes: &[(BirthShape, u32)], +) { + for (shape, id) in shapes.iter().filter(|(shape, _)| !shape.constfn.is_empty()) { + let entries = shape + .constfn + .iter() + .map(|e| { + module.request_static_seed_body( + e.symbol.strip_suffix("$info").expect("body info suffix"), + ); + format!("{{ i32, ptr }} {{ i32 {}, ptr @{} }}", e.slot, e.symbol) + }) + .collect::>() + .join(", "); + module.add_raw_global(format!( + "@{} = private constant [{} x {{ i32, ptr }}] [{entries}]", + entries_symbol(prefix, *id), + shape.constfn.len() + )); + // Packed key names are finalizer metadata, not JavaScript strings. + // Putting them in StringPool would allocate and root a heap string + // for every distinct final layout, although only its bytes are used. + let packed = shape + .keys + .iter() + .map(|byte| format!("\\{byte:02X}")) + .collect::(); + module.add_named_string_constant( + &format!("{}_keys", entries_symbol(prefix, *id)), + shape.keys.len(), + &format!("c\"{packed}\""), + ); + } +} + +pub(crate) fn has_final_shapes() -> bool { + super::static_shape_ids::has_static_final_shapes() +} + +/// Called only below the last store/this patch. The runtime owns a root during +/// minting and returns its refreshed handle for the expression's result. +pub(crate) fn finalize_literal( + ctx: &mut crate::expr::FnCtx<'_>, + props: &[(String, Expr)], + base_rep: u64, + object: &str, +) -> String { + if !super::static_shape_ids::has_static_final_shapes() { + return object.to_string(); + } + let Some(shape) = literal_final(ctx.strings.module_prefix(), props, base_rep) else { + return object.to_string(); + }; + finalize_shape(ctx, &shape, object) +} + +pub(crate) fn finalize_class(ctx: &mut crate::expr::FnCtx<'_>, name: &str, boxed: &str) -> String { + let shape = ctx.classes.get(name).and_then(|class| { + let rep = *ctx + .class_birth_reps + .get(ctx.class_keys_globals.get(name)?)?; + let cid = *ctx.class_ids.get(name)?; + super::static_constfn_class::class_final( + ctx.strings.module_prefix(), + class, + ctx.classes, + rep, + cid, + ) + .ok() + }); + let Some(shape) = shape else { + return boxed.to_string(); + }; + if super::static_shape_ids::static_final_shape_id(&shape).is_none() { + return boxed.to_string(); + } + // `boxed` is the completed receiver, including constructor return override. + // The proof declines replacement-return constructors. Never recover the + // original allocation root here: super()/constructors own this selection. + let bits = ctx.block().bitcast_double_to_i64(boxed); + let handle = ctx + .block() + .and(crate::types::I64, &bits, crate::nanbox::POINTER_MASK_I64); + let result = finalize_shape(ctx, &shape, &handle); + crate::expr::nanbox_pointer_inline(ctx.block(), &result) +} + +fn finalize_shape(ctx: &mut crate::expr::FnCtx<'_>, shape: &BirthShape, object: &str) -> String { + let Some(id) = super::static_shape_ids::static_final_shape_id(&shape) else { + return object.to_string(); + }; + let entries = format!("@{}", entries_symbol(ctx.strings.module_prefix(), id)); + let global = format!("{entries}_keys"); + let len = shape.keys.len().to_string(); + use crate::types::{I32, I64, PTR}; + ctx.block().call( + I64, + "js_object_finalize_constfn_static", + &[ + (I64, object), + (I32, &id.to_string()), + (PTR, &global), + (I32, &len), + (I32, &shape.key_count.to_string()), + (I32, &shape.live.to_string()), + ( + I32, + &match shape.proto { + BirthProto::Literal => 0, + BirthProto::Class(cid) => cid, + } + .to_string(), + ), + (I64, &shape.rep.to_string()), + (PTR, &entries), + (I32, &shape.constfn.len().to_string()), + ], + ) +} + +#[cfg(test)] +#[path = "static_constfn_tests.rs"] +mod tests; diff --git a/crates/perry-codegen/src/codegen/static_constfn_class.rs b/crates/perry-codegen/src/codegen/static_constfn_class.rs new file mode 100644 index 0000000000..a82ca29126 --- /dev/null +++ b/crates/perry-codegen/src/codegen/static_constfn_class.rs @@ -0,0 +1,213 @@ +//! Conservative completed layouts for ordinary, locally defined user classes. +use super::{BirthProto, BirthShape, ModuleBirth}; +use perry_hir::{Class, Expr, Module, Stmt}; +use std::collections::{BTreeSet, HashMap}; + +// Admit expressions with no explicit property mutation or receiver dispatch. +// Operators can still coerce/collect; the runtime finalizer validates the +// resulting receiver after every construction effect has completed. +fn value_proof(e: &Expr) -> bool { + match e { + Expr::Closure { .. } => true, // body runs later, captures are current slots + // A direct, argument-free function effect cannot carry this receiver + // as an argument. It may allocate/collect; final facts are validated + // only after it returns. Receiver/property dispatch stays unsupported. + Expr::Call { callee, args, .. } => { + matches!(callee.as_ref(), Expr::FuncRef(_)) && args.is_empty() + } + Expr::Undefined + | Expr::Null + | Expr::Bool(_) + | Expr::Number(_) + | Expr::Integer(_) + | Expr::String(_) + | Expr::BigInt(_) + | Expr::LocalGet(_) + | Expr::GlobalGet(_) + | Expr::This => true, + Expr::Binary { .. } + | Expr::Unary { .. } + | Expr::Compare { .. } + | Expr::Logical { .. } + | Expr::Object(_) => { + let mut valid = true; + perry_hir::walker::walk_expr_children(e, &mut |child| valid &= value_proof(child)); + valid + } + _ => false, + } +} + +/// Concrete refusal reasons keep extensions of this proof reviewable. A local +/// root-to-leaf chain is required: imported stubs do not retain initializer +/// bodies, and dynamic/native heritage cannot prove a completed layout. +pub(crate) fn class_final( + prefix: &str, + class: &Class, + classes: &HashMap, + base_rep: u64, + class_id: u32, +) -> Result { + if class_id == 0 || class.name.starts_with("__AnonShape_") { + return Err("not an ordinary user class"); + } + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = class; + loop { + if !seen.insert(¤t.name) { + return Err("cyclic heritage"); + } + if current.is_imported_stub() + || current.extends_expr.is_some() + || current.native_extends.is_some() + || current.heritage_lexically_shadowed + { + return Err("unresolved or external heritage"); + } + if current.is_nested || current.alloc_width_hint != 0 { + return Err("captured or widened construction layout"); + } + if !current.decorators.is_empty() + || !current.computed_members.is_empty() + || current.has_private_instance_elements() + || !current.getters.is_empty() + || !current.setters.is_empty() + || current.methods.iter().any(|m| !m.decorators.is_empty()) + || current + .static_fields + .iter() + .any(|f| f.is_private || f.key_expr.is_some() || !f.decorators.is_empty()) + || current + .static_methods + .iter() + .any(|m| m.name.starts_with('#') || !m.decorators.is_empty()) + { + return Err("computed, private, decorated or accessor members"); + } + chain.push(current); + match current.extends_name.as_deref() { + Some(name) => { + let parent = classes + .get(name) + .copied() + .ok_or("unresolved named heritage")?; + if current.extends.is_some_and(|id| id != parent.id) { + return Err("ambiguous heritage identity"); + } + current = parent; + } + None if current.extends.is_some() => return Err("unnamed heritage"), + None => break, + } + } + let mut props = Vec::new(); + for c in chain.iter().rev() { + for field in &c.fields { + if field.is_private + || field.key_expr.is_some() + || !field.decorators.is_empty() + || field.name.starts_with("__perry_cap_") + { + return Err("nonpublic or captured field layout"); + } + let init = field.init.clone().unwrap_or(Expr::Undefined); + if !value_proof(&init) { + return Err("initializer has unsupported dispatch or property mutation"); + } + props.push((field.name.clone(), init)); + } + } + let names: BTreeSet<_> = props.iter().map(|(name, _)| name.as_str()).collect(); + for c in &chain { + if let Some(ctor) = &c.constructor { + if ctor.is_async + || ctor.is_generator + || !ctor.decorators.is_empty() + || ctor.params.iter().any(|p| { + !p.decorators.is_empty() || p.default.as_ref().is_some_and(|e| !value_proof(e)) + }) + { + return Err("unsupported constructor signature"); + } + let mut supers = 0; + for stmt in &ctor.body { + let valid = match stmt { + Stmt::Let { init, .. } => init.as_ref().is_none_or(value_proof), + Stmt::Expr(Expr::SuperCall(args)) if c.extends_name.is_some() => { + supers += 1; + supers == 1 && args.iter().all(value_proof) + } + Stmt::Expr(Expr::PropertySet { + object, + property, + value, + }) => { + matches!(object.as_ref(), Expr::This) + && names.contains(property.as_str()) + && value_proof(value) + } + Stmt::Expr(Expr::PutValueSet { + target, + key, + value, + receiver, + .. + }) => { + matches!(target.as_ref(), Expr::This) + && matches!(receiver.as_ref(), Expr::This) + && matches!(key.as_ref(), Expr::String(k) if names.contains(k.as_str())) + && value_proof(value) + } + Stmt::Expr(e) => value_proof(e), + _ => false, // early/value returns, branches, descriptors, delete + }; + if !valid { + return Err("constructor control flow or property mutation"); + } + } + if c.extends_name.is_some() && supers != 1 { + return Err("derived constructor requires one explicit super call"); + } + } + } + let mut shape = super::static_constfn::literal_final(prefix, &props, base_rep) + .ok_or("no safe closure initializer or uncertain slot order")?; + shape.proto = BirthProto::Class(class_id); + Ok(shape) +} + +pub(crate) fn module_class_finals( + module: &Module, + prefix: &str, + ordinary: &[ModuleBirth], + keys_globals: &HashMap, + class_ids: &HashMap, +) -> Vec { + let classes = module.classes.iter().map(|c| (c.name.clone(), c)).collect(); + let ordinary: HashMap<_, _> = ordinary + .iter() + .map(|b| (b.keys_global.as_str(), &b.shape)) + .collect(); + module + .classes + .iter() + .filter_map(|class| { + let keys = keys_globals.get(&class.name)?; + let birth = *ordinary.get(keys.as_str())?; + let cid = *class_ids.get(&class.name)?; + let shape = class_final(prefix, class, &classes, birth.rep, cid).ok()?; + // Allocation is the authority for keys and live capacity too. A + // widened/inferred layout is declined, never guessed by this producer. + if shape.keys != birth.keys || shape.live != birth.live || shape.proto != birth.proto { + return None; + } + Some(ModuleBirth { + keys_global: format!("perry_constfn_class_final_{prefix}__{cid}"), + class_id: cid, + defined: false, + shape, + }) + }) + .collect() +} diff --git a/crates/perry-codegen/src/codegen/static_constfn_tests.rs b/crates/perry-codegen/src/codegen/static_constfn_tests.rs new file mode 100644 index 0000000000..05f6ff766b --- /dev/null +++ b/crates/perry-codegen/src/codegen/static_constfn_tests.rs @@ -0,0 +1,700 @@ +use super::*; +use perry_hir::types::Type; +use perry_hir::{Function, Param}; + +static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); +struct Pin(Option); +impl Drop for Pin { + fn drop(&mut self) { + match self.0.take() { + Some(v) => std::env::set_var("PERRY_CONSTFN_SHAPE", v), + None => std::env::remove_var("PERRY_CONSTFN_SHAPE"), + } + } +} +fn closure(id: u32, this: bool) -> Expr { + Expr::Closure { + func_id: id, + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::Number(1.0)))], + captures: Vec::new(), + mutable_captures: Vec::new(), + captures_this: this, + captures_new_target: false, + enclosing_class: None, + is_arrow: !this, + is_async: false, + is_generator: false, + is_strict: true, + } +} +fn fixture() -> Module { + let mut m = Module::new("cf_final_order"); + m.init.push(Stmt::Expr(Expr::Object(vec![ + ("m".into(), closure(1, false)), + ("unsafe".into(), closure(2, true)), + ("collecting".into(), Expr::Object(Vec::new())), + ]))); + m +} +fn opts(output: &str) -> CompileOptions { + CompileOptions { + emit_ir_only: true, + output_type: output.into(), + ..Default::default() + } +} + +#[test] +fn final_literal_seed_and_lowering_share_symbols_and_stamp_after_stores_and_patches() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let m = fixture(); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + assert_eq!( + births.len(), + 1, + "the collecting literal must have a final producer" + ); + let shape = &births[0].shape; + assert_eq!(shape.rep, 3, "only the safe closure lane becomes SPECIAL"); + assert_eq!( + shape.constfn[0].symbol, + "perry_closure_cf_final_order__1$info" + ); + let assigned = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)); + let id = assigned[shape]; + let mut options = opts("executable"); + options.static_shape_ids = assigned.into_iter().collect(); + let ir = String::from_utf8(crate::compile_module(&m, options.clone()).unwrap()).unwrap(); + let body = ir + .lines() + .filter(|l| !l.starts_with("declare ")) + .collect::>() + .join("\n"); + let stamp = body + .find("call i64 @js_object_finalize_constfn_static") + .expect("finalizer was emitted"); + assert!( + body.rfind("call void @js_object_set_field").unwrap() < stamp, + "every store must precede promotion" + ); + assert!( + body.rfind("call void @js_closure_set_capture_bits") + .unwrap() + < stamp, + "this patches must precede promotion" + ); + let allocation = body + .find("call i64 @js_object_alloc_with_shape") + .expect("ordinary allocation"); + assert!(allocation < stamp); + assert!( + !body[allocation..body[allocation..].find('\n').unwrap() + allocation] + .contains(&format!("i32 {id},")), + "allocation cannot name the final id" + ); + assert!( + ir.contains("hidden constant") && ir.contains("perry_closure_cf_final_order__1$info"), + "seed body info must be linkable" + ); + let seeds = super::super::static_shape_ids::take_module_static_seeds(); + assert_eq!(seeds, vec![(id, shape.clone())]); + let warm = crate::decode_static_seed(&crate::encode_static_seed(id, shape)).unwrap(); + assert_eq!( + crate::stubs::static_shape_seed_ll(&seeds), + crate::stubs::static_shape_seed_ll(&[warm]), + "cold and sidecar replay must have identical seed references" + ); + std::env::set_var("PERRY_CONSTFN_SHAPE", "0"); + let off = String::from_utf8(crate::compile_module(&m, opts("executable")).unwrap()).unwrap(); + let ordinary_atoms = off.matches("call i64 @js_string_pool_atom").count(); + assert!( + ordinary_atoms > 0, + "the ordinary string pool must be exercised" + ); + assert_eq!( + ir.matches("call i64 @js_string_pool_atom").count(), + ordinary_atoms, + "packed finalizer metadata must not allocate JavaScript string-pool atoms" + ); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + options.output_type = "dylib".into(); + let unloadable = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + assert!(!unloadable.contains("call i64 @js_object_finalize_constfn_static")); + assert!(super::super::static_shape_ids::take_module_static_seeds().is_empty()); +} + +#[test] +fn literal_proof_rejects_duplicates_and_rebindable_rest_bodies() { + let safe = closure(1, false); + let first = literal_final("p", &[("m".into(), safe.clone())], 0).unwrap(); + let second = literal_final("p", &[("m".into(), closure(2, false))], 0).unwrap(); + assert_ne!(first, second, "exact body symbols split final identity"); + assert_eq!( + first, + literal_final("p", &[("m".into(), safe.clone())], 0).unwrap() + ); + assert!(literal_final( + "p", + &[("m".into(), safe.clone()), ("m".into(), safe.clone())], + 0 + ) + .is_none()); + assert!(literal_final("p", &[("__proto__".into(), safe.clone())], 0).is_none()); + assert!(literal_final("p", &[("m".into(), closure(3, true))], 0).is_none()); + let mut rest = safe; + if let Expr::Closure { params, .. } = &mut rest { + params.push(Param { + id: 4, + name: "args".into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: true, + arguments_object: None, + }); + } + assert!(literal_final("p", &[("m".into(), rest)], 0).is_none()); +} + +#[cfg(feature = "llvm-inprocess")] +#[test] +fn final_key_bytes_survive_llvm_decoding_and_codegen_unit_splitting() { + use crate::types::{I32, PTR, VOID}; + + let shape = literal_final( + "packed_keys", + &[ + ("mé雪🦀".into(), closure(1, false)), + ("quote\"slash\\".into(), Expr::Number(1.0)), + ], + 0, + ) + .unwrap(); + let expected = "mé雪🦀\0quote\"slash\\\0".as_bytes(); + let entries = entries_symbol("packed_keys", 23); + let keys = format!("{entries}_keys"); + for target in [ + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "arm64-apple-macosx15.0.0", + ] { + let mut module = crate::module::LlModule::new(target); + module.add_external_global(&shape.constfn[0].symbol, PTR); + emit_final_entries(&mut module, "packed_keys", &[(shape.clone(), 23)]); + module.declare_function("consume", VOID, &[PTR, PTR, I32]); + // Both units use the same layout: ELF/COFF need one owner plus an + // external reference; Mach-O needs duplicate-safe definitions. + for name in ["first_factory", "second_factory"] { + let block = module + .define_function(name, VOID, vec![]) + .create_block("entry"); + block.call_void( + "consume", + &[ + (PTR, &format!("@{keys}")), + (PTR, &format!("@{entries}")), + (I32, &expected.len().to_string()), + ], + ); + block.ret_void(); + } + let units = module.render_codegen_units(2); + assert_eq!(units.len(), 2); + let context = inkwell::context::Context::create(); + let mut definitions = 0; + for unit in units { + let parsed = crate::inprocess::parse_ir_text(&context, &unit, "final_key_bytes") + .expect("packed metadata and all cross-unit references must parse"); + parsed.verify().expect("valid finalizer metadata unit"); + let global = parsed.get_global(&keys).expect("every consumer needs keys"); + assert!(global.is_constant()); + if let Some(initializer) = global.get_initializer() { + definitions += 1; + assert_eq!( + initializer.into_array_value().as_const_string().unwrap(), + expected, + "LLVM must decode exact UTF-8 bytes and one NUL per key" + ); + } + } + assert_eq!(definitions, if target.contains("apple") { 2 } else { 1 }); + } +} + +fn empty_class() -> Class { + Class { + id: 7, + name: "__AnonShape_test".into(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + } +} + +#[test] +fn anonymous_record_admission_uses_the_full_constructor_proof() { + let mut class = empty_class(); + class.constructor = Some(Function { + id: 8, + name: "constructor".into(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(Expr::Object(Vec::new())))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + assert!(anon_props(&class, &[]).is_none()); + class.constructor.as_mut().unwrap().body.clear(); + assert!( + anon_props(&class, &[]).is_some(), + "the exact synthetic constructor must be admitted" + ); + class + .getters + .push(("m".into(), class.constructor.clone().unwrap())); + assert!( + anon_props(&class, &[]).is_none(), + "descriptor-bearing constructors stay ordinary" + ); +} + +#[test] +fn closed_literal_constructor_emits_a_separate_final_shape() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let mut class = empty_class(); + class.fields.push(perry_hir::ClassField { + name: "m".into(), + key_expr: None, + ty: Type::Any, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }); + class.constructor = Some(Function { + id: 8, + name: "constructor".into(), + type_params: Vec::new(), + params: vec![Param { + id: 9, + name: "m".into(), + ty: Type::Any, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }], + return_type: Type::Void, + body: vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "m".into(), + value: Box::new(Expr::LocalGet(9)), + })], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }); + assert!( + class.is_literal_shape(), + "record constructor proof must be live" + ); + let mut m = Module::new("cf_closed_literal"); + m.init.push(Stmt::Expr(Expr::New { + class_name: class.name.clone(), + args: vec![closure(1, false)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + m.classes.push(class); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + let final_shape = births + .iter() + .find(|b| !b.shape.constfn.is_empty()) + .expect("closed literal final content") + .shape + .clone(); + assert_eq!( + births.len(), + 2, + "ordinary allocation and final content must coexist" + ); + assert_eq!(final_shape.rep, 3); + assert!(births + .iter() + .any(|b| b.shape.constfn.is_empty() && b.shape.rep == 0)); + let assigned = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)); + let mut options = opts("executable"); + options.static_shape_ids = assigned.into_iter().collect(); + let ir = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + assert_eq!( + ir.matches("call i64 @js_object_finalize_constfn_static") + .count(), + 1, + "completed record must call the finalizer once" + ); + assert!(super::super::static_shape_ids::take_module_static_seeds() + .iter() + .any(|(_, s)| s == &final_shape)); +} + +fn user_class(name: &str, id: u32, method_id: u32) -> Class { + let mut class = empty_class(); + class.name = name.into(); + class.id = id; + class.fields.push(perry_hir::ClassField { + name: format!("m{id}"), + key_expr: None, + ty: Type::Any, + init: Some(closure(method_id, false)), + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }); + class +} + +fn constructor(body: Vec) -> Function { + Function { + id: 100, + name: "constructor".into(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Void, + body, + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +#[test] +fn general_class_proof_covers_local_inheritance_and_declines_uncertain_construction() { + use super::super::static_constfn_class::class_final; + let base = user_class("Base", 7, 1); + let mut child = user_class("Child", 8, 2); + child.extends = Some(7); + child.extends_name = Some("Base".into()); + child.constructor = Some(constructor(vec![Stmt::Expr(Expr::SuperCall(Vec::new()))])); + let parents = HashMap::from([("Base".into(), &base)]); + let shape = class_final("p", &child, &parents, 0, 88).unwrap(); + assert_eq!(shape.proto, BirthProto::Class(88)); + assert_eq!(shape.keys, b"m7\0m8\0"); + assert_eq!(shape.rep, 15); + assert_eq!( + shape.constfn.iter().map(|e| e.slot).collect::>(), + vec![0, 1] + ); + assert_eq!(shape.constfn[0].symbol, "perry_closure_p__1$info"); + assert_eq!( + class_final("p", &child, &HashMap::new(), 0, 88).unwrap_err(), + "unresolved named heritage" + ); + child.constructor = Some(constructor(vec![Stmt::Return(None)])); + assert_eq!( + class_final("p", &child, &parents, 0, 88).unwrap_err(), + "constructor control flow or property mutation" + ); + child.constructor = Some(constructor(vec![Stmt::Return(Some(Expr::Object( + Vec::new(), + )))])); + assert!(class_final("p", &child, &parents, 0, 88).is_err()); + child.constructor = None; + child.fields[0].key_expr = Some(Expr::String("m8".into())); + assert_eq!( + class_final("p", &child, &parents, 0, 88).unwrap_err(), + "nonpublic or captured field layout" + ); + child.fields[0].key_expr = None; + child.fields[0].is_private = true; + assert!(class_final("p", &child, &parents, 0, 88).is_err()); + child.fields[0].is_private = false; + child.fields[0].name = "m7".into(); + assert_eq!( + class_final("p", &child, &parents, 0, 88).unwrap_err(), + "no safe closure initializer or uncertain slot order" + ); + child.fields[0].name = "m8".into(); + child.extends_expr = Some(Box::new(Expr::GlobalGet(1))); + assert_eq!( + class_final("p", &child, &parents, 0, 88).unwrap_err(), + "unresolved or external heritage" + ); + let mut mutated = base.clone(); + mutated.constructor = Some(constructor(vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "added".into(), + value: Box::new(Expr::Number(1.0)), + })])); + assert_eq!( + class_final("p", &mutated, &HashMap::new(), 0, 77).unwrap_err(), + "constructor control flow or property mutation" + ); +} + +#[test] +fn general_class_records_follow_registration_and_finalize_the_completed_result() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let mut class = user_class("User", 7, 1); + class.fields.push(perry_hir::ClassField { + name: "effect".into(), + key_expr: None, + ty: Type::Any, + init: Some(Expr::Call { + callee: Box::new(Expr::FuncRef(90)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }); + class.constructor = Some(constructor(vec![Stmt::Expr(Expr::PropertySet { + object: Box::new(Expr::This), + property: "effect".into(), + value: Box::new(Expr::Object(Vec::new())), + })])); + let mut m = Module::new("cf_user_class"); + let mut effect = constructor(vec![ + Stmt::Expr(Expr::Object(Vec::new())), + Stmt::Return(Some(Expr::Number(1.0))), + ]); + effect.id = 90; + effect.name = "collect".into(); + effect.return_type = Type::Any; + m.functions.push(effect); + m.classes.push(class); + m.init.push(Stmt::Expr(Expr::New { + class_name: "User".into(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + let ordinary = births.iter().find(|b| b.shape.constfn.is_empty()).unwrap(); + let final_content = births.iter().find(|b| !b.shape.constfn.is_empty()).unwrap(); + assert_eq!(births.len(), 2); + assert_eq!(ordinary.shape.keys, final_content.shape.keys); + assert_eq!(ordinary.shape.proto, final_content.shape.proto); + assert_eq!(ordinary.shape.rep, 0); + assert_eq!(final_content.shape.rep, 3); + let assigned = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)); + let final_id = assigned[&final_content.shape]; + let mut options = opts("executable"); + options.static_shape_ids = assigned.into_iter().collect(); + let ir = String::from_utf8(crate::compile_module(&m, options.clone()).unwrap()).unwrap(); + let calls = ir + .lines() + .filter(|l| l.contains(" call ")) + .collect::>(); + let mint = calls + .iter() + .position(|l| l.contains("@js_object_final_shape_id_for_class_keys_static_constfn")) + .unwrap(); + assert!( + calls + .iter() + .rposition(|l| l.contains("@js_register_class_name")) + .unwrap() + < mint + ); + let mint_line = calls[mint]; + assert!( + mint_line.contains(&format!("i32 {final_id}, i64 3")), + "{mint_line}" + ); + assert!(mint_line.contains("ptr @perry_constfn_final_cf_user_class__")); + assert!(ir.contains("perry_closure_cf_user_class__1$info = hidden constant")); + let stamp = calls + .iter() + .position(|l| l.contains("@js_object_finalize_constfn_static")) + .unwrap(); + let override_pos = calls + .iter() + .position(|l| l.contains("@js_ctor_return_override")) + .unwrap(); + assert!( + override_pos < stamp, + "completed receiver selection must precede finalization" + ); + assert_eq!( + calls + .iter() + .filter(|l| l.contains("@js_object_finalize_constfn_static")) + .count(), + 1 + ); + for line in calls.iter().filter(|l| l.contains("@js_object_alloc")) { + assert!( + !line.contains(&format!("i32 {final_id}")), + "allocation used final shape: {line}" + ); + } + // Class facts are minted by the cached defining object after registration, + // while startup seed sidecars remain reserved for literal prototypes. + assert!(super::super::static_shape_ids::take_module_static_seeds() + .iter() + .all(|(_, shape)| shape.proto == BirthProto::Literal)); + let warm = String::from_utf8(crate::compile_module(&m, options.clone()).unwrap()).unwrap(); + assert_eq!(ir, warm); + options.output_type = "dylib".into(); + let unloadable = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + assert!( + !unloadable.contains("call i32 @js_object_final_shape_id_for_class_keys_static_constfn") + ); + assert!(!unloadable.contains("call i64 @js_object_finalize_constfn_static")); +} + +#[test] +fn class_replacement_returns_never_produce_a_final_record() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let mut class = user_class("Replacement", 7, 1); + class.constructor = Some(constructor(vec![Stmt::Return(Some(Expr::Object( + Vec::new(), + )))])); + let mut m = Module::new("cf_replacement"); + m.init.push(Stmt::Expr(Expr::Object(vec![( + "unrelated".into(), + closure(2, false), + )]))); + m.classes.push(class); + m.init.push(Stmt::Expr(Expr::New { + class_name: "Replacement".into(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + assert!(births + .iter() + .all(|b| b.shape.constfn.is_empty() || b.shape.proto == BirthProto::Literal)); + assert!( + births.iter().any(|b| !b.shape.constfn.is_empty()), + "unrelated final ids keep the finalizer supplier active" + ); + let mut options = opts("executable"); + options.static_shape_ids = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)) + .into_iter() + .collect(); + let ir = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + assert_eq!( + ir.matches("call i64 @js_object_finalize_constfn_static") + .count(), + 1, + "only the unrelated literal is finalized; the class allocation stays ordinary" + ); +} + +#[test] +fn default_derived_class_finalizes_inherited_and_own_closure_fields() { + let _lock = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let _pin = Pin(std::env::var_os("PERRY_CONSTFN_SHAPE")); + std::env::set_var("PERRY_CONSTFN_SHAPE", "1"); + let base = user_class("Base", 7, 1); + let mut child = user_class("Child", 8, 2); + child.extends = Some(base.id); + child.extends_name = Some(base.name.clone()); + let mut m = Module::new("cf_user_inherit"); + m.classes.extend([base, child]); + m.init.push(Stmt::Expr(Expr::New { + class_name: "Child".into(), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + })); + let births = crate::module_birth_shapes(&m, opts("executable")).unwrap(); + assert_eq!( + births + .iter() + .filter(|b| !b.shape.constfn.is_empty()) + .count(), + 2 + ); + let child_final = &births + .iter() + .find(|b| !b.shape.constfn.is_empty() && b.shape.key_count == 2) + .unwrap() + .shape; + assert_eq!(child_final.keys, b"m7\0m8\0"); + assert_eq!(child_final.rep, 15); + let assigned = + super::super::static_shape_ids::assign_static_shape_ids(births.iter().map(|b| &b.shape)); + let id = assigned[child_final]; + let allocation_ids = + super::super::static_shape_ids::ProgramClassShapeIds::from_births(&births, &assigned); + assert!( + allocation_ids + .0 + .values() + .all(|b| b.shape.constfn.is_empty()), + "general final records cannot enter the class allocation supplier" + ); + let mut options = opts("executable"); + options.static_shape_ids = assigned.into_iter().collect(); + let ir = String::from_utf8(crate::compile_module(&m, options).unwrap()).unwrap(); + let finalizers = ir + .lines() + .filter(|l| l.contains("call i64 @js_object_finalize_constfn_static")) + .collect::>(); + assert_eq!(finalizers.len(), 1); + assert!(finalizers[0].contains(&format!("i32 {id},")) && finalizers[0].contains("i64 15,")); + assert_eq!( + ir.matches("call i32 @js_object_final_shape_id_for_class_keys_static_constfn") + .count(), + 2 + ); +} diff --git a/crates/perry-codegen/src/codegen/static_shape_ids.rs b/crates/perry-codegen/src/codegen/static_shape_ids.rs index 32229c9608..9cadedaf61 100644 --- a/crates/perry-codegen/src/codegen/static_shape_ids.rs +++ b/crates/perry-codegen/src/codegen/static_shape_ids.rs @@ -44,6 +44,15 @@ pub struct TypedMasks { pub pointer_words: Vec, } +/// One compile-time ConstFn body fact. `symbol` is the defining body's +/// stable LLVM info symbol (without `@`), never an ASLR address. The linker +/// resolves it to the one `JsFunctionInfo` for that body in every agent. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ConstFnBirth { + pub slot: u8, + pub symbol: String, +} + /// The content of one compiler-visible birth shape. #[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct BirthShape { @@ -60,6 +69,9 @@ pub struct BirthShape { /// an importer's all-`Any` stub of the same keys are two contents, and /// the stub never adopts the definer's id. pub rep: u64, + /// Sorted, unique body symbols for SPECIAL lanes. The current class-birth + /// collector leaves this empty; post-construction producers populate it. + pub constfn: Vec, } impl BirthShape { @@ -75,8 +87,15 @@ impl BirthShape { /// The facts the runtime mints for this content, without the masks: a /// typed layout and a structural mint of the same class share them. The /// rep is a runtime fact, so it is part of them. - pub(crate) fn structure(&self) -> (&[u8], u32, u32, &BirthProto, u64) { - (&self.keys, self.key_count, self.live, &self.proto, self.rep) + pub(crate) fn structure(&self) -> (&[u8], u32, u32, &BirthProto, u64, &[ConstFnBirth]) { + ( + &self.keys, + self.key_count, + self.live, + &self.proto, + self.rep, + &self.constfn, + ) } /// A stable 64-bit FNV-1a over the content (never `RandomState`: the id @@ -115,6 +134,17 @@ impl BirthShape { eat(&[3]); eat(&self.rep.to_le_bytes()); } + // Preserve every old content hash when there is no ConstFn fact. + // Body names, never load addresses, determine static ids. + if !self.constfn.is_empty() { + eat(&[4]); + eat(&(self.constfn.len() as u32).to_le_bytes()); + for entry in &self.constfn { + eat(&[entry.slot]); + eat(&(entry.symbol.len() as u32).to_le_bytes()); + eat(entry.symbol.as_bytes()); + } + } h } @@ -351,6 +381,7 @@ pub(crate) fn class_birth( }, typed: None, rep: class_birth_reps.get(global_name).copied().unwrap_or(0), + constfn: Vec::new(), }); ClassBirth { class_id, @@ -367,6 +398,10 @@ thread_local! { /// content (the facts the id names: a resolved definer id names the same /// structure). Set by `compile_module` for every module (empty when the /// driver assigned none). + static MODULE_FINAL_IDS: RefCell> = RefCell::new(HashMap::new()); + /// Final records named by this module's finalizers or class-registration + /// mints, including class prototypes that cannot use startup literal seeds. + static MODULE_FINAL_USES: RefCell> = RefCell::new(BTreeMap::new()); static MODULE_STATIC_IDS: RefCell> = RefCell::new(HashMap::new()); /// The seedable static ids this module's GUARDS embedded, with their @@ -410,9 +445,17 @@ pub(crate) fn set_module_static_ids( }) .collect() }; + MODULE_FINAL_IDS.with(|m| { + *m.borrow_mut() = assigned + .iter() + .filter(|(shape, _)| !shape.constfn.is_empty()) + .cloned() + .collect() + }); MODULE_STATIC_IDS.with(|m| *m.borrow_mut() = map); MODULE_PROGRAM_IDS.with(|m| *m.borrow_mut() = program.clone()); MODULE_SEEDS.with(|s| s.borrow_mut().clear()); + MODULE_FINAL_USES.with(|s| s.borrow_mut().clear()); } /// Note that a guard embeds `id` as an immediate: a seedable content joins @@ -425,6 +468,35 @@ fn note_guard_id(id: u32, seed: Option<&BirthShape>) { } } +pub(crate) fn has_static_final_shapes() -> bool { + MODULE_FINAL_IDS.with(|m| !m.borrow().is_empty()) +} + +pub(crate) fn disable_static_final_shapes() { + MODULE_FINAL_IDS.with(|m| m.borrow_mut().clear()); +} + +/// Final ids are requested only after construction, never by allocation guards. +pub(crate) fn static_final_shape_id(shape: &BirthShape) -> Option { + let id = MODULE_FINAL_IDS.with(|m| m.borrow().get(shape).copied())?; + note_guard_id(id, Some(shape)); + MODULE_FINAL_USES.with(|s| { + s.borrow_mut().insert(id, shape.clone()); + }); + Some(id) +} + +/// Only final shapes named by emitted finalizers or class mints need body references. +pub(crate) fn module_final_seeds() -> Vec<(BirthShape, u32)> { + MODULE_FINAL_USES.with(|s| { + s.borrow() + .iter() + .filter(|(_, shape)| !shape.constfn.is_empty()) + .map(|(id, shape)| (shape.clone(), *id)) + .collect() + }) +} + /// Drain the seed set of the module just compiled on this thread: every /// seedable static id its guards embedded, with its content. The driver /// persists it beside the module's cached object, so a cache hit replays the @@ -437,22 +509,41 @@ pub fn take_module_static_seeds() -> Vec<(u32, BirthShape)> { /// part of the object-cache key: an entry written in another format is a /// miss, never a line this decoder reads as other facts (a pinned /// `PERRY_OBJECT_CACHE_BUILD_ID` keeps the build id across compilers). -pub const STATIC_SEED_FORMAT: &str = "2"; +pub const STATIC_SEED_FORMAT: &str = "3"; -/// One seed as a line of the object cache's seed sidecar: -/// ` `, -/// the rep as `0x`-prefixed hex. Every field the seed mints from is in the -/// line: a warm link seeds exactly the facts the cold one did. +/// One seed as a line of the object cache's sidecar: +/// ` `. +/// `body_entries` is `-` or comma-separated `@` pairs. +/// ConstFn entries describe opt-in post-construction final shapes. Warm cache +/// replay preserves the same body references as a cold executable link. pub fn encode_static_seed(id: u32, shape: &BirthShape) -> String { let hex: String = shape.keys.iter().map(|b| format!("{b:02x}")).collect(); + let bodies = if shape.constfn.is_empty() { + "-".to_string() + } else { + shape + .constfn + .iter() + .map(|entry| { + let symbol: String = entry + .symbol + .as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + format!("{}@{symbol}", entry.slot) + }) + .collect::>() + .join(",") + }; format!( - "{id} {} {} {hex} {:#x}", + "{id} {} {} {hex} {:#x} {bodies}", shape.key_count, shape.live, shape.rep ) } -/// The inverse of [`encode_static_seed`]; `None` for a malformed line -/// (including a line of another format, which lacks the rep field). +/// Decode the exact current sidecar format. A missing body field, malformed +/// symbol, unsorted/duplicate slot, or SPECIAL/metadata mismatch is a miss. pub fn decode_static_seed(line: &str) -> Option<(u32, BirthShape)> { let mut it = line.split_ascii_whitespace(); let id = it.next()?.parse().ok()?; @@ -460,6 +551,7 @@ pub fn decode_static_seed(line: &str) -> Option<(u32, BirthShape)> { let live = it.next()?.parse().ok()?; let hex = it.next()?; let rep = u64::from_str_radix(it.next()?.strip_prefix("0x")?, 16).ok()?; + let bodies = it.next()?; if it.next().is_some() || hex.is_empty() || hex.len() % 2 != 0 { return None; } @@ -467,6 +559,52 @@ pub fn decode_static_seed(line: &str) -> Option<(u32, BirthShape)> { .step_by(2) .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok()) .collect::>>()?; + let constfn = if bodies == "-" { + Vec::new() + } else { + let mut entries = Vec::new(); + for text in bodies.split(',') { + let (slot, encoded) = text.split_once('@')?; + let slot: u8 = slot.parse().ok()?; + if slot >= 32 || encoded.is_empty() || encoded.len() % 2 != 0 { + return None; + } + if entries + .last() + .is_some_and(|entry: &ConstFnBirth| entry.slot >= slot) + { + return None; + } + let bytes = (0..encoded.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&encoded[i..i + 2], 16).ok()) + .collect::>>()?; + let symbol = String::from_utf8(bytes).ok()?; + if !symbol + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.$".contains(&b)) + { + return None; + } + if (rep >> (u32::from(slot) * 2)) & 3 != 3 { + return None; + } + entries.push(ConstFnBirth { slot, symbol }); + } + entries + }; + let mut special = 0u32; + for slot in 0..32 { + if (rep >> (slot * 2)) & 3 == 3 { + special |= 1 << slot; + } + } + let covered = constfn + .iter() + .fold(0u32, |mask, entry| mask | (1 << entry.slot)); + if special != covered { + return None; + } Some(( id, BirthShape { @@ -476,6 +614,7 @@ pub fn decode_static_seed(line: &str) -> Option<(u32, BirthShape)> { proto: BirthProto::Literal, typed: None, rep, + constfn, }, )) } @@ -487,11 +626,89 @@ pub(crate) fn static_shape_id_for_keys_global(keys_global: &str) -> Option MODULE_STATIC_IDS.with(|m| { let m = m.borrow(); let (id, shape) = m.get(keys_global)?; + if !shape.constfn.is_empty() { + return None; + } note_guard_id(*id, Some(shape)); Some(*id) }) } +/// A contained receiver proof proves offsets, not a function body's invariant. +/// An inherited method can receive a subclass layout: match the key at this +/// slot across completed contents instead of treating allocation class as the +/// only possible receiver. Such boxed stores must use the checked slot funnel. +pub(crate) fn slot_may_be_constfn(keys_global: &str, slot: u32) -> bool { + let birth = MODULE_STATIC_IDS.with(|m| m.borrow().get(keys_global).map(|(_, s)| s.clone())); + let Some(birth) = birth else { + return false; + }; + let name = birth.keys.split(|&b| b == 0).nth(slot as usize); + MODULE_FINAL_IDS.with(|m| { + m.borrow().keys().any(|s| { + s.constfn.iter().any(|i| i.slot as u32 == slot) + && s.keys.split(|&b| b == 0).nth(slot as usize) == name + }) + }) +} + +/// Guard-only compatible completed identities. Allocation suppliers continue +/// returning the ordinary birth id. Match all structural facts and preserve +/// every base representation; a written SPECIAL slot cannot use a raw store. +pub(crate) fn compatible_final_shape_ids(expected: &str, written_slots: &[u32]) -> Vec { + let Ok(expected) = expected.parse::() else { + return Vec::new(); + }; + let birth = MODULE_STATIC_IDS + .with(|m| { + m.borrow() + .values() + .find(|(id, _)| *id == expected) + .map(|(_, s)| s.clone()) + }) + .or_else(|| { + MODULE_PROGRAM_IDS.with(|m| { + m.borrow() + .0 + .values() + .find(|d| d.id == expected) + .map(|d| d.shape.clone()) + }) + }); + let Some(birth) = birth else { + return Vec::new(); + }; + let candidates: Vec<(BirthShape, u32)> = MODULE_FINAL_IDS.with(|m| { + m.borrow() + .iter() + .filter_map(|(shape, &id)| { + let ordinary_rep = shape + .constfn + .iter() + .fold(shape.rep, |rep, entry| rep & !(3u64 << (2 * entry.slot))); + (shape.keys == birth.keys + && shape.key_count == birth.key_count + && shape.live == birth.live + && shape.proto == birth.proto + && ordinary_rep == birth.rep + && !shape + .constfn + .iter() + .any(|i| written_slots.contains(&(i.slot as u32)))) + .then(|| (shape.clone(), id)) + }) + .collect() + }); + let mut ids = Vec::new(); + for (shape, id) in candidates { + note_guard_id(id, Some(&shape)); + ids.push(id); + } + ids.sort_unstable(); + ids.dedup(); + ids +} + /// The static supplier of a loop region (DESIGN §4.1): the static id of /// `keys_global` and the inline slot of each of `keys` in the birth shape /// that id names, when every key is an inline data slot the region word can @@ -504,16 +721,20 @@ pub(crate) fn static_shape_id_for_keys_global(keys_global: &str) -> Option /// inline key of the birth shape, or when a key in `boxed_mask` (a bare /// store of a value not proven a canonical double) sits on a non-`Any` lane /// of the birth rep: the runtime's pack refuses that word too (charter step -/// 5). A returned id is a guard immediate: it joins the module's seed set -/// like any other. +/// 5). The third result is R in region-key order: only identity F64 lanes +/// of this exact birth ShapeId set a bit. A returned id is a guard immediate: +/// it joins the module's seed set like any other. pub(crate) fn static_region_slots( keys_global: &str, keys: &[String], boxed_mask: u32, -) -> Option<(u32, Vec)> { +) -> Option<(u32, Vec, u32)> { MODULE_STATIC_IDS.with(|m| { let m = m.borrow(); let (id, shape) = m.get(keys_global)?; + if !shape.constfn.is_empty() { + return None; + } let names: Vec<&[u8]> = shape .keys .strip_suffix(&[0]) @@ -538,15 +759,32 @@ pub(crate) fn static_region_slots( { return None; } + let r_mask = slots.iter().enumerate().fold(0u32, |mask, (i, &slot)| { + if (shape.rep >> (2 * slot)) & 0b11 == 0b01 { + mask | (1 << i) + } else { + mask + } + }); note_guard_id(*id, Some(shape)); - Some((*id, slots)) + Some((*id, slots, r_mask)) }) } -/// The static id this module's mint of `keys_global` requests (the same id -/// its guards embed; a mint alone does not need a seed). +/// The static id this module's mint of `keys_global` requests. A literal's +/// key-cache builder already mints its plain layout before the class mint, so +/// it needs a startup seed even when no guard embeds this id. Declared-class +/// prototypes differ from the plain key-cache layout and do not need a seed. pub(crate) fn requested_shape_id_for_keys_global(keys_global: &str) -> Option { - MODULE_STATIC_IDS.with(|m| m.borrow().get(keys_global).map(|(id, _)| *id)) + MODULE_STATIC_IDS.with(|m| { + m.borrow() + .get(keys_global) + .filter(|(_, shape)| shape.constfn.is_empty()) + .map(|(id, shape)| { + note_guard_id(*id, Some(shape)); + *id + }) + }) } /// The static id behind ANOTHER module's shape-id global `shape_id_global` @@ -564,6 +802,9 @@ pub(crate) fn static_shape_id_for_foreign_global( { return None; } + if !d.shape.constfn.is_empty() { + return None; + } note_guard_id(d.id, Some(&d.shape)); Some(d.id) }) diff --git a/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs b/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs index ca396503f7..b92a055675 100644 --- a/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs +++ b/crates/perry-codegen/src/codegen/static_shape_ids_tests.rs @@ -8,6 +8,7 @@ fn class(keys: &str, count: u32, cid: u32) -> BirthShape { proto: BirthProto::Class(cid), typed: None, rep: 0, + constfn: Vec::new(), } } @@ -277,6 +278,61 @@ fn an_f64_birth_rep_is_content_and_a_stub_never_adopts_it() { assert!(lit.is_seedable()); } +#[test] +fn constfn_body_symbols_are_seedable_final_static_content() { + let body = |symbol: &str| BirthShape { + proto: BirthProto::Literal, + rep: 0b11, + constfn: vec![ConstFnBirth { + slot: 0, + symbol: symbol.to_string(), + }], + ..class("method\0", 1, 0) + }; + let first = body("perry_closure_m__first$info"); + let second = body("perry_closure_m__second$info"); + assert_ne!(first.content_hash(), second.content_hash()); + assert_ne!(first.structure(), second.structure()); + assert!( + first.is_seedable(), + "final literal shapes have a body-aware seed" + ); + let line = encode_static_seed(0x1000_0099, &first); + assert_eq!(decode_static_seed(&line), Some((0x1000_0099, first))); + assert_eq!(decode_static_seed("268435609 1 1 6d6574686f6400 0x3"), None); + assert_eq!( + decode_static_seed("268435609 1 1 6d6574686f6400 0x0 0@61"), + None + ); + assert_eq!( + decode_static_seed("268435609 1 1 6d6574686f6400 0x3 0@61,0@62"), + None + ); +} + +#[test] +fn constfn_birth_cannot_publish_a_static_guard_or_seed() { + let shape = BirthShape { + proto: BirthProto::Literal, + rep: 0b11, + constfn: vec![ConstFnBirth { + slot: 0, + symbol: "perry_closure_m__method$info".to_string(), + }], + ..class("method\0", 1, 0) + }; + let key = "perry_class_keys_m__method"; + MODULE_STATIC_IDS.with(|m| { + m.borrow_mut().insert(key.to_string(), (0x1000_0099, shape)); + }); + MODULE_SEEDS.with(|s| s.borrow_mut().clear()); + assert_eq!(static_shape_id_for_keys_global(key), None); + assert_eq!(requested_shape_id_for_keys_global(key), None); + assert_eq!(static_region_slots(key, &["method".into()], 0), None); + assert!(take_module_static_seeds().is_empty()); + MODULE_STATIC_IDS.with(|m| m.borrow_mut().clear()); +} + /// The seed sidecar carries the birth rep: a warm link replays exactly the /// facts a cold one seeded. A line without the rep (another format) is /// malformed, never an all-`Any` seed of the same keys. @@ -368,3 +424,139 @@ fn class_birth_names_anon_shapes_as_literals_and_skips_class_zero() { assert_eq!(o.class_id, 0); assert!(o.shape.is_none()); } + +#[test] +fn compatible_final_guards_preserve_allocation_identity_and_refuse_special_writes() { + let ordinary = BirthShape { + rep: 1 << 2, + ..class("m\0x\0", 2, 71) + }; + let completed = BirthShape { + rep: 3 | (1 << 2), + constfn: vec![ConstFnBirth { + slot: 0, + symbol: "guard_body$info".into(), + }], + ..ordinary.clone() + }; + let wrong_number = BirthShape { + rep: 3, + ..completed.clone() + }; + let key = "perry_class_keys_guard__C".to_string(); + MODULE_STATIC_IDS.with(|m| { + *m.borrow_mut() = [(key.clone(), (SHAPE_ID_BASE + 4, ordinary.clone()))] + .into_iter() + .collect(); + }); + MODULE_FINAL_IDS.with(|m| { + *m.borrow_mut() = [ + (completed, SHAPE_ID_BASE + 5), + (wrong_number, SHAPE_ID_BASE + 6), + ] + .into_iter() + .collect(); + }); + let (region_id, slots, r_mask) = static_region_slots(&key, &["x".into(), "m".into()], 0) + .expect("ordinary birth supplies exact numeric slots"); + assert_eq!(region_id, SHAPE_ID_BASE + 4); + assert_eq!(slots, vec![1, 0]); + assert_eq!(r_mask, 1, "the method lane never supplies a Number fact"); + assert!(static_region_slots(&key, &["x".into()], 1).is_none()); + assert_eq!( + requested_shape_id_for_keys_global(&key), + Some(SHAPE_ID_BASE + 4), + "allocation supplier cannot request final id" + ); + assert_eq!( + compatible_final_shape_ids(&(SHAPE_ID_BASE + 4).to_string(), &[]), + vec![SHAPE_ID_BASE + 5] + ); + assert_eq!( + compatible_final_shape_ids(&(SHAPE_ID_BASE + 4).to_string(), &[1]), + vec![SHAPE_ID_BASE + 5], + "numeric stores preserve completed facts" + ); + assert!( + compatible_final_shape_ids(&(SHAPE_ID_BASE + 4).to_string(), &[0]).is_empty(), + "CF stores require checked deprecation before writing" + ); + assert!(slot_may_be_constfn(&key, 0)); + assert!(!slot_may_be_constfn(&key, 1)); + MODULE_STATIC_IDS.with(|m| m.borrow_mut().clear()); + MODULE_FINAL_IDS.with(|m| m.borrow_mut().clear()); + MODULE_SEEDS.with(|m| m.borrow_mut().clear()); +} + +#[test] +fn region_static_r_is_the_exact_birth_shapes_f64_key_mask() { + let shape = BirthShape { + rep: 0b01 | (0b01 << 4), + ..class("ra\0rb\0rc\0", 3, 0x517) + }; + let global = "p7_region_keys".to_string(); + MODULE_STATIC_IDS.with(|m| { + m.borrow_mut() + .insert(global.clone(), (SHAPE_ID_BASE + 917, shape)); + }); + let keys = vec!["rc".to_string(), "rb".to_string(), "ra".to_string()]; + let (_, slots, r) = static_region_slots(&global, &keys, 0).expect("birth keys are inline"); + assert_eq!(slots, vec![2, 1, 0]); + assert_eq!(r, 0b101, "R follows key order, not birth slot order"); + assert!( + static_region_slots(&global, &keys, 0b001).is_none(), + "a boxed store to an F64 birth lane is refused" + ); + MODULE_STATIC_IDS.with(|m| { + m.borrow_mut().remove(&global); + }); + take_module_static_seeds(); +} + +#[test] +fn literal_key_cache_mints_require_a_seed_even_without_a_guard() { + let literal = BirthShape { + proto: BirthProto::Literal, + ..class("x\0m\0", 2, 7) + }; + let declared = class("x\0m\0", 2, 8); + let assigned = assign_static_shape_ids([&literal, &declared]); + let entries = vec![ + ( + "perry_class_keys_probe____AnonShape_a".into(), + "x\0m\0".into(), + 2, + vec![], + vec![], + ), + ( + "perry_class_keys_probe__Declared".into(), + "x\0m\0".into(), + 2, + vec![], + vec![], + ), + ]; + let classes = HashMap::from([("__AnonShape_a".into(), 7), ("Declared".into(), 8)]); + set_module_static_ids( + "probe", + &entries, + &HashMap::new(), + &HashMap::new(), + &classes, + &assigned.clone().into_iter().collect::>(), + &ProgramClassShapeIds::default(), + ); + assert_eq!( + requested_shape_id_for_keys_global(&entries[0].0), + Some(assigned[&literal]) + ); + assert_eq!( + requested_shape_id_for_keys_global(&entries[1].0), + Some(assigned[&declared]) + ); + assert_eq!( + take_module_static_seeds(), + vec![(assigned[&literal], literal)] + ); +} diff --git a/crates/perry-codegen/src/codegen/string_pool.rs b/crates/perry-codegen/src/codegen/string_pool.rs index e627e8a9c5..ab51e31a22 100644 --- a/crates/perry-codegen/src/codegen/string_pool.rs +++ b/crates/perry-codegen/src/codegen/string_pool.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use crate::block::LlBlock; use crate::module::LlModule; use crate::strings::StringPool; -use crate::types::{DOUBLE, I32, I64, PTR, VOID}; +use crate::types::{DOUBLE, I32, I64, I8, PTR, VOID}; use super::ctor_arity::constructor_layout_params; use super::helpers::{sanitize_member, scoped_static_method_name}; @@ -25,13 +25,21 @@ use super::spec_function_length; /// runtime registry; no SSA value flows between ops — so splitting at op /// boundaries is safe and order-preserving (chunks run in sequence, ops in order /// within a chunk). +#[derive(Default)] +struct InitChunks { + ops: usize, + current: Option, + names: Vec, +} + struct InitChunker<'a> { llmod: &'a mut LlModule, base_name: String, ops_per_chunk: usize, - ops_in_current: usize, - cur_idx: usize, - chunk_names: Vec, + // Literal infrastructure may run before cyclic dependencies. Declared + // class metadata retains its existing module-body initialization boundary. + literals: bool, + phases: [InitChunks; 2], } impl<'a> InitChunker<'a> { @@ -40,65 +48,64 @@ impl<'a> InitChunker<'a> { llmod, base_name, ops_per_chunk: ops_per_chunk.max(1), - // Force a fresh chunk on the first op. - ops_in_current: usize::MAX, - cur_idx: 0, - chunk_names: Vec::new(), + literals: true, + phases: Default::default(), } } - /// Start a fresh chunk function if the current one is full. Call ONCE at the - /// top of each loop iteration (one independent init op), before - /// [`current_block`]. Closes the previous chunk with `ret void`. - /// The module, for a definition an init op registers (the chunk being - /// filled is addressed by index, so appending functions is fine). fn module(&mut self) -> &mut LlModule { self.llmod } fn roll_if_full(&mut self) { - if self.ops_in_current >= self.ops_per_chunk { - if !self.chunk_names.is_empty() { + let phase = usize::from(!self.literals); + let state = &mut self.phases[phase]; + if state.current.is_none() || state.ops >= self.ops_per_chunk { + if let Some(current) = state.current { self.llmod - .function_mut(self.cur_idx) + .function_mut(current) .unwrap() .block_mut(0) .unwrap() .ret_void(); } - let name = format!("{}_chunk{}", self.base_name, self.chunk_names.len()); + let name = format!( + "{}_{}_chunk{}", + self.base_name, + if self.literals { "literal" } else { "class" }, + state.names.len() + ); self.llmod .define_function(&name, VOID, vec![]) .create_block("entry"); - self.cur_idx = self.llmod.function_count() - 1; - self.chunk_names.push(name); - self.ops_in_current = 0; + state.current = Some(self.llmod.function_count() - 1); + state.names.push(name); + state.ops = 0; } } - /// The current chunk's entry block, for emitting one op's instructions. - /// Counts as one op (a logical init step may emit several instructions onto - /// it). Always preceded by [`roll_if_full`]. fn current_block(&mut self) -> &mut LlBlock { - self.ops_in_current += 1; + let state = &mut self.phases[usize::from(!self.literals)]; + state.ops += 1; self.llmod - .function_mut(self.cur_idx) + .function_mut(state.current.unwrap()) .unwrap() .block_mut(0) .unwrap() } - /// Close the final chunk and return all chunk function names, in order. - fn finish(self) -> Vec { - if !self.chunk_names.is_empty() { - self.llmod - .function_mut(self.cur_idx) - .unwrap() - .block_mut(0) - .unwrap() - .ret_void(); - } - self.chunk_names + fn finish(self) -> [Vec; 2] { + self.phases.map(|state| { + if let Some(current) = state.current { + self.llmod + .function_mut(current) + .unwrap() + .block_mut(0) + .unwrap() + .ret_void(); + } + state.names + }) } } @@ -120,6 +127,7 @@ pub(super) fn emit_string_pool( llmod: &mut LlModule, strings: &StringPool, module_prefix: &str, + agent_strings_tls: bool, // #9188 follow-up: which registration spelling the name/source loops below // may use. `_static` hands the registry the `@.str.N` constant itself // instead of a slice to copy, which is sound only while this image stays @@ -244,9 +252,13 @@ pub(super) fn emit_string_pool( entry.bytes_global )); } - // #10399: the string pool is populated by each module's init, which - // runs once per thread when the program has a Worker. - llmod.add_internal_module_state_global(&entry.handle_global, DOUBLE, "0.0"); + // Worker module init and perry/thread's explicit string bootstrap each + // populate this slot in the allocating agent's own arena. + if agent_strings_tls { + llmod.add_internal_thread_local_global(&entry.handle_global, DOUBLE, "0.0"); + } else { + llmod.add_internal_global(&entry.handle_global, DOUBLE, "0.0"); + } } // Per-class packed-keys constants (rodata) — referenced by the @@ -447,7 +459,7 @@ pub(super) fn emit_string_pool( .unwrap_or(4000); let mut chunker = InitChunker::new( llmod, - format!("__perry_init_strings_{}", module_prefix), + format!("__perry_agent_strings_{}", module_prefix), ops_per_chunk, ); @@ -496,6 +508,39 @@ pub(super) fn emit_string_pool( blk.call_void("js_gc_register_global_root", &[(I64, &addr_i64)]); } + let [string_chunks, no_class_chunks] = chunker.finish(); + debug_assert!(no_class_chunks.is_empty()); + let agent_strings_name = format!("__perry_prepare_agent_strings_{}", module_prefix); + let ready = format!("__perry_agent_strings_ready_{}", module_prefix); + if agent_strings_tls { + llmod.add_internal_thread_local_global(&ready, I8, "0"); + } else { + llmod.add_internal_global(&ready, I8, "0"); + } + let prepare_strings = llmod.define_function(&agent_strings_name, VOID, vec![]); + prepare_strings.create_block("entry"); + prepare_strings.create_block("prepare"); + prepare_strings.create_block("done"); + let prepare_label = prepare_strings.block_mut(1).unwrap().label.clone(); + let done_label = prepare_strings.block_mut(2).unwrap().label.clone(); + let blk = prepare_strings.block_mut(0).unwrap(); + let prepared = blk.load(I8, &format!("@{}", ready)); + let prepared = blk.icmp_ne(I8, &prepared, "0"); + blk.cond_br(&prepared, &done_label, &prepare_label); + let blk = prepare_strings.block_mut(1).unwrap(); + for name in &string_chunks { + blk.call_void(name, &[]); + } + blk.store(I8, "1", &format!("@{}", ready)); + blk.br(&done_label); + prepare_strings.block_mut(2).unwrap().ret_void(); + + let mut chunker = InitChunker::new( + llmod, + format!("__perry_init_strings_{}", module_prefix), + ops_per_chunk, + ); + // An image that can be UNLOADED cannot lend its rodata to a registry that // never drops entries. Perry compiles TypeScript to a dylib plugin as well // as an executable, and `perry_plugin_unload` ends in `dlclose` — after @@ -586,6 +631,7 @@ pub(super) fn emit_string_pool( // the user wrote. This is a distinct edge from the parent one on purpose: // `CLASS_REGISTRY`'s chain also resolves `super()`, static-method lookup // and vtable dispatch, so it must keep pointing at the real base. + chunker.literals = false; let mut origin_pairs: Vec<(u32, u32)> = Vec::new(); for (name, &cid) in class_ids.iter() { let Some(class) = classes.get(name) else { @@ -626,6 +672,7 @@ pub(super) fn emit_string_pool( } anon_shape_ids.sort_unstable(); anon_shape_ids.dedup(); + chunker.literals = true; for cid in anon_shape_ids { chunker.roll_if_full(); let blk = chunker.current_block(); @@ -641,16 +688,14 @@ pub(super) fn emit_string_pool( // module init; every `new ClassName()` call from then on does a // single global load + inline allocator call (no SHAPE_CACHE // lookup, no js_build_class_keys_array overhead). + let literal_classes: std::collections::HashSet<_> = classes + .values() + .filter(|class| class.name.starts_with("__AnonShape_")) + .filter_map(|class| class_ids.get(&class.name).copied()) + .collect(); for (idx, (global_name, packed, field_count, _raw_mask_words, _pointer_mask_words)) in class_keys_init_data.iter().enumerate() { - chunker.roll_if_full(); - let blk = chunker.current_block(); - // The birth's class id, typed-ness and live bound come from the ONE - // derivation the driver's pre-pass also uses to name this birth's - // content (`static_shape_ids::class_birth`); `requested` is that - // content's static id — the definer's for a structural stub of the - // definer's facts — (0 = none). let birth = super::static_shape_ids::class_birth( module_prefix, &class_keys_init_data[idx], @@ -658,6 +703,16 @@ pub(super) fn emit_string_pool( class_birth_reps, class_ids, ); + // Only synthetic ordinary-object layouts are safe before dependency + // bodies. User-class keys, prototypes and methods stay in the late phase. + chunker.literals = literal_classes.contains(&birth.class_id); + chunker.roll_if_full(); + let blk = chunker.current_block(); + // The birth's class id, typed-ness and live bound come from the ONE + // derivation the driver's pre-pass also uses to name this birth's + // content (`static_shape_ids::class_birth`); `requested` is that + // content's static id — the definer's for a structural stub of the + // definer's facts — (0 = none). let class_id = birth.class_id; let requested = super::static_shape_ids::requested_shape_id_for_keys_global(global_name) .unwrap_or(0) @@ -809,6 +864,7 @@ pub(super) fn emit_string_pool( // where `Square extends Rectangle extends Shape`) terminate // prematurely. We emit one call per inheriting class, sorted by // class id for deterministic ordering. + chunker.literals = false; let mut parent_pairs: Vec<(u32, u32)> = Vec::new(); for (name, &cid) in class_ids.iter() { if let Some(class) = classes.get(name) { @@ -1574,7 +1630,74 @@ pub(super) fn emit_string_pool( ); } - let chunk_names = chunker.finish(); + // Final class records follow all class/prototype registrations. They + // coexist with the ordinary allocation ids and never feed header images. + if super::static_constfn::has_final_shapes() { + let defined_classes: HashMap<_, _> = module_classes + .iter() + .filter_map(|class| class_ids.get(&class.name).map(|cid| (*cid, class))) + .collect(); + for entry in class_keys_init_data { + let birth = super::static_shape_ids::class_birth( + module_prefix, + entry, + class_header_image_inits, + class_birth_reps, + class_ids, + ); + let Some(ordinary) = birth.shape else { + continue; + }; + let Some(class) = defined_classes.get(&birth.class_id).copied() else { + continue; + }; + let Ok(shape) = super::static_constfn_class::class_final( + module_prefix, + class, + classes, + ordinary.rep, + birth.class_id, + ) else { + continue; + }; + if shape.keys != ordinary.keys + || shape.live != ordinary.live + || shape.proto != ordinary.proto + { + continue; + } + let Some(id) = super::static_shape_ids::static_final_shape_id(&shape) else { + continue; + }; + chunker.roll_if_full(); + let blk = chunker.current_block(); + // Registration calls above can collect; load the canonical keys + // afresh from their registered root immediately before the mint. + let keys = blk.load(I64, &format!("@{}", entry.0)); + blk.call( + I32, + "js_object_final_shape_id_for_class_keys_static_constfn", + &[ + (I64, &keys), + (I32, &shape.key_count.to_string()), + (I32, &shape.live.to_string()), + (I32, &birth.class_id.to_string()), + (I32, &id.to_string()), + (I64, &shape.rep.to_string()), + ( + PTR, + &format!( + "@{}", + super::static_constfn::entries_symbol(module_prefix, id) + ), + ), + (I32, &shape.constfn.len().to_string()), + ], + ); + } + } + + let [literal_chunks, class_chunks] = chunker.finish(); record_fn_info_facts( llmod, module_prefix, @@ -1598,11 +1721,42 @@ pub(super) fn emit_string_pool( user_fn_wrapper_strict, }, ); + // A cyclic importer can call a hoisted factory before this module body. + // Prepare literal strings/function info/ordinary-object layouts first, + // once per arena. Workers can enter __init_body directly, so the body + // also reaches this guarded preparation without allocating a second pool. + let prepare_name = format!("__perry_prepare_literals_{}", module_prefix); + let prepared = format!("__perry_literals_ready_{}", module_prefix); + if super::program_has_worker() { + llmod.add_internal_thread_local_global(&prepared, I8, "0"); + } else { + llmod.add_internal_global(&prepared, I8, "0"); + } + let prepare_fn = llmod.define_function(&prepare_name, VOID, vec![]); + prepare_fn.create_block("entry"); + prepare_fn.create_block("prepare"); + prepare_fn.create_block("done"); + let prepare_label = prepare_fn.block_mut(1).unwrap().label.clone(); + let done_label = prepare_fn.block_mut(2).unwrap().label.clone(); + let blk = prepare_fn.block_mut(0).unwrap(); + let ready = blk.load(I8, &format!("@{}", prepared)); + let ready = blk.icmp_ne(I8, &ready, "0"); + blk.cond_br(&ready, &done_label, &prepare_label); + let blk = prepare_fn.block_mut(1).unwrap(); + blk.call_void(&agent_strings_name, &[]); + for cname in &literal_chunks { + blk.call_void(cname, &[]); + } + blk.store(I8, "1", &format!("@{}", prepared)); + blk.br(&done_label); + prepare_fn.block_mut(2).unwrap().ret_void(); + let init_name = format!("__perry_init_strings_{}", module_prefix); let init_fn = llmod.define_function(&init_name, VOID, vec![]); - let _ = init_fn.create_block("entry"); + init_fn.create_block("entry"); let blk = init_fn.block_mut(0).unwrap(); - for cname in &chunk_names { + blk.call_void(&prepare_name, &[]); + for cname in &class_chunks { blk.call_void(cname, &[]); } blk.ret_void(); diff --git a/crates/perry-codegen/src/collectors/mod.rs b/crates/perry-codegen/src/collectors/mod.rs index 1a3ea444fa..0d2ce741dc 100644 --- a/crates/perry-codegen/src/collectors/mod.rs +++ b/crates/perry-codegen/src/collectors/mod.rs @@ -117,6 +117,10 @@ pub(crate) use proven_this::{ tower_route_profitable as pshape_tower_route_profitable, }; pub(crate) use ptr_numarray::{NumArrayDensity, NumArrayLocal}; +pub(crate) use ptr_shape::{ + collect_numeric_by_construction_locals_in_region, region_number_flow_reads, + region_store_value_is_number, RegionNumberAssumptions, +}; pub(crate) use ptr_shape::{ptr_shape_locals_enabled, PtrShapeLocal}; pub(crate) use ptr_shape_callbacks::collect_array_callback_shapes; pub(crate) use ptr_shape_returns::collect_exported_return_shapes; diff --git a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs index 8c3a14671d..370b7885c3 100644 --- a/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs +++ b/crates/perry-codegen/src/collectors/proven_this_routing_tests.rs @@ -43,6 +43,7 @@ fn ir_opts(is_entry: bool) -> CompileOptions { target: None, is_entry_module: is_entry, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index dda88b6e52..be9256f82b 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -1968,6 +1968,10 @@ mod numeric; use numeric::{ collect_numeric_by_construction_locals, prove_group_numeric_fields, prove_numeric_fields, }; +pub(crate) use numeric::{ + collect_numeric_by_construction_locals_in_region, region_number_flow_reads, + region_store_value_is_number, RegionNumberAssumptions, +}; // #8105: the same locals fixpoint, consumed outside the `Ptr` pass by // `collectors/number_by_construction.rs`. pub(in crate::collectors) use numeric::collect_numeric_by_construction_locals as collect_numeric_by_construction_locals_for_type_analysis; diff --git a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs index 1e3ef0550f..7567e98a7e 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape_numeric.rs @@ -413,6 +413,13 @@ pub(super) fn prove_group_numeric_fields<'a>( // ── #7770: numeric-by-construction locals ────────────────────────────────── +/// Extra leaves and entry candidates for one guarded region's F clone. +pub(crate) struct RegionNumberAssumptions<'a> { + pub(crate) entry_candidates: &'a HashSet, + pub(crate) static_numbers: &'a HashSet, + pub(crate) f64_reads: &'a HashSet, +} + /// Locals whose every write is number-producing by construction — above all /// the loop counter (`let i = 0` + `i++`) that feeds a provenance /// `new C(i, i + 1)`. @@ -448,6 +455,38 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( // completely `o`'s shape was proven. Empty for every pre-existing caller. shape_members: &HashSet, shape_numeric_fields: &HashSet, +) -> HashSet { + collect_numeric_by_construction_locals_in_region( + stmts, + boxed_vars, + module_globals, + not_bigint_locals, + const_local_inits, + numeric_ta_views, + shape_members, + shape_numeric_fields, + None, + ) +} + +pub(crate) fn collect_numeric_by_construction_locals_in_region<'a>( + stmts: &'a [Stmt], + boxed_vars: &HashSet, + module_globals: &HashMap, + not_bigint_locals: &HashSet, + const_local_inits: &HashMap>, + // #8619: view bindings proven to hold a numeric-kind typed array (spec-ABI + // `TaPtr` params). Empty for the `Ptr` type-analysis caller. + numeric_ta_views: &HashSet, + // #10777: shape-proven receivers visible to THIS walk, and the property + // names numeric on all of them. Both were hardcoded empty here, so + // `expr_numeric_by_construction`'s `PropertyGet` arm — gated on + // `members.contains(id)` — could never fire for a function-scope walk. An + // accumulator written `h = h + o.a` was therefore never admitted, however + // completely `o`'s shape was proven. Empty for every pre-existing caller. + shape_members: &HashSet, + shape_numeric_fields: &HashSet, + region: Option<&RegionNumberAssumptions<'_>>, ) -> HashSet { // ONE write walker for both fixpoints (`collect_not_bigint_locals` and // this one) — see its doc for why sharing is load-bearing. `None` = a @@ -464,6 +503,12 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( // constructors without trusting their erased annotation. let mut stable_local_inits = const_local_inits.clone(); for (&id, local_writes) in &writes { + // An entry-tested loop-carried local can have just one F-body write. + // That write is not a stable initializer and must be judged through + // the running fixed-point assumption (h = h + x). + if region.is_some_and(|r| r.entry_candidates.contains(&id)) { + continue; + } if let [Some(init)] = local_writes.as_slice() { stable_local_inits.entry(id).or_insert(Some(*init)); } @@ -472,17 +517,28 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( let empty_fields: HashSet = shape_numeric_fields.clone(); let mut numeric: HashSet = let_bound .into_iter() + .chain( + region + .into_iter() + .flat_map(|r| r.entry_candidates.iter().copied()), + ) .filter(|id| !boxed_vars.contains(id) && !module_globals.contains_key(id)) .collect(); + if let Some(r) = region { + numeric.extend(r.static_numbers.iter().copied()); + } loop { let mut drop: Vec = Vec::new(); for &id in &numeric { + if region.is_some_and(|r| r.static_numbers.contains(&id)) { + continue; + } let ok = writes .get(&id) .map(|ws| { ws.iter().all(|w| match w { None => false, - Some(e) => expr_numeric_by_construction( + Some(e) => expr_numeric_by_construction_with_region( e, &ParamEnv::None, &empty_members, @@ -492,12 +548,13 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( &numeric, numeric_ta_views, 0, + region.map(|r| r.f64_reads), ), }) }) - // A `let_bound` id always has its `Let` recorded; treat a - // missing entry as unproven rather than as vacuously true. - .unwrap_or(false); + // Only a strictly tested entry candidate may have no local + // write inside F; its incoming value is the guarded leaf. + .unwrap_or_else(|| region.is_some_and(|r| r.entry_candidates.contains(&id))); if !ok { drop.push(id); } @@ -512,6 +569,70 @@ pub(in crate::collectors) fn collect_numeric_by_construction_locals<'a>( numeric } +/// Trace Number-consuming local uses back through the shared exhaustive +/// write inventory to the property reads feeding them. The caller intersects +/// these expression identities with the planner's fresh bare reads, so a +/// read after an E2 call never becomes an R leaf. +pub(crate) fn region_number_flow_reads( + stmts: &[Stmt], + roots: &HashSet, +) -> (HashSet, HashSet, HashSet) { + fn deps(e: &Expr, reads: &mut HashSet, locals: &mut Vec) { + match e { + Expr::PropertyGet { .. } => { + reads.insert(e as *const Expr as usize); + return; + } + Expr::LocalGet(id) => { + locals.push(*id); + return; + } + _ => {} + } + perry_hir::walker::walk_expr_children(e, &mut |child| deps(child, reads, locals)); + } + + let mut writes = HashMap::new(); + let mut bound = HashSet::new(); + super::super::not_bigint_locals::collect_writes(stmts, &mut writes, &mut bound); + let mut reads = HashSet::new(); + let mut seen = HashSet::new(); + let mut pending: Vec = roots.iter().copied().collect(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + if let Some(ws) = writes.get(&id) { + for value in ws.iter().flatten() { + deps(value, &mut reads, &mut pending); + } + } + } + (reads, seen, bound) +} + +/// Reuse the Number-by-construction expression rule when the region planner +/// decides whether a bare store is compatible with an R-proven F64 lane. +pub(crate) fn region_store_value_is_number( + value: &Expr, + f64_reads: &HashSet, + numeric_locals: &HashSet, + not_bigint_locals: &HashSet, +) -> bool { + expr_numeric_by_construction_with_region( + value, + &ParamEnv::None, + &HashSet::new(), + &HashSet::new(), + not_bigint_locals, + &HashMap::new(), + numeric_locals, + &HashSet::new(), + 0, + Some(f64_reads), + ) +} + // ── The expression-level proof ───────────────────────────────────────────── /// Number-by-construction: the expression's runtime value is a JS Number for @@ -535,13 +656,48 @@ pub(super) fn expr_numeric_by_construction( // pass). numeric_ta_views: &HashSet, depth: usize, +) -> bool { + expr_numeric_by_construction_with_region( + e, + param_env, + members, + numeric_fields, + not_bigint_locals, + const_local_inits, + numeric_locals, + numeric_ta_views, + depth, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn expr_numeric_by_construction_with_region( + e: &Expr, + param_env: &ParamEnv<'_>, + members: &HashSet, + numeric_fields: &HashSet, + not_bigint_locals: &HashSet, + const_local_inits: &HashMap>, + numeric_locals: &HashSet, + // #8619: view bindings PROVEN to permanently hold a numeric-kind typed + // array — a spec-ABI `TaPtr` parameter (the entry contract binds the raw + // header of a proven numeric non-view typed array). A read + // `view_id[numeric_index]` is then a Number (in-bounds) or `undefined` + // (OOB) by construction, never a pointer/string, which the Add rule below + // launders into a genuine Number. Empty on every path that is not a + // specialized-entry local proof (the class-field provers, the `Ptr` + // pass). + numeric_ta_views: &HashSet, + depth: usize, + region_f64_reads: Option<&HashSet>, ) -> bool { if depth > 16 { return false; } use perry_hir::BinaryOp; let rec = |x: &Expr| { - expr_numeric_by_construction( + expr_numeric_by_construction_with_region( x, param_env, members, @@ -551,6 +707,7 @@ pub(super) fn expr_numeric_by_construction( numeric_locals, numeric_ta_views, depth + 1, + region_f64_reads, ) }; // A numeric index into one of these compiler-owned constructors can only @@ -628,6 +785,12 @@ pub(super) fn expr_numeric_by_construction( numeric_storage && rec(index) }; match e { + Expr::PropertyGet { .. } + if region_f64_reads + .is_some_and(|reads| reads.contains(&(e as *const Expr as usize))) => + { + true + } Expr::Number(_) | Expr::Integer(_) | Expr::PodLayoutSizeOf { .. } @@ -738,7 +901,7 @@ pub(super) fn expr_numeric_by_construction( return !sites.is_empty() && sites.iter().all(|args| { args.get(pos).map(|a| { - expr_numeric_by_construction( + expr_numeric_by_construction_with_region( a, &ParamEnv::None, members, @@ -748,6 +911,7 @@ pub(super) fn expr_numeric_by_construction( numeric_locals, numeric_ta_views, depth + 1, + region_f64_reads, ) }) == Some(true) }); @@ -762,7 +926,7 @@ pub(super) fn expr_numeric_by_construction( // A single-Let const temp: chase its init (function // scope, so no parameter mapping applies to it). if let Some(Some(init)) = const_local_inits.get(id) { - return expr_numeric_by_construction( + return expr_numeric_by_construction_with_region( init, &ParamEnv::None, members, @@ -772,6 +936,7 @@ pub(super) fn expr_numeric_by_construction( numeric_locals, numeric_ta_views, depth + 1, + region_f64_reads, ); } // #7770: a local every one of whose writes is @@ -819,3 +984,108 @@ pub(super) fn expr_provably_not_bigint(e: &Expr, not_bigint_locals: &HashSet Expr { + Expr::PropertyGet { + object: Box::new(Expr::LocalGet(OBJECT)), + property: "x".to_string(), + byte_offset: 0, + } + } + + fn let_read(id: u32) -> Stmt { + Stmt::Let { + id, + name: format!("n{id}"), + ty: perry_hir::types::Type::Any, + mutable: false, + init: Some(read()), + } + } + + fn add_to_acc(id: u32) -> Stmt { + Stmt::Expr(Expr::LocalSet( + ACC, + Box::new(Expr::Binary { + op: perry_hir::BinaryOp::Add, + left: Box::new(Expr::LocalGet(ACC)), + right: Box::new(Expr::LocalGet(id)), + }), + )) + } + + fn number_set(stmts: &[Stmt], fresh_read: usize) -> HashSet { + let boxed = HashSet::new(); + let globals = HashMap::new(); + let empty_ids = HashSet::new(); + let empty_fields = HashSet::new(); + let inits = HashMap::new(); + let entry = HashSet::from([ACC]); + let reads = HashSet::from([fresh_read]); + let region = RegionNumberAssumptions { + entry_candidates: &entry, + static_numbers: &empty_ids, + f64_reads: &reads, + }; + collect_numeric_by_construction_locals_in_region( + stmts, + &boxed, + &globals, + &empty_ids, + &inits, + &empty_ids, + &empty_ids, + &empty_fields, + Some(®ion), + ) + } + + #[test] + fn an_e2_stale_second_read_drops_the_loop_carried_number_fact() { + let mut stmts = vec![let_read(FRESH), add_to_acc(FRESH)]; + let fresh_ptr = match &stmts[0] { + Stmt::Let { init: Some(e), .. } => e as *const Expr as usize, + _ => unreachable!(), + }; + let first = number_set(&stmts, fresh_ptr); + assert!(first.contains(&FRESH) && first.contains(&ACC)); + + // The intervening call makes the second slot read stale in the + // region planner. Only the first read's exact Expr identity is an + // F64 leaf; the second write must withdraw ACC from N_F. + stmts.push(Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(99)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + })); + stmts.push(let_read(STALE)); + stmts.push(add_to_acc(STALE)); + let fresh_ptr = match &stmts[0] { + Stmt::Let { init: Some(e), .. } => e as *const Expr as usize, + _ => unreachable!(), + }; + let stale_ptr = match &stmts[3] { + Stmt::Let { init: Some(e), .. } => e as *const Expr as usize, + _ => unreachable!(), + }; + let roots = HashSet::from([ACC]); + let (flows, locals, bound) = region_number_flow_reads(&stmts, &roots); + assert!(flows.contains(&fresh_ptr) && flows.contains(&stale_ptr)); + assert!(locals.contains(&ACC) && locals.contains(&FRESH) && locals.contains(&STALE)); + assert!(bound.contains(&FRESH) && bound.contains(&STALE) && !bound.contains(&ACC)); + let after = number_set(&stmts, fresh_ptr); + assert!(after.contains(&FRESH)); + assert!(!after.contains(&STALE)); + assert!(!after.contains(&ACC), "the stale write must drop ACC"); + } +} diff --git a/crates/perry-codegen/src/collectors/receiver_regions.rs b/crates/perry-codegen/src/collectors/receiver_regions.rs index bc119d323e..84e5adb018 100644 --- a/crates/perry-codegen/src/collectors/receiver_regions.rs +++ b/crates/perry-codegen/src/collectors/receiver_regions.rs @@ -3,14 +3,15 @@ //! //! # Why this exists //! -//! Phase 1 found sixteen separate receiver-keyed fact mechanisms on `FnCtx`. +//! Phase 1 found sixteen separate receiver-keyed fact mechanisms on `FnCtx`; +//! P8 removed the class-field-loop twin, leaving fifteen in the active inventory. //! The original issue singled out six (`cached_lengths`, //! `bounded_index_pairs`, `packed_f64_loop_facts`, //! `masked_window_array_facts`, `buffer_view_slots`, and the //! `packed_receiver_*` trio); Phase 4 has now moved all six into this table. //! The expanded audit also records `int_range_facts`, //! `bounded_buffer_index_pairs`, `guarded_buffer_index_pairs`, -//! `element_shape_loop_facts`, `class_field_loop_facts`, +//! `element_shape_loop_facts`, //! `versioned_indexed_loop_facts`, `stable_packed_loop_facts`, //! `string_window_array_facts`, `buffer_data_slots`, and `class_keys_slots`. //! Historically, each answered the same two questions diff --git a/crates/perry-codegen/src/collectors/receiver_regions_tests.rs b/crates/perry-codegen/src/collectors/receiver_regions_tests.rs index 5c449f99b6..1afd5941b6 100644 --- a/crates/perry-codegen/src/collectors/receiver_regions_tests.rs +++ b/crates/perry-codegen/src/collectors/receiver_regions_tests.rs @@ -989,20 +989,13 @@ fn inventory() -> Vec { excludes_try: true, unwind_safe_by: "body must be a single LocalSet, so no handler can exist in extent", }, - TableRow { - table: "class_field_loop_facts", - claim: ReceiverClaim::Address, - boundary: FactBoundary::ScopeId, - excludes_try: true, - unwind_safe_by: "single-statement body plus a post-hoc contains_gc_unsafe_call scan \ - that discards the clone if any call was emitted", - }, TableRow { table: "element_shape_loop_facts", claim: ReceiverClaim::Address, boundary: FactBoundary::ScopeId, excludes_try: true, - unwind_safe_by: "same double lock as class_field_loop_facts", + unwind_safe_by: "matcher rejects handlers; emitted clone is entered only after \ + contains_gc_unsafe_call proves every block call-free", }, TableRow { table: "receiver_descriptors", @@ -1211,9 +1204,13 @@ fn the_inventory_covers_every_claim_kind_and_every_boundary_mechanism() { } assert_eq!( rows.len(), - 16, + 15, "inventory size changed — see FnCtx declarations" ); + assert!( + !rows.iter().any(|r| r.table == "class_field_loop_facts"), + "the removed class-loop fact table must not remain an active mechanism" + ); } #[test] diff --git a/crates/perry-codegen/src/concat_site_cache.rs b/crates/perry-codegen/src/concat_site_cache.rs index 7785d989c1..ed930946e5 100644 --- a/crates/perry-codegen/src/concat_site_cache.rs +++ b/crates/perry-codegen/src/concat_site_cache.rs @@ -48,7 +48,9 @@ //! an unproven operand keeps the plain fused call and the process-wide memo. //! //! The table is emitted through `typed_parse_rodata`, the per-function -//! deferred raw-global sink every lowering context already drains. +//! deferred raw-global sink every lowering context already drains. Worker +//! programs emit the table in TLS: its heap strings and registered root-cell +//! addresses belong to the current agent, including the empty-slot state. //! `PERRY_CONCAT_SITE_CACHE=0` removes the lane at build time. use anyhow::Result; @@ -175,8 +177,17 @@ pub(crate) fn try_lower_concat_site_cached( let site_id = ctx.ic_site_counter; ctx.ic_site_counter += 1; let table_name = concat_site_global_name(ctx, site_id); + // Both cached strings and their root registration belong to one agent. + // A process-global hit can otherwise reuse a retired worker's heap, and + // simultaneous workers can race to fill/register the same cell. + let tls = if crate::codegen::program_has_worker() || crate::codegen::program_has_thread_agents() + { + "thread_local " + } else { + "" + }; ctx.typed_parse_rodata.push(format!( - "@{table_name} = private global {CONCAT_SITE_TABLE_TY} zeroinitializer" + "@{table_name} = private {tls}global {CONCAT_SITE_TABLE_TY} zeroinitializer" )); let table_ref = format!("@{table_name}"); diff --git a/crates/perry-codegen/src/expr/array_push_guard_tests.rs b/crates/perry-codegen/src/expr/array_push_guard_tests.rs index fe68b0681f..463712c3f4 100644 --- a/crates/perry-codegen/src/expr/array_push_guard_tests.rs +++ b/crates/perry-codegen/src/expr/array_push_guard_tests.rs @@ -47,6 +47,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs index 7f60a1a4bc..311b67e779 100644 --- a/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs +++ b/crates/perry-codegen/src/expr/barrier_stem_census_tests.rs @@ -385,6 +385,34 @@ fn verify_gate_instance( /// The uniform floor, over EVERY instance of the stem's gates in `ir`. /// `Err` is the red verdict; every message names what broke. pub(super) fn verify_stem_ir(ir: &str, stem: &str, kind: StemKind) -> Result<(), String> { + // Block labels and SSA registers are unique only inside their function. + // A specialized copy may reuse every label and register of its original. + let functions: Vec<_> = function_bodies(ir) + .filter(|f| { + ["barrier.", "barrier.maybe.", "gc_bookkeeping."] + .iter() + .any(|suffix| !all_numbered_labels(f, &format!("{stem}.{suffix}")).is_empty()) + }) + .collect(); + if functions.is_empty() { + return Err(format!("no `{stem}.barrier.` block in the emitted IR")); + } + for function in functions { + verify_stem_function(function, stem, kind) + .map_err(|e| format!("{}: {e}", function.lines().next().unwrap_or("function")))?; + } + Ok(()) +} + +/// Function bodies include the header and stop at the closing brace. No +/// lookup below may resolve a label or SSA definition in another function. +fn function_bodies(ir: &str) -> impl Iterator { + ir.split("\ndefine ") + .skip(1) + .map(|f| f.split_once("\n}").map_or(f, |(body, _)| body)) +} + +fn verify_stem_function(ir: &str, stem: &str, kind: StemKind) -> Result<(), String> { let barrier_labels = all_numbered_labels(ir, &format!("{stem}.barrier.")); if barrier_labels.is_empty() { return Err(format!( @@ -448,6 +476,10 @@ const VAL_ID: u32 = 22; /// runtime-key fallback, and `v: Any` is what puts the store on the live-test /// tier at all (same fixture reasoning as `index_set_barrier_tests::setter`). fn idxset_inbounds_ir() -> String { + idxset_inbounds_ir_for_target(None) +} + +fn idxset_inbounds_ir_for_target(target: Option<&str>) -> String { let mut m = Module::new("idxset_inbounds_census.ts"); m.functions = vec![Function { id: 1, @@ -507,7 +539,9 @@ fn idxset_inbounds_ir() -> String { was_unrolled: false, }]; m.init_kind = ModuleInitKind::Eager; - String::from_utf8(compile_module(&m, ir_opts()).expect("module compiles")) + let mut opts = ir_opts(); + opts.target = target.map(str::to_string); + String::from_utf8(compile_module(&m, opts).expect("module compiles")) .expect("LLVM IR should be UTF-8") } @@ -924,19 +958,21 @@ fn sabotage_hardwiring_the_gate_goes_red_for_every_stem() { for &(stem, kind) in VERIFIED_BARRIER_STEMS { let ir = probe_ir(stem); verify_stem_ir(&ir, stem, kind).expect("pristine IR must verify first"); - let label = all_numbered_labels(&ir, &format!("{stem}.barrier.")) - .into_iter() - .next() - .expect("a gated barrier block exists"); - let (branch, _) = branch_into_exact(&ir, &label).expect("gated branch exists"); - let cond = live_branch_condition(&branch).expect("pristine branch is live"); - let hardwired = branch.replacen(&cond, "true", 1); - let doctored = ir.replacen(&branch, &hardwired, 1); - let doctored = assert_changed(&ir, &doctored, "hardwire branch true"); - assert!( - verify_stem_ir(&doctored, stem, kind).is_err(), - "stem {stem:?}: `br i1 true` with a dead predicate must be caught" - ); + for function in function_bodies(&ir) { + for label in all_numbered_labels(function, &format!("{stem}.barrier.")) { + let (branch, _) = branch_into_exact(function, &label).expect("gated branch exists"); + let cond = live_branch_condition(&branch).expect("pristine branch is live"); + let hardwired = branch.replacen(&cond, "true", 1); + let changed_function = function.replacen(&branch, &hardwired, 1); + let doctored = ir.replacen(function, &changed_function, 1); + let doctored = assert_changed(&ir, &doctored, "hardwire branch true"); + assert!( + verify_stem_ir(&doctored, stem, kind).is_err(), + "stem {stem:?} in {}: hardwired gate must be caught", + function.lines().next().unwrap() + ); + } + } } } @@ -993,19 +1029,21 @@ fn sabotage_bypassing_the_gate_goes_red_for_every_stem() { for &(stem, kind) in VERIFIED_BARRIER_STEMS { let ir = probe_ir(stem); verify_stem_ir(&ir, stem, kind).expect("pristine IR must verify first"); - let label = all_numbered_labels(&ir, &format!("{stem}.barrier.")) - .into_iter() - .next() - .expect("a gated barrier block exists"); - let (branch, _) = branch_into_exact(&ir, &label).expect("gated branch exists"); - let false_target = operand(&branch, 2).expect("branch has a false target"); - let bypass = format!("br label {false_target}"); - let doctored = ir.replacen(branch.trim_start(), &bypass, 1); - let doctored = assert_changed(&ir, &doctored, "bypass gate"); - assert!( - verify_stem_ir(&doctored, stem, kind).is_err(), - "stem {stem:?}: an unconditionally-bypassed gate must be caught" - ); + for function in function_bodies(&ir) { + for label in all_numbered_labels(function, &format!("{stem}.barrier.")) { + let (branch, _) = branch_into_exact(function, &label).expect("gated branch exists"); + let false_target = operand(&branch, 2).expect("branch has a false target"); + let bypass = format!("br label {false_target}"); + let changed_function = function.replacen(&branch, &bypass, 1); + let doctored = ir.replacen(function, &changed_function, 1); + let doctored = assert_changed(&ir, &doctored, "bypass gate"); + assert!( + verify_stem_ir(&doctored, stem, kind).is_err(), + "stem {stem:?} in {}: bypassed gate must be caught", + function.lines().next().unwrap() + ); + } + } } } @@ -1054,3 +1092,59 @@ fn sabotage_moving_the_store_into_the_guard_goes_red_for_the_store_ic() { "a slot store that only the pointer arm performs must be caught" ); } + +/// Repeated block labels AND SSA registers across function copies must never +/// let an intact copy conceal a broken one, on any directory-lookup target. +#[test] +fn identical_labels_in_other_functions_cannot_validate_a_sabotaged_gate() { + assert_default_barrier_env_not_disabled(); + let stem = "idxset.inbounds"; + let kind = StemKind::ValueAndGenerationTested; + for target in [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + ] { + let ir = idxset_inbounds_ir_for_target(Some(target)); + verify_stem_ir(&ir, stem, kind).expect("pristine target IR must verify"); + let function = function_bodies(&ir) + .find(|f| !all_numbered_labels(f, &format!("{stem}.barrier.")).is_empty()) + .expect("fixture must reach the barrier in a function"); + let start = function.find('@').unwrap(); + let end = function[start..].find('(').unwrap() + start; + let mut clone = function.to_string(); + clone.replace_range(start..end, "@barrier_contract_clone"); + let repeated = format!("{ir}\ndefine {clone}\n}}\n"); + verify_stem_ir(&repeated, stem, kind).expect("both intact copies must verify"); + let label = all_numbered_labels(&clone, &format!("{stem}.barrier.")).remove(0); + let (branch, _) = branch_into_exact(&clone, &label).unwrap(); + let cond = live_branch_condition(&branch).unwrap(); + let call_body = block_body_exact(&clone, &label).unwrap(); + let call = call_body + .lines() + .find(|l| l.contains(BARRIER_CALL)) + .unwrap(); + for (name, broken) in [ + ( + "bypass", + clone.replacen( + &branch, + &format!("br label {}", operand(&branch, 2).unwrap()), + 1, + ), + ), + ( + "hardwire", + clone.replacen(&branch, &branch.replacen(&cond, "true", 1), 1), + ), + ("delete call", clone.replacen(call, "", 1)), + ] { + let doctored = repeated.replacen(&clone, &broken, 1); + assert_changed(&repeated, &doctored, name); + assert!( + verify_stem_ir(&doctored, stem, kind).is_err(), + "{target}: {name} hidden by another function" + ); + } + } +} diff --git a/crates/perry-codegen/src/expr/binary.rs b/crates/perry-codegen/src/expr/binary.rs index bb503ecc92..6b72429f83 100644 --- a/crates/perry-codegen/src/expr/binary.rs +++ b/crates/perry-codegen/src/expr/binary.rs @@ -23,7 +23,7 @@ use crate::type_analysis::{ }; use crate::types::{DOUBLE, I1, I128, I32, I64}; -use crate::rooting::with_operands_rooted; +use crate::rooting::{self, with_operands_rooted, EmittedValue, Repr, RootedGroup}; use super::{is_known_i32_range, lower_expr, FnCtx}; @@ -238,7 +238,7 @@ fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result // `width` is provenance-proven — and the tripwire took whole // application builds down: pi's `graphemeWidth`, cc's cli bundle.) let Some(all_num) = cond else { - return Ok(rebuild_add_tree(ctx, expr, values, &mut 0, true)); + return Ok(rebuild_numeric_add_tree(ctx, expr, values, &mut 0)); }; let fast_idx = ctx.new_block("guarded_add.numeric"); @@ -250,13 +250,27 @@ fn lower_guarded_numeric_add(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result ctx.block().cond_br(&all_num, &fast_label, &slow_label); ctx.current_block = fast_idx; - let fast_val = rebuild_add_tree(ctx, expr, values, &mut 0, true); + let fast_val = rebuild_numeric_add_tree(ctx, expr, values, &mut 0); let fast_end = ctx.block().label.clone(); ctx.block().br(&merge_label); ctx.current_block = slow_idx; crate::expr::emit_versioned_loop_callback_deopt(ctx); - let slow_val = rebuild_add_tree(ctx, expr, values, &mut 0, false); + let slow_val = rooting::with_rooted_group(ctx, values.len(), |ctx, group| { + // Capture the already-evaluated leaves, including global reads: + // coercion may both relocate them and overwrite their bindings. + // Leaves consumed by the first call have no preceding window; + // that helper protects its own inputs during the call. + let mut protect = Vec::with_capacity(values.len()); + let _ = dynamic_add_leaf_windows(expr, &mut false, &mut protect); + let leaves: Vec<_> = values + .iter() + .zip(protect) + .map(|(value, protect)| group.adopt_emitted(ctx, Repr::Boxed, value, protect)) + .collect(); + let result = rebuild_rooted_dynamic_add_tree(ctx, expr, group, &leaves, &mut 0, false); + Ok(group.reread_emitted(ctx, result)) + })?; let slow_end = ctx.block().label.clone(); ctx.block().br(&merge_label); @@ -539,7 +553,7 @@ fn dynamic_add_tree_benefits_shared_guard(expr: &Expr) -> bool { /// /// The fold departs from the specification only in WHEN it reads such a /// leaf. The conversions themselves still run in specification order: the -/// cold arm (`rebuild_add_tree(.., fast = false)`) calls the spec-`+` helper +/// cold arm (`rebuild_rooted_dynamic_add_tree`) calls the spec-`+` helper /// node for node over the lowered values. So a tree is faithful exactly when /// every leaf the specification reads after an earlier conversion is one /// whose read time cannot be observed (`add_leaf_is_evaluation_invariant`). @@ -637,14 +651,12 @@ fn add_leaf_is_evaluation_invariant(ctx: &FnCtx<'_>, leaf: &Expr) -> bool { } /// Rebuild the `+` tree over already-lowered leaf values, node for node, so the -/// original associativity survives. `fast` picks the inline `fadd`; otherwise -/// every node goes through the spec-`+` helper. -fn rebuild_add_tree( +/// original associativity survives. This arm contains only inline `fadd`s. +fn rebuild_numeric_add_tree( ctx: &mut FnCtx<'_>, expr: &Expr, values: &[String], next_leaf: &mut usize, - fast: bool, ) -> String { if let Expr::Binary { op: BinaryOp::Add, @@ -652,23 +664,86 @@ fn rebuild_add_tree( right, } = expr { - let l = rebuild_add_tree(ctx, left, values, next_leaf, fast); - let r = rebuild_add_tree(ctx, right, values, next_leaf, fast); - return if fast { - ctx.block().fadd(&l, &r) - } else { - ctx.block().call( - DOUBLE, - "js_dynamic_string_or_number_add", - &[(DOUBLE, &l), (DOUBLE, &r)], - ) - }; + let l = rebuild_numeric_add_tree(ctx, left, values, next_leaf); + let r = rebuild_numeric_add_tree(ctx, right, values, next_leaf); + return ctx.block().fadd(&l, &r); } let value = values[*next_leaf].clone(); *next_leaf += 1; value } +/// Rebuild the original cold `+` tree through root handles. A left subtree's +/// result needs its own root when computing the right subtree can collect; +/// rooting the leaves alone cannot protect this newly-produced value. +fn rebuild_rooted_dynamic_add_tree( + ctx: &mut FnCtx<'_>, + expr: &Expr, + group: &mut RootedGroup<'_>, + leaves: &[EmittedValue], + next_leaf: &mut usize, + protect_result: bool, +) -> EmittedValue { + if let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = expr + { + let right_collects = matches!( + right.as_ref(), + Expr::Binary { + op: BinaryOp::Add, + .. + } + ); + let l = + rebuild_rooted_dynamic_add_tree(ctx, left, group, leaves, next_leaf, right_collects); + let r = rebuild_rooted_dynamic_add_tree(ctx, right, group, leaves, next_leaf, false); + // No register snapshot crosses the recursive right-hand calls. + // The helper owns both inputs during this consuming call. + let l = group.reread_emitted(ctx, l); + let r = group.reread_emitted(ctx, r); + let result = ctx.block().call( + DOUBLE, + "js_dynamic_string_or_number_add", + &[(DOUBLE, &l), (DOUBLE, &r)], + ); + return group.adopt_emitted(ctx, Repr::Boxed, &result, protect_result); + } + let leaf = leaves[*next_leaf]; + *next_leaf += 1; + leaf +} + +/// Which captured leaves are consumed after an earlier dynamic-add call? +/// Follow the same left/right/postorder call order as the cold rebuild, so +/// inputs used only by the first call need no extra roots. Intermediate +/// results have separate windows, handled by `protect_result` above. +fn dynamic_add_leaf_windows( + expr: &Expr, + called: &mut bool, + protect: &mut Vec, +) -> Option { + if let Expr::Binary { + op: BinaryOp::Add, + left, + right, + } = expr + { + let l = dynamic_add_leaf_windows(left, called, protect); + let r = dynamic_add_leaf_windows(right, called, protect); + for leaf in [l, r].into_iter().flatten() { + protect[leaf] = *called; + } + *called = true; + return None; + } + let index = protect.len(); + protect.push(false); + Some(index) +} + /// May the flattened `p1 + p2 + … + pN` chain be handed to /// `js_string_concat_chain`, which formats EVERY part as a string? (#7837) /// diff --git a/crates/perry-codegen/src/expr/call_spread_short.rs b/crates/perry-codegen/src/expr/call_spread_short.rs index 6104022e48..d733cde2c6 100644 --- a/crates/perry-codegen/src/expr/call_spread_short.rs +++ b/crates/perry-codegen/src/expr/call_spread_short.rs @@ -302,9 +302,12 @@ pub(crate) fn try_lower<'f, 'e>( let cid_ok = ctx .block() .icmp_eq(I32, &live_class, &candidate.class_id.to_string()); - let shape_ok = ctx - .block() - .icmp_eq(I32, &live_shape, &expected_shapes[candidate_no]); + let shape_ok = crate::typed_shape::emit_compatible_shape_eq( + ctx.block(), + &live_shape, + &expected_shapes[candidate_no], + &[], + ); let target_ok = ctx.block().and(I1, &cid_ok, &shape_ok); ctx.block().cond_br(&target_ok, &target_label, &miss_label); diff --git a/crates/perry-codegen/src/expr/class_field_barrier_tests.rs b/crates/perry-codegen/src/expr/class_field_barrier_tests.rs index fb7cb9c682..647d62062a 100644 --- a/crates/perry-codegen/src/expr/class_field_barrier_tests.rs +++ b/crates/perry-codegen/src/expr/class_field_barrier_tests.rs @@ -65,6 +65,7 @@ pub(super) fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/class_field_inline_guard.rs b/crates/perry-codegen/src/expr/class_field_inline_guard.rs index 46e0c52a5e..fb195d8328 100644 --- a/crates/perry-codegen/src/expr/class_field_inline_guard.rs +++ b/crates/perry-codegen/src/expr/class_field_inline_guard.rs @@ -18,8 +18,7 @@ //! arm puts an unknown external call inside the loop body, which //! clobber-blocks LICM for every load in the check. Per-access cost is //! therefore paid on every iteration. The hoisted form exists as the #5093 -//! versioned-loop preheader check (`emit_class_field_loop_preheader_check`, -//! sound only for call-free clone bodies), and statically-proven receivers +//! region preheader checks (`stmt::region_loop`), and statically-proven receivers //! skip the diamond entirely (`collectors/ptr_shape.rs`). Do not "fix" this //! by de-volatilizing the gate: it buys nothing (the calls still block LICM) //! and weakens the mid-loop sticky-flip visibility guarantee for loops whose @@ -39,10 +38,7 @@ use super::FnCtx; // Mirror of the runtime constants the inline check reproduces. Kept as literal // decimals because the emitted IR is textual. -const GC_TYPE_OBJECT: &str = "2"; const GC_FLAG_FORWARDED_I8: &str = "-128"; // 0x80 as i8 -/// `OBJ_FLAG_HAS_DESCRIPTORS | OBJ_FLAG_STABLE_TOMBSTONES`. -const OBJ_FLAG_READ_FAST_PATH_BLOCKED: &str = "3072"; /// `OBJ_FLAG_FROZEN | OBJ_FLAG_STABLE_TOMBSTONES | /// OBJ_FLAG_HAS_DESCRIPTORS`. Numeric proof is a different ShapeId, so the /// exact shape comparison below excludes it. @@ -274,125 +270,6 @@ pub(crate) fn emit_plain_finite_number_check( blk.icmp_ne(I64, &exp, F64_EXP_MASK) } -/// #5093 loop versioning: emit the whole-loop shape check in a versioned -/// loop's preheader. -/// -/// This is the hoisted form of [`emit_class_field_inline_precheck`]: the same -/// strict subset of the runtime `class_field_fast_contract`, evaluated ONCE -/// before loop entry, branching to `fast_label` (the fast clone's preheader) -/// when the monomorphic shape holds and to `slow_label` (the slow clone's -/// preheader, i.e. today's guarded loop) otherwise. Evaluating it once is -/// sound only because the fast clone's body is call-free (matcher-enforced in -/// `stmt/loops.rs`): with no calls there is no allocation, so no GC can move -/// the object or run any of the runtime paths that mutate class_id / -/// keys_array / field_count / the typed-layout intact bit / the frozen bit / -/// the process-global enable flag mid-loop. -/// -/// No typed-layout bit is tested (charter step 5, P4: raw-f64 fields are `F64` -/// birth lanes of the compared id); `require_not_frozen` adds the frozen-bit check (any write in the -/// loop). Per-store value checks are NOT emitted here — the fast clone's -/// stores keep their inline plain-finite check and side-exit to `slow_label`. -/// -/// Returns `(obj_ptr, shape_ok)`: the SSA name of the receiver object pointer -/// (`inttoptr` of `obj_handle`) and the accumulated `i1` shape predicate, -/// both emitted in the deref block. The deref block is deliberately left -/// UNTERMINATED with `ctx.current_block` pointing at it: the caller lowers -/// the fast clone first, verifies it really came out call-free -/// (`LlBlock::contains_gc_unsafe_call`), and only then terminates the deref -/// block — `cond_br(shape_ok, fast, slow)` on success, or an unconditional -/// branch to the slow clone if some unpredicted lowering path emitted a call -/// (never enter a fast clone whose call-freeness is unproven). The deref -/// block dominates the fast preheader, so the fast clone may use `obj_ptr` -/// directly for raw slot access. -#[allow(clippy::too_many_arguments)] -pub(crate) fn emit_class_field_loop_preheader_check( - ctx: &mut FnCtx, - obj_bits: &str, - obj_handle: &str, - expected_class_id: &str, - expected_shape_id: &str, - require_not_frozen: bool, - slow_label: &str, -) -> (String, String) { - let deref_idx = ctx.new_block("class_field_loop.preheader.deref"); - let deref_label = ctx.block_label(deref_idx); - - // Gate: enable flag first (volatile — the runtime flips it sticky 0 -> 1 - // when descriptors / typed feedback / verify mode come into use), then - // prove the receiver is a real heap object before dereferencing. - { - let blk = ctx.block(); - let flag = blk.load_volatile(I8, "@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"); - let flag_ok = blk.icmp_eq(I8, &flag, "0"); - // POINTER tag and above the handle band: the fused receiver test. - let ptr_safe = - crate::expr::receiver_range::emit_fused_receiver_test(blk, obj_bits).is_object_pointer; - let can_inline = blk.and(I1, &ptr_safe, &flag_ok); - blk.cond_br(&can_inline, &deref_label, slow_label); - } - - ctx.current_block = deref_idx; - { - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, obj_handle); - - // GcHeader (precedes the object by 8 bytes): obj_type @-8 (i8), - // gc_flags @-7 (i8), _reserved @-6 (i16). - let gtype_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-8")]); - let gtype = blk.load(I8, >ype_ptr); - let gtype_ok = blk.icmp_eq(I8, >ype, GC_TYPE_OBJECT); - - let gflags_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-7")]); - let gflags = blk.load(I8, &gflags_ptr); - let fwd = blk.and(I8, &gflags, GC_FLAG_FORWARDED_I8); - let not_fwd = blk.icmp_eq(I8, &fwd, "0"); - - let res_ptr = blk.gep(I8, &obj_ptr, &[(I64, "-6")]); - let reserved = blk.load(I16, &res_ptr); - - // ObjectHeader: class_id @0 and authoritative ShapeId @4 (#8113 — the - // two leading offsets moved down 4 when `object_type` was deleted). - // Matching the immutable descriptor proves the live-slot bound and key - // order. - let cid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "0")]); - let class_id = blk.load(I32, &cid_ptr); - let cid_ok = blk.icmp_eq(I32, &class_id, expected_class_id); - - let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); - let shape_id = blk.load(I32, &sid_ptr); - let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); - - let mut acc = blk.and(I1, >ype_ok, ¬_fwd); - acc = blk.and(I1, &acc, &cid_ok); - acc = blk.and(I1, &acc, &shape_ok); - - // #5654: a receiver that has ever had a property / accessor descriptor - // installed on it needs the guard's descriptor-aware dispatch (an - // accessor must fire on reads, a non-writable slot must reject - // stores). Instance-level installs no longer flip the process-global - // gate, so the hoisted check must vet the per-object flag — once, for - // the whole loop: installing a descriptor mid-loop would require a - // runtime call, which the call-free fast clone cannot make. - let blocked = blk.and(I16, &reserved, OBJ_FLAG_READ_FAST_PATH_BLOCKED); - let unblocked = blk.icmp_eq(I16, &blocked, "0"); - acc = blk.and(I1, &acc, &unblocked); - - // Charter step 5, P4: a raw-f64 field needs no per-object bit. The - // site is raw only for an `F64` lane of every compared id's birth rep - // (`class_field_site_raw_f64`), and an object carrying such an id holds - // a Number in that lane by the shape's invariant. - - if require_not_frozen { - let blocked = blk.and(I16, &reserved, OBJ_FLAG_WRITE_FAST_PATH_BLOCKED); - let write_fast_path_ok = blk.icmp_eq(I16, &blocked, "0"); - acc = blk.and(I1, &acc, &write_fast_path_ok); - } - - // No terminator: the caller branches after verifying the fast clone. - (obj_ptr, acc) - } -} - /// #7142: the inline shape re-check that licenses routing a class-id dispatch /// tower case to a proven-receiver method clone. /// @@ -476,7 +353,8 @@ pub(crate) fn emit_proven_shape_recheck( // exact immutable layout and receiver-kind descriptor (#8113 offsets). let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); - let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape_id); + let shape_ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, expected_shape_id, &[]); let mut acc = blk.and(I1, &flag_ok, ¬_fwd); acc = blk.and(I1, &acc, &unlatched); @@ -581,7 +459,14 @@ pub(crate) fn emit_class_field_inline_precheck( // one 64-bit compare against `(shape << 32) | class_id`. let identity = blk.load(I64, &obj_ptr); let declared = expected_class_identity(blk, expected_class_id, &live_shape); - let mut ok = blk.icmp_eq(I64, &identity, &declared); + let mut ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &identity, + expected_class_id, + &live_shape, + &declared, + &[field_index], + ); for arm in subclass_arms { let arm_shape = crate::typed_shape::class_shape_id_operand_on_block( blk, @@ -590,21 +475,38 @@ pub(crate) fn emit_class_field_inline_precheck( ); let arm_expected = expected_class_identity(blk, &arm.class_id.to_string(), &arm_shape); - let arm_ok = blk.icmp_eq(I64, &identity, &arm_expected); + let arm_ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &identity, + &arm.class_id.to_string(), + &arm_shape, + &arm_expected, + &[field_index], + ); ok = blk.or(I1, &ok, &arm_ok); } ok } else { let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); - let mut ok = blk.icmp_eq(I32, &shape_id, &live_shape); + let mut ok = crate::typed_shape::emit_compatible_shape_eq( + blk, + &shape_id, + &live_shape, + &[field_index], + ); for arm in subclass_arms { let arm_shape = crate::typed_shape::class_shape_id_operand_on_block( blk, &arm.keys_global, true, ); - let arm_ok = blk.icmp_eq(I32, &shape_id, &arm_shape); + let arm_ok = crate::typed_shape::emit_compatible_shape_eq( + blk, + &shape_id, + &arm_shape, + &[field_index], + ); ok = blk.or(I1, &ok, &arm_ok); } ok @@ -757,18 +659,27 @@ pub(crate) fn emit_class_field_read_precheck( // one 64-bit compare against `(shape << 32) | class_id`. let identity = blk.load(I64, &obj_ptr); let declared = expected_class_identity(blk, expected_class_id, &live_shape); - blk.icmp_eq(I64, &identity, &declared) + crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &identity, + expected_class_id, + &live_shape, + &declared, + &[], + ) } else { let sid_ptr = blk.gep(I8, &obj_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); - let mut ok = blk.icmp_eq(I32, &shape_id, &live_shape); + let mut ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, &live_shape, &[]); for arm in subclass_arms { let arm_shape = crate::typed_shape::class_shape_id_operand_on_block( blk, &arm.keys_global, true, ); - let arm_ok = blk.icmp_eq(I32, &shape_id, &arm_shape); + let arm_ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, &arm_shape, &[]); ok = blk.or(I1, &ok, &arm_ok); } ok @@ -785,7 +696,14 @@ pub(crate) fn emit_class_field_read_precheck( ); let arm_expected = expected_class_identity(blk, &arm.class_id.to_string(), &arm_shape); - let arm_ok = blk.icmp_eq(I64, &identity, &arm_expected); + let arm_ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &identity, + &arm.class_id.to_string(), + &arm_shape, + &arm_expected, + &[], + ); ok = blk.or(I1, &ok, &arm_ok); } } diff --git a/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs b/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs index 305a3cfcbb..7adec8f974 100644 --- a/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs +++ b/crates/perry-codegen/src/expr/class_method_arguments_object_tests.rs @@ -59,6 +59,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/closure.rs b/crates/perry-codegen/src/expr/closure.rs index f8d46d2a95..c38481801a 100644 --- a/crates/perry-codegen/src/expr/closure.rs +++ b/crates/perry-codegen/src/expr/closure.rs @@ -204,7 +204,8 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // Compute the closure function name BEFORE taking the // mutable block borrow. - let func_name = format!("perry_closure_{}__{}", ctx.strings.module_prefix(), func_id); + let func_name = + crate::fn_info::closure_body_symbol(ctx.strings.module_prefix(), *func_id); // Closures may reserve extra lexical slots after ordinary // captures. Keep `this` last because the runtime's diff --git a/crates/perry-codegen/src/expr/collecting_root_tests.rs b/crates/perry-codegen/src/expr/collecting_root_tests.rs new file mode 100644 index 0000000000..4865d3df32 --- /dev/null +++ b/crates/perry-codegen/src/expr/collecting_root_tests.rs @@ -0,0 +1,1082 @@ +//! Caller roots on collecting field-store, nested-add and constructor paths. +use crate::testing::{root_slots::function_slice, temp_slots}; +use crate::{compile_module, user_function_symbol}; +use perry_hir::types::Type; +use perry_hir::{BinaryOp, Class, ClassField, Expr, Function, Module, Param, Stmt}; +use std::collections::{BTreeMap, BTreeSet}; + +fn probe(params: Vec, result: Expr) -> Function { + Function { + id: 1, + name: "probe".to_string(), + type_params: Vec::new(), + params: params + .into_iter() + .enumerate() + .map(|(i, ty)| Param { + id: i as u32 + 1, + name: format!("p{i}"), + ty, + default: None, + decorators: Vec::new(), + is_rest: false, + arguments_object: None, + }) + .collect(), + return_type: Type::Any, + body: vec![Stmt::Return(Some(result))], + is_async: false, + is_generator: false, + is_strict: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn ir_for(mut module: Module, function: Function) -> String { + module.functions = vec![function]; + let symbol = user_function_symbol(&module.name, "probe"); + let ir = String::from_utf8( + compile_module(&module, crate::temp_root_coverage::entry_opts()) + .expect("collecting-root fixture compiles"), + ) + .unwrap(); + function_slice(&ir, &symbol).to_string() +} + +fn blocks(ir: &str) -> BTreeMap<&str, String> { + let mut out = BTreeMap::new(); + let mut label = None; + for line in ir.lines() { + if !line.starts_with(char::is_whitespace) && line.ends_with(':') { + label = Some(line.trim_end_matches(':')); + } else if let Some(label) = label { + out.entry(label) + .or_insert_with(String::new) + .push_str(&format!("{line}\n")); + } + } + out +} + +#[derive(Clone, Copy, Debug)] +struct Site<'a> { + label: &'a str, + index: usize, + text: &'a str, +} + +struct ColdCfg<'a> { + ir: &'a str, + blocks: &'a BTreeMap<&'a str, String>, + start: &'a str, + stop: &'a str, +} + +fn block<'a>(blocks: &'a BTreeMap<&str, String>, prefix: &str, ir: &str) -> (&'a str, &'a str) { + let matches: Vec<_> = blocks + .iter() + .filter(|(label, _)| label.starts_with(prefix)) + .collect(); + assert_eq!(matches.len(), 1, "expected one block {prefix}:\n{ir}"); + (*matches[0].0, matches[0].1.as_str()) +} + +fn registers(text: &str) -> impl Iterator { + text.split(|c: char| !(c.is_alphanumeric() || c == '%' || c == '.' || c == '_')) + .filter(|token| token.starts_with('%')) +} + +fn successors(body: &str) -> impl Iterator { + // Read terminator edges only; phi predecessors are not successor edges. + body.lines() + .map(str::trim) + .filter(|line| line.starts_with("br ")) + .flat_map(|line| line.split("label %").skip(1)) + .map(|tail| { + tail.split(|c: char| !(c.is_alphanumeric() || c == '.' || c == '_')) + .next() + .unwrap() + }) +} + +impl<'a> ColdCfg<'a> { + fn reachable(&self, start: &'a str, skip: Option<&str>) -> BTreeSet<&'a str> { + let mut pending = vec![start]; + let mut seen = BTreeSet::new(); + while let Some(label) = pending.pop() { + if label == self.stop || Some(label) == skip || !seen.insert(label) { + continue; + } + let body = self + .blocks + .get(label) + .unwrap_or_else(|| panic!("missing CFG target {label}:\n{}", self.ir)); + pending.extend(successors(body)); + } + seen + } + + fn sites(&self) -> Vec> { + self.reachable(self.start, None) + .into_iter() + .flat_map(|label| { + self.blocks[label] + .lines() + .enumerate() + .map(move |(index, text)| Site { + label, + index, + text: text.trim(), + }) + }) + .collect() + } + + fn definition(&self, reg: &str) -> Site<'a> { + let needle = format!("{reg} = "); + self.blocks + .iter() + .find_map(|(label, body)| { + body.lines().enumerate().find_map(|(index, text)| { + text.trim().starts_with(&needle).then_some(Site { + label, + index, + text: text.trim(), + }) + }) + }) + .unwrap_or_else(|| panic!("missing definition {reg}:\n{}", self.ir)) + } + + fn dominates(&self, before: Site<'_>, after: Site<'_>) -> bool { + let region = self.reachable(self.start, None); + if !region.contains(before.label) || !region.contains(after.label) { + return false; + } + if before.label == after.label { + return before.index < after.index; + } + !self + .reachable(self.start, Some(before.label)) + .contains(after.label) + } + + fn assert_before(&self, before: Site<'_>, after: Site<'_>, what: &str) { + assert!( + self.dominates(before, after), + "{what}: {before:?} must dominate {after:?} through the cold CFG:\n{}", + self.ir + ); + } + + fn calls(&self, helper: &str) -> Vec> { + let needle = format!("@{helper}("); + let mut pending: Vec<_> = self + .sites() + .into_iter() + .filter(|site| site.text.contains("call ") && site.text.contains(&needle)) + .collect(); + let mut ordered = Vec::new(); + // Order by control flow, never by block emission order. Reject branches + // whose calls do not have the fixture's expected sequential relation. + while !pending.is_empty() { + let first = pending + .iter() + .position(|candidate| { + pending.iter().all(|other| { + (candidate.label == other.label && candidate.index == other.index) + || self.dominates(*candidate, *other) + }) + }) + .unwrap_or_else(|| { + panic!( + "@{helper} calls lack a dominance order: {pending:?}\n{}", + self.ir + ) + }); + ordered.push(pending.remove(first)); + } + ordered + } + + // Only representation-preserving wrappers are accepted between a root + // load and an operand, including native RS4GC's opaque identity asm. + fn root_read(&self, operand: &str, consumer: Site<'_>) -> (&'a str, Site<'a>) { + let (slot, read) = self.slot_read(operand, consumer); + assert!(expression_temp_slots(self.ir, self.blocks).contains(slot), + "cold operand must read an expression root, not its mutable source binding: {read:?}\n{}", self.ir); + (slot, read) + } + + fn slot_read(&self, operand: &str, consumer: Site<'_>) -> (&'a str, Site<'a>) { + assert!( + temp_slots::derives_from_slot_load(self.ir, operand, 16), + "{operand} must derive from a rooted load:\n{}", + self.ir + ); + let mut reg = operand; + for _ in 0..16 { + let site = self.definition(reg); + let def = site.text.split_once(" = ").unwrap().1; + if def.starts_with("load ") { + self.assert_before(site, consumer, "root reread must dominate its consumer"); + let slot = def + .rsplit_once(", ptr ") + .unwrap() + .1 + .split(',') + .next() + .unwrap() + .trim(); + return (slot, site); + } + assert!( + def.starts_with("bitcast ") + || def.starts_with("ptrtoint ") + || def.starts_with("inttoptr ") + || def.starts_with("and i64 ") + || (def.starts_with("or i64 ") + && def.ends_with(crate::nanbox::POINTER_TAG_I64)) + || (def.starts_with("call i64 asm \"\"") && def.contains("\"=r,0\"")), + "unexpected root operand transformation: {site:?}\n{}", + self.ir + ); + reg = registers(def).next().unwrap(); + } + panic!("no root load for {operand}:\n{}", self.ir) + } + + fn publications(&self, slot: &str) -> Vec> { + self.sites() + .into_iter() + .filter(|site| { + site.text.starts_with("store ") + && !is_clear(site.text) + && store_parts(site.text).is_some_and(|(_, target)| target == slot) + }) + .collect() + } + + fn publication_before(&self, slot: &str, consumer: Site<'_>) -> Site<'a> { + let publications: Vec<_> = self + .publications(slot) + .into_iter() + .filter(|store| self.dominates(*store, consumer)) + .collect(); + assert_eq!( + publications.len(), + 1, + "one cold publication of {slot} must dominate {consumer:?}:\n{}", + self.ir + ); + publications[0] + } +} + +fn store_parts(line: &str) -> Option<(&str, &str)> { + let rest = line.strip_prefix("store ")?; + let (value, slot) = rest.split_once(", ptr ")?; + Some((value, slot.split(',').next().unwrap().trim())) +} + +fn is_clear(line: &str) -> bool { + line.starts_with("store i64 0,") || line.starts_with("store ptr addrspace(1) null,") +} + +fn derives_from(ir: &str, value: &str, ancestor: &str, depth: usize) -> bool { + if value == ancestor { + return true; + } + if depth == 0 { + return false; + } + let needle = format!("{value} = "); + ir.lines() + .map(str::trim) + .find_map(|line| line.strip_prefix(&needle)) + .is_some_and(|def| registers(def).any(|reg| derives_from(ir, reg, ancestor, depth - 1))) +} + +fn expression_temp_slots(ir: &str, blocks: &BTreeMap<&str, String>) -> BTreeSet { + let (entry, _) = block(blocks, "entry.", ir); + temp_slots::temp_root_slots(ir) + .into_iter() + .filter(|slot| { + // Native roots zero-seed parameter allocas too. Exempt only roots + // whose sole publication is an entry store of a function argument; + // an expression root later parking that argument remains a temp. + let stores: Vec<_> = blocks + .iter() + .flat_map(|(label, body)| { + body.lines().map(str::trim).filter_map(move |line| { + store_parts(line) + .filter(|(_, target)| *target == slot) + .filter(|_| !is_clear(line)) + .map(move |(value, _)| (*label, value)) + }) + }) + .collect(); + !(stores.len() == 1 + && stores[0].0 == entry + && registers(ir.lines().next().unwrap()).any(|argument| { + registers(stores[0].1).any(|reg| derives_from(ir, reg, argument, 8)) + })) + }) + .collect() +} + +fn assert_hot_has_no_temp_traffic( + ir: &str, + blocks: &BTreeMap<&str, String>, + start: &str, + stop: &str, +) { + let cfg = ColdCfg { + ir, + blocks, + start, + stop, + }; + let slots = expression_temp_slots(ir, blocks); + for label in cfg.reachable(start, None) { + let body = &blocks[label]; + for token in registers(body) { + assert!( + !slots.contains(token), + "hot block {label} touches temp root {token}:\n{ir}" + ); + } + assert!( + !body.contains("js_gc_temp_root_"), + "hot block has runtime root traffic:\n{ir}" + ); + } +} + +fn field_store_ir(field_ty: Type) -> String { + // A declared number alone has an Any birth lane. Use an actual early + // numeric initializer so this fixture reaches the raw-f64 store arm. + let init = (field_ty == Type::Number).then_some(Expr::Number(0.0)); + let mut module = Module::new("collecting_field_store.ts"); + module.classes = vec![Class { + id: 101, + name: "Boxed".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: vec![ClassField { + name: "v".to_string(), + key_expr: None, + ty: field_ty, + init, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + }], + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + }]; + ir_for( + module, + probe( + vec![Type::Named("Boxed".to_string()), Type::Any], + Expr::PropertySet { + object: Box::new(Expr::LocalGet(1)), + property: "v".to_string(), + value: Box::new(Expr::LocalGet(2)), + }, + ), + ) +} + +#[test] +fn collecting_field_guard_refreshes_store_fallback_and_assignment_only_on_cold_paths() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + for field_ty in [Type::Any, Type::Number] { + let numeric = field_ty == Type::Number; + let ir = field_store_ir(field_ty); + let blocks = blocks(&ir); + let (hot_label, _) = block(&blocks, "class_field_set.fast.", &ir); + let (merge_label, merge) = block(&blocks, "class_field_set.merge.", &ir); + let (guard_entry, _) = block(&blocks, "class_field_inline.guardcall.", &ir); + let (cold_label, _) = block(&blocks, "class_field_set.cold_fast.", &ir); + let (cold_merge_label, _) = block(&blocks, "class_field_set.cold_merge.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: guard_entry, + stop: merge_label, + }; + let guards = cfg.calls("js_typed_feedback_class_field_set_guard"); + assert_eq!(guards.len(), 1, "{mode}: one collecting guard:\n{ir}"); + let guard = guards[0]; + let guard_body = &blocks[guard.label]; + assert!( + successors(guard_body).any(|label| label == cold_label), + "{mode}: collecting guard must enter rooted cold store:\n{ir}" + ); + assert!( + !cfg.reachable(guard_entry, None).contains(hot_label), + "collecting edge must not enter unrooted hot store:\n{ir}" + ); + assert!( + blocks.values().any(|body| body.contains("br i1 ") + && successors(body).any(|label| label == hot_label)), + "inline store must remain reachable:\n{ir}" + ); + assert_hot_has_no_temp_traffic(&ir, &blocks, hot_label, merge_label); + let guard_args = + temp_slots::call_operands(guard.text, "js_typed_feedback_class_field_set_guard") + .unwrap(); + let (receiver_slot, _) = cfg.root_read(&guard_args[1], guard); + let (rhs_slot, _) = cfg.root_read(&guard_args[6], guard); + assert_ne!( + receiver_slot, rhs_slot, + "receiver and RHS need distinct live roots:\n{ir}" + ); + for slot in [receiver_slot, rhs_slot] { + cfg.publication_before(slot, guard); + } + + // Inspect every store reachable from the guard-pass entry until + // its cold merge. Number fields legitimately store either the + // reread or the NaN canonicalizer's result. + let store_cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: cold_label, + stop: cold_merge_label, + }; + let stores: Vec<_> = store_cfg + .sites() + .into_iter() + .filter(|site| site.text.starts_with("store double ")) + .collect(); + assert!( + !stores.is_empty(), + "cold guard-pass arm must store a value:\n{ir}" + ); + let mut canonical_stores = 0; + for store in stores { + let (value, target) = store_parts(store.text).unwrap(); + let stored = value.strip_prefix("double ").unwrap(); + let definition = cfg.definition(stored); + let rhs = if definition + .text + .contains("call double @js_array_numeric_value_to_raw_f64(") + { + assert!(numeric, "only Number fields canonicalize raw f64:\n{ir}"); + canonical_stores += 1; + cfg.assert_before(definition, store, "canonicalizer result must reach store"); + temp_slots::call_operands(definition.text, "js_array_numeric_value_to_raw_f64") + .unwrap()[0] + .clone() + } else { + stored.to_string() + }; + let (slot, reload) = cfg.root_read(&rhs, store); + assert_eq!( + slot, rhs_slot, + "guard-pass store must consume refreshed RHS:\n{ir}" + ); + cfg.assert_before( + guard, + reload, + "store RHS must be reread after collecting guard", + ); + let receiver_load = cfg.sites().into_iter().find(|site| { + site.text.contains(" = load ") + && site.text.rsplit_once(", ptr ").is_some_and(|(_, tail)| tail.split(',').next().unwrap().trim() == receiver_slot) + && cfg.dominates(guard, *site) && cfg.dominates(*site, store) + && derives_from(&ir, target, site.text.split_once(" = ").unwrap().0, 16) + }).unwrap_or_else(|| panic!("store address must derive from post-guard receiver reread: {store:?}\n{ir}")); + cfg.assert_before(guard, receiver_load, "receiver must refresh after guard"); + } + if numeric { + assert!( + canonical_stores > 0, + "Number cold path must retain non-finite/boxed-number canonicalization:\n{ir}" + ); + } + + let fallbacks = cfg.calls("js_class_field_set_fallback"); + assert_eq!(fallbacks.len(), 1, "one setter fallback:\n{ir}"); + let fallback = fallbacks[0]; + let fallback_args = + temp_slots::call_operands(fallback.text, "js_class_field_set_fallback").unwrap(); + for (operand, expected) in [ + (&fallback_args[1], receiver_slot), + (&fallback_args[3], rhs_slot), + ] { + let (slot, reload) = cfg.root_read(operand, fallback); + assert_eq!( + slot, expected, + "fallback must read same captured root:\n{ir}" + ); + cfg.assert_before( + guard, + reload, + "fallback reread must follow collecting guard", + ); + } + assert!( + blocks[fallback.label].contains("load double, ptr @"), + "fallback must reload immutable key handle:\n{ir}" + ); + + let phi = merge + .lines() + .find(|line| line.contains("phi double")) + .unwrap(); + let incoming: Vec<_> = phi + .split('[') + .skip(1) + .map(|tail| { + let (value, predecessor) = tail.split_once(',').unwrap(); + ( + value.trim(), + predecessor + .split(']') + .next() + .unwrap() + .trim() + .trim_start_matches('%'), + ) + }) + .filter(|(_, predecessor)| cfg.reachable(guard_entry, None).contains(predecessor)) + .collect(); + assert_eq!( + incoming.len(), + 1, + "assignment has one cold result edge:\n{ir}" + ); + let (cold_result, cold_end) = incoming[0]; + let result_use = Site { + label: cold_end, + index: blocks[cold_end].lines().count(), + text: "cold result edge", + }; + let (slot, result_read) = cfg.root_read(cold_result, result_use); + assert_eq!(slot, rhs_slot, "assignment must return captured RHS:\n{ir}"); + // The fallback need not dominate the join: both alternatives enter + // it. Require the join to be reachable from each and the reload + // to reside there, after a possible setter collection. + assert_eq!( + result_read.label, cold_merge_label, + "assignment reread belongs in cold merge:\n{ir}" + ); + let fallback_cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: fallback.label, + stop: merge_label, + }; + fallback_cfg.assert_before( + fallback, + result_read, + "assignment must reread after the possible setter collection", + ); + for slot in [receiver_slot, rhs_slot] { + let clears: Vec<_> = cfg + .sites() + .into_iter() + .filter(|site| { + is_clear(site.text) + && store_parts(site.text).is_some_and(|(_, target)| target == slot) + }) + .collect(); + assert_eq!( + clears.len(), + 1, + "cold group must release {slot} exactly once:\n{ir}" + ); + cfg.assert_before( + result_read, + clears[0], + "assignment reread must precede root release", + ); + cfg.assert_before( + clears[0], + result_use, + "release must precede cold result edge", + ); + } + } + }); +} + +fn add(left: Expr, right: Expr) -> Expr { + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(left), + right: Box::new(right), + } +} + +const ADD_HELPER: &str = "js_dynamic_string_or_number_add"; + +#[test] +fn single_dynamic_add_has_no_prior_call_window_or_added_temp_roots() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + let ir = ir_for( + Module::new("collecting_single_add.ts"), + probe( + vec![Type::Any; 2], + add(Expr::LocalGet(1), Expr::LocalGet(2)), + ), + ); + let blocks = blocks(&ir); + let (slow, _) = block(&blocks, "guarded_add.dynamic.", &ir); + let (merge, _) = block(&blocks, "guarded_add.merge.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: slow, + stop: merge, + }; + assert_eq!( + cfg.calls(ADD_HELPER).len(), + 1, + "{mode}: one consuming add:\n{ir}" + ); + assert_hot_has_no_temp_traffic(&ir, &blocks, slow, merge); + assert!(expression_temp_slots(&ir, &blocks).is_empty(), + "single consuming add has no earlier collection window or expression-root allocations:\n{ir}"); + }); +} + +#[test] +fn right_nested_add_preserves_captured_leaf_across_inner_coercion() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + let ir = ir_for( + Module::new("collecting_nested_add.ts"), + probe( + vec![Type::Any; 3], + add(Expr::LocalGet(1), add(Expr::LocalGet(2), Expr::LocalGet(3))), + ), + ); + let blocks = blocks(&ir); + let (fast_label, fast) = block(&blocks, "guarded_add.numeric.", &ir); + let (merge_label, _) = block(&blocks, "guarded_add.merge.", &ir); + assert_eq!( + fast.matches("fadd double").count(), + 2, + "{mode}: numeric tree:\n{ir}" + ); + assert_hot_has_no_temp_traffic(&ir, &blocks, fast_label, merge_label); + let (slow, _) = block(&blocks, "guarded_add.dynamic.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: slow, + stop: merge_label, + }; + let calls = cfg.calls(ADD_HELPER); + assert_eq!( + calls.len(), + 2, + "{mode}: inner and outer consuming calls:\n{ir}" + ); + let outer = temp_slots::call_operands(calls[1].text, ADD_HELPER).unwrap(); + let (slot, reread) = cfg.root_read(&outer[0], calls[1]); + cfg.publication_before(slot, calls[0]); + cfg.assert_before( + calls[0], + reread, + "saved leaf must be reread after inner coercion", + ); + let inner_result = calls[0].text.split_once(" = ").unwrap().0; + assert!( + derives_from(&ir, &outer[1], inner_result, 16), + "outer right must consume inner result:\n{ir}" + ); + }); +} + +#[test] +fn balanced_add_roots_left_intermediate_across_right_subtree() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + let ir = ir_for( + Module::new("collecting_balanced_add.ts"), + probe( + vec![Type::Any; 4], + add( + add(Expr::LocalGet(1), Expr::LocalGet(2)), + add(Expr::LocalGet(3), Expr::LocalGet(4)), + ), + ), + ); + let blocks = blocks(&ir); + let (fast, fast_body) = block(&blocks, "guarded_add.numeric.", &ir); + let (merge, _) = block(&blocks, "guarded_add.merge.", &ir); + assert_eq!( + fast_body.matches("fadd double").count(), + 3, + "{mode}: numeric tree:\n{ir}" + ); + assert_hot_has_no_temp_traffic(&ir, &blocks, fast, merge); + let (slow, _) = block(&blocks, "guarded_add.dynamic.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: slow, + stop: merge, + }; + let calls = cfg.calls(ADD_HELPER); + assert_eq!(calls.len(), 3, "{mode}: left, right and outer calls:\n{ir}"); + let intermediate = calls[0].text.split_once(" = ").unwrap().0; + let slot = temp_slots::temp_root_slot_holding(&ir, intermediate) + .unwrap_or_else(|| panic!("left intermediate must enter an expression root:\n{ir}")); + let publication = cfg.publication_before(&slot, calls[1]); + cfg.assert_before( + calls[0], + publication, + "left intermediate publication must follow its producer", + ); + let (stored, _) = store_parts(publication.text).unwrap(); + assert!( + registers(stored).any(|reg| derives_from(&ir, reg, intermediate, 16)), + "root dominating right call must hold left intermediate:\n{ir}" + ); + let outer = temp_slots::call_operands(calls[2].text, ADD_HELPER).unwrap(); + let (reread_slot, reread) = cfg.root_read(&outer[0], calls[2]); + assert_eq!( + reread_slot, slot, + "outer left must read intermediate root:\n{ir}" + ); + cfg.assert_before( + calls[1], + reread, + "left intermediate reread must follow right subtree collection", + ); + let right_result = calls[1].text.split_once(" = ").unwrap().0; + assert!( + derives_from(&ir, &outer[1], right_result, 16), + "outer right must consume right-subtree result:\n{ir}" + ); + }); +} + +fn imported_constructor_ir(walk_ancestor: bool, has_rest: bool, has_arguments: bool) -> String { + let mut opts = crate::temp_root_coverage::entry_opts(); + opts.imported_classes.push(crate::ImportedClass { + name: "WorkerMade".to_string(), + local_alias: None, + namespace: None, + source_prefix: "worker_producer_ts".to_string(), + constructor_param_count: (usize::from(has_rest) + usize::from(has_arguments)).max(1), + has_own_constructor: true, + constructor_has_rest: has_rest, + constructor_has_synthetic_arguments: has_arguments, + has_instance_fields: true, + method_names: Vec::new(), + proven_this_method_names: Vec::new(), + proven_this_tower_method_names: Vec::new(), + method_return_types: Vec::new(), + method_param_counts: Vec::new(), + method_has_rest: Vec::new(), + method_has_synthetic_arguments: Vec::new(), + method_arguments_length_only: Vec::new(), + static_field_names: Vec::new(), + static_method_names: Vec::new(), + static_method_return_types: Vec::new(), + static_method_param_counts: Vec::new(), + static_method_has_rest: Vec::new(), + static_method_has_user_rest: Vec::new(), + static_method_has_synthetic_arguments: Vec::new(), + getter_names: Vec::new(), + getter_return_types: Vec::new(), + setter_names: Vec::new(), + parent_name: None, + field_names: vec!["v".to_string(), "w".to_string()], + field_types: vec![Type::Any, Type::Any], + source_class_id: Some(101), + return_shape_imports: Vec::new(), + object_literal: None, + }); + let mut module = Module::new("collecting_imported_constructor.ts"); + if walk_ancestor { + module.classes.push(Class { + id: 102, + name: "Leaf".to_string(), + type_params: Vec::new(), + extends: Some(101), + extends_name: Some("WorkerMade".to_string()), + native_extends: None, + extends_expr: None, + heritage_lexically_shadowed: false, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_accessor_names: Vec::new(), + static_accessor_fn_ids: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + is_nested: false, + alloc_width_hint: 0, + specialized_from: None, + }); + } + module.functions.push(probe( + vec![Type::Any], + Expr::New { + class_name: if walk_ancestor { "Leaf" } else { "WorkerMade" }.to_string(), + // A real pointer-bearing argument, not an undefined padding value. + args: vec![Expr::LocalGet(1), Expr::Object(Vec::new())], + type_args: Vec::new(), + cap_args_appended: 0, + byte_offset: 0, + }, + )); + let symbol = user_function_symbol(&module.name, "probe"); + let ir = + String::from_utf8(compile_module(&module, opts).expect("imported ctor fixture compiles")) + .unwrap(); + function_slice(&ir, &symbol).to_string() +} + +#[test] +fn imported_constructor_receiver_refreshes_after_initializers_and_call_preparation() { + crate::temp_root_coverage::under_both_lowerings(|mode| { + for walk_ancestor in [false, true] { + for (has_rest, has_arguments) in + [(false, false), (true, false), (false, true), (true, true)] + { + let packed = has_rest || has_arguments; + let ir = imported_constructor_ir(walk_ancestor, has_rest, has_arguments); + let blocks = blocks(&ir); + let (entry, _) = block(&blocks, "entry.", &ir); + let cfg = ColdCfg { + ir: &ir, + blocks: &blocks, + start: entry, + stop: "__end_of_function__", + }; + let ctor = "worker_producer_ts__WorkerMade_constructor"; + let calls = cfg.calls(ctor); + assert_eq!(calls.len(), 1, "{mode}: one imported ctor dispatch:\n{ir}"); + let call = calls[0]; + let operands = temp_slots::call_operands(call.text, ctor).unwrap(); + let (this_slot, read) = cfg.slot_read(&operands[0], call); + // Fixed arguments keep their existing root; packed arrays + // must each own a distinct expression root through dispatch. + let fixed_arg = (!packed).then(|| cfg.slot_read(&operands[1], call)); + let packed_reads: Vec<_> = if packed { + operands[1..] + .iter() + .map(|arg| cfg.root_read(arg, call)) + .collect() + } else { + Vec::new() + }; + assert_eq!( + packed_reads.len(), + usize::from(has_rest) + usize::from(has_arguments) + ); + if packed_reads.len() == 2 { + assert_ne!( + packed_reads[0].0, packed_reads[1].0, + "rest and arguments must retain separate arrays:\n{ir}" + ); + } + if mode == "native roots" { + assert!( + cfg.definition(this_slot) + .text + .contains("alloca ptr addrspace(1)"), + "constructor this-slot must be a native GC root:\n{ir}" + ); + } else { + assert!( + crate::testing::root_slots::bound_slots(&ir).contains_key(this_slot), + "constructor this-slot must be bound in the shadow frame:\n{ir}" + ); + } + let allocation = cfg + .sites() + .into_iter() + .find(|site| site.text.contains(" = call i64 @js_object_alloc_class_")) + .expect("fixture must allocate a class instance"); + let allocated = allocation.text.split_once(" = ").unwrap().0; + let publications: Vec<_> = cfg + .publications(this_slot) + .into_iter() + .filter(|site| { + registers(store_parts(site.text).unwrap().0) + .any(|value| derives_from(&ir, value, allocated, 16)) + }) + .collect(); + assert_eq!( + publications.len(), + 1, + "allocation must publish into this root:\n{ir}" + ); + let publication = publications[0]; + cfg.assert_before( + allocation, + publication, + "root publication follows allocation", + ); + cfg.assert_before(publication, call, "root publication dominates constructor"); + for helper in [ + "js_class_value", + "js_typed_feedback_class_field_set_guard", + "js_class_field_set_fallback", + "js_array_alloc", + ] { + let sites: Vec<_> = cfg + .sites() + .into_iter() + .filter(|site| site.text.contains(&format!("@{helper}("))) + .filter(|site| { + let path = ColdCfg { + start: site.label, + ..cfg + }; + path.reachable(site.label, None).contains(call.label) + }) + .collect(); + if helper != "js_array_alloc" || packed { + assert!( + !sites.is_empty(), + "{mode}: collecting subject {helper} must be live:\n{ir}" + ); + } + for site in sites { + cfg.assert_before( + publication, + site, + "this root precedes collecting preparation", + ); + let path = ColdCfg { + start: site.label, + ..cfg + }; + path.assert_before( + site, + read, + "receiver reread follows collecting preparation", + ); + if let Some((_, arg_read)) = fixed_arg { + path.assert_before( + site, + arg_read, + "fixed argument reread follows collecting preparation", + ); + } + } + } + for (slot, packed_read) in packed_reads { + let publications = cfg.publications(slot); + // The pool can reuse a released field-initializer root. + // Identify the array's publication by its allocation; + // earlier uses of the same slot are separate lifetimes. + let initial: Vec<_> = cfg + .sites() + .into_iter() + .filter(|site| site.text.contains(" = call i64 @js_array_alloc(")) + .flat_map(|producer| { + publications.iter().copied().filter_map(move |publication| { + registers(store_parts(publication.text).unwrap().0) + .any(|value| { + derives_from( + cfg.ir, + value, + producer.text.split_once(" = ").unwrap().0, + 16, + ) + }) + .then_some((producer, publication)) + }) + }) + .collect(); + assert_eq!(initial.len(), 1, + "{mode}: ancestor={walk_ancestor}, rest={has_rest}, arguments={has_arguments}: one initial packed-array publication in {slot}; publications={publications:?}\n{ir}"); + let (producer, first) = initial[0]; + for update in publications.iter().copied().filter(|site| { + cfg.dominates(producer, *site) + && !(site.label == first.label && site.index == first.index) + }) { + cfg.assert_before(first, update, "array publication dominates its updates"); + } + cfg.assert_before( + producer, + first, + "packed allocation precedes root publication", + ); + cfg.assert_before(first, call, "packed root survives through dispatch"); + for helper in ["js_array_alloc", "js_array_push_f64", "js_class_value"] { + for site in cfg.sites().into_iter().filter(|site| { + site.text.contains(&format!("@{helper}(")) + && cfg.dominates(producer, *site) + }) { + cfg.assert_before( + first, + site, + "packed root precedes subsequent collecting calls", + ); + let path = ColdCfg { + start: site.label, + ..cfg + }; + path.assert_before( + site, + packed_read, + "packed argument reread follows collecting preparation", + ); + } + } + let clears: Vec<_> = cfg + .sites() + .into_iter() + .filter(|site| { + is_clear(site.text) + && cfg.dominates(call, *site) + && store_parts(site.text).is_some_and(|(_, target)| target == slot) + }) + .collect(); + assert_eq!(clears.len(), 1, "packed root releases once:\n{ir}"); + cfg.assert_before( + call, + clears[0], + "packed root releases after constructor dispatch", + ); + } + // Existing field-store hot blocks keep their direct stores; + // this repair adds no root publication or reread in those arms. + for (label, body) in &blocks { + if label.starts_with("class_field_set.fast.") { + let stop = successors(body).next().expect("hot store must rejoin"); + assert_hot_has_no_temp_traffic(&ir, &blocks, label, stop); + } + } + } + } + }); +} diff --git a/crates/perry-codegen/src/expr/compare.rs b/crates/perry-codegen/src/expr/compare.rs index 40b9eef35f..2147e62591 100644 --- a/crates/perry-codegen/src/expr/compare.rs +++ b/crates/perry-codegen/src/expr/compare.rs @@ -1165,6 +1165,9 @@ fn lower_typeof_literal_inline( } pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { + if let Some(value) = crate::stmt::region_loop::try_lower_numeric_compare(ctx, expr, lower)? { + return Ok(value); + } match expr { Expr::Compare { op, left, right } => { // `typeof` always yields a string, so loose and strict equality diff --git a/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs b/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs index f442c9a9fb..bf93d877aa 100644 --- a/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs +++ b/crates/perry-codegen/src/expr/conforming_layout_note_tests.rs @@ -14,6 +14,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/expr/element_shape_guard.rs b/crates/perry-codegen/src/expr/element_shape_guard.rs index fa6f91a9c3..d2d4cad19b 100644 --- a/crates/perry-codegen/src/expr/element_shape_guard.rs +++ b/crates/perry-codegen/src/expr/element_shape_guard.rs @@ -164,8 +164,7 @@ pub(crate) struct ElementShapeGuardOutputs { /// Emit the once-per-loop element-shape guard into the current block chain. /// /// Leaves `ctx.current_block` on an UNTERMINATED block holding the accumulated -/// `i1` predicate, exactly like -/// [`super::class_field_inline_guard::emit_class_field_loop_preheader_check`]: +/// `i1` predicate: /// the caller lowers the fast clone, proves it call-free, and only then /// terminates with `cond_br(shape_ok, fast, slow)`. Never entering a clone /// whose call-freeness is unproven is the whole revocation argument. @@ -610,7 +609,8 @@ pub(crate) fn emit_element_deref_with_residual( // #8113: the ShapeId moved from header offset 8 to 4. let sid_ptr = blk.gep(I8, &elem_ptr, &[(I64, "4")]); let shape_id = blk.load(I32, &sid_ptr); - let shape_ok = blk.icmp_eq(I32, &shape_id, &fact.expected_shape_id); + let shape_ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, &fact.expected_shape_id, &[]); let ok = blk.and(I1, &hdr_ok, &shape_ok); // The side exit resumes the CURRENT iteration in the slow clone; no effect diff --git a/crates/perry-codegen/src/expr/index_get.rs b/crates/perry-codegen/src/expr/index_get.rs index c762f32c48..1849e5bed9 100644 --- a/crates/perry-codegen/src/expr/index_get.rs +++ b/crates/perry-codegen/src/expr/index_get.rs @@ -1064,6 +1064,10 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { versioned_handle, ctx.i32_counter_slots.get(index_id).cloned(), ) { + crate::expr::store_census::bump( + ctx, + crate::expr::store_census::ELEM_READ_VERSIONED_INDEXED, + ); let idx_i32 = ctx.block().load(I32, &index_slot); return Ok(guarded_array::lower_trusted_plain_array_index_get( ctx, diff --git a/crates/perry-codegen/src/expr/index_get/guarded_array.rs b/crates/perry-codegen/src/expr/index_get/guarded_array.rs index c4722c122b..b928c6e72a 100644 --- a/crates/perry-codegen/src/expr/index_get/guarded_array.rs +++ b/crates/perry-codegen/src/expr/index_get/guarded_array.rs @@ -734,19 +734,22 @@ pub(super) fn lower_guarded_array_index_get( } ctx.current_block = fast_idx; - crate::expr::store_census::bump(ctx, crate::expr::store_census::ELEM_READ_FAST); - let fast_blk = ctx.block(); let arr_handle = match (&inline_fast_handle, &runtime_fast_handle) { - (Some((inline_handle, inline_pred)), Some((runtime_handle, runtime_pred))) => fast_blk.phi( - I64, - &[ - (inline_handle.as_str(), inline_pred.as_str()), - (runtime_handle.as_str(), runtime_pred.as_str()), - ], - ), + (Some((inline_handle, inline_pred)), Some((runtime_handle, runtime_pred))) => { + ctx.block().phi( + I64, + &[ + (inline_handle.as_str(), inline_pred.as_str()), + (runtime_handle.as_str(), runtime_pred.as_str()), + ], + ) + } (Some((handle, _)), None) | (None, Some((handle, _))) => handle.clone(), (None, None) => unreachable!("guarded array fast block has no predecessor handle"), }; + // The handle PHI must precede the census load/add/store in this join. + crate::expr::store_census::bump(ctx, crate::expr::store_census::ELEM_READ_FAST); + let fast_blk = ctx.block(); let fast_val = if require_numeric_layout { // The guard on the way into this block (inline tier or the runtime // `numeric_array_index_get_guard`) already proved: a plain, diff --git a/crates/perry-codegen/src/expr/index_get_claim_tests.rs b/crates/perry-codegen/src/expr/index_get_claim_tests.rs index d65859528c..dcffdb5d9c 100644 --- a/crates/perry-codegen/src/expr/index_get_claim_tests.rs +++ b/crates/perry-codegen/src/expr/index_get_claim_tests.rs @@ -81,9 +81,8 @@ fn numeric_key_on_a_declared_array_keeps_the_guarded_array_tier() { ); } -#[test] -fn numeric_layout_oob_array_read_returns_undefined_inline() { - let ir = ir_for( +fn numeric_layout_oob_array_read_ir() -> String { + ir_for( "numeric_layout_oob_array_read", vec![ Stmt::Let { @@ -115,7 +114,12 @@ fn numeric_layout_oob_array_read_returns_undefined_inline() { }), }, ], - ); + ) +} + +#[test] +fn numeric_layout_oob_array_read_returns_undefined_inline() { + let ir = numeric_layout_oob_array_read_ir(); assert!( ir.contains("arr.guard.oob") && ir.contains("9222246136947933185"), "a numeric-layout OOB read must inline the undefined tag:\n{ir}" @@ -126,6 +130,58 @@ fn numeric_layout_oob_array_read_returns_undefined_inline() { ); } +#[test] +fn instrumented_numeric_array_read_keeps_handle_phi_first() { + // Census enablement is cached process-wide. Use a fresh test process so + // this test also exercises the instrument when the suite defaults to OFF. + const CHILD: &str = "NUMERIC_PHI_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let current = std::thread::current(); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([current.name().unwrap(), "--exact", "--test-threads=1"]) + .env(CHILD, "1") + .env("PERRY_STORE_CENSUS", "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "instrumented array read failed: {}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); + return; + } + assert!(super::store_census::enabled()); + let ir = numeric_layout_oob_array_read_ir(); + let mut in_fast = false; + let mut instructions = Vec::new(); + for line in ir.lines() { + if !line.starts_with(char::is_whitespace) && line.ends_with(':') { + if in_fast { + break; + } + in_fast = line.starts_with("arr.fast."); + } else if in_fast && !line.trim().is_empty() { + instructions.push(line.trim()); + } + } + assert!(!instructions.is_empty(), "numeric fast route absent:\n{ir}"); + assert!( + instructions[0].contains(" = phi i64 ") + && instructions[0].contains("%arr.guard.numeric_in_bounds.") + && instructions[0].contains("%arr.guard.cold."), + "the admitted inline/cold handles must merge before any ordinary instruction: {instructions:?}" + ); + assert!( + instructions + .iter() + .skip(1) + .any(|line| line.contains("@PERRY_STORE_CENSUS")), + "the fast route's census must actually be emitted: {instructions:?}" + ); +} + /// The ordered block labels of ONE dynamic element-read site, with the /// per-site numeric suffix stripped. fn dynamic_index_site_blocks(ir: &str) -> Vec { diff --git a/crates/perry-codegen/src/expr/method_site.rs b/crates/perry-codegen/src/expr/method_site.rs index 35b369b254..6539a1e1c4 100644 --- a/crates/perry-codegen/src/expr/method_site.rs +++ b/crates/perry-codegen/src/expr/method_site.rs @@ -91,6 +91,7 @@ pub(crate) fn emit_method_site( let spill_offset = crate::runtime_abi::OBJECT_META_SPILL_OFFSET.to_string(); let array_header = crate::runtime_abi::ARRAY_HEADER_SIZE.to_string(); let index_mask = crate::runtime_abi::METHOD_SITE_INDEX_MASK.to_string(); + let constfn_bit = crate::runtime_abi::METHOD_SITE_CONSTFN.to_string(); let entry_size = crate::runtime_abi::METHOD_SITE_ENTRY_SIZE; let header = crate::target_layout::object_header_size_bytes(ctx.target_triple) as i64; // `ClosureHeader` (64-bit only here): the info pointer, and the GcHeader @@ -112,6 +113,7 @@ pub(crate) fn emit_method_site( let kind_idx = ctx.new_block("msite.kind"); let own_idx = ctx.new_block("msite.own"); let own_fn_idx = ctx.new_block("msite.own_fn"); + let constfn_idx = ctx.new_block("msite.constfn"); let value_idx = ctx.new_block("msite.value"); let inh_idx = ctx.new_block("msite.inherited"); let inh_value_idx = ctx.new_block("msite.inherited_value"); @@ -122,6 +124,7 @@ pub(crate) fn emit_method_site( let kind_l = ctx.block_label(kind_idx); let own_l = ctx.block_label(own_idx); let own_fn_l = ctx.block_label(own_fn_idx); + let constfn_l = ctx.block_label(constfn_idx); let value_l = ctx.block_label(value_idx); let inh_l = ctx.block_label(inh_idx); let inh_value_l = ctx.block_label(inh_value_idx); @@ -209,8 +212,8 @@ pub(crate) fn emit_method_site( ctx.current_block = idx; } } - // kind: an own inline slot (top two bits clear), else inherited (bit 63) - // or an own spill slot (bit 62). + // kind: an own inline slot when bits 59..63 are clear; otherwise route + // inherited, spill, function-bag and ConstFn tags explicitly. ctx.current_block = kind_idx; let entry = { let incoming: Vec<(&str, &str)> = found @@ -222,6 +225,7 @@ pub(crate) fn emit_method_site( let other_idx = ctx.new_block("msite.other"); let other2_idx = ctx.new_block("msite.other2"); let other3_idx = ctx.new_block("msite.other3"); + let other4_idx = ctx.new_block("msite.other4"); let bag_idx = ctx.new_block("msite.fn_bag"); let bag2_idx = ctx.new_block("msite.fn_bag_load"); let spill_idx = ctx.new_block("msite.spill"); @@ -231,6 +235,7 @@ pub(crate) fn emit_method_site( let other_l = ctx.block_label(other_idx); let other2_l = ctx.block_label(other2_idx); let other3_l = ctx.block_label(other3_idx); + let other4_l = ctx.block_label(other4_idx); let bag_l = ctx.block_label(bag_idx); let bag2_l = ctx.block_label(bag2_idx); let spill_l = ctx.block_label(spill_idx); @@ -241,13 +246,13 @@ pub(crate) fn emit_method_site( let blk = ctx.block(); let sp = blk.gep(crate::types::I8, &entry, &[(I64, &abi_slot)]); let s = blk.load(I64, &sp); - let top = blk.lshr(I64, &s, "61"); + let top = blk.lshr(I64, &s, "59"); let tagged = blk.icmp_ne(I64, &top, "0"); blk.cond_br(&tagged, &other_l, &own_l); s }; - // other: inherited (bit 63), own spill (bit 62) or function bag (bit 61); - // any other kind bit is not one this site knows, and misses. + // other: inherited (bit 63), own spill (bit 62), function bag (bit 61) + // or ConstFn (bit 59). Bit 60 is reserved; unknown tags miss. ctx.current_block = other_idx; { let blk = ctx.block(); @@ -268,7 +273,16 @@ pub(crate) fn emit_method_site( let blk = ctx.block(); let bag_bit = blk.lshr(I64, &slot, "61"); let is_bag = blk.icmp_ne(I64, &bag_bit, "0"); - blk.cond_br(&is_bag, &bag_l, &miss_l); + blk.cond_br(&is_bag, &bag_l, &other4_l); + } + ctx.current_block = other4_idx; + { + let blk = ctx.block(); + // Admit exactly bit 59. Bit 60 is reserved and must never turn an + // unknown tagged entry into an unchecked ConstFn call. + let tag = blk.lshr(I64, &slot, "59"); + let is_constfn = blk.icmp_eq(I64, &tag, "1"); + blk.cond_br(&is_constfn, &own_l, &miss_l); } // function bag: the receiver's own-property object, then its inline slot. // The keyed Function ShapeId the word matched is canonical per that @@ -298,12 +312,29 @@ pub(crate) fn emit_method_site( let (inline_v, inline_end) = { let blk = ctx.block(); let base = emit_field_ptr(blk, &biased, header); - let vp = blk.gep(I64, &base, &[(I64, &slot)]); + let own_index = blk.and(I64, &slot, &index_mask); + let vp = blk.gep(I64, &base, &[(I64, &own_index)]); let v = blk.load(I64, &vp); let end = blk.label.clone(); - blk.br(&value_l); + let bit = blk.and(I64, &slot, &constfn_bit); + let is_constfn = blk.icmp_ne(I64, &bit, "0"); + blk.cond_br(&is_constfn, &constfn_l, &value_l); (v, end) }; + // ConstFn: the shape compare proves the current slot is a closure of this + // body's info. Load that closure for its captures; no heap-kind or info + // load occurs on this hit path. + ctx.current_block = constfn_idx; + let (constfn_handle, constfn_func, constfn_end) = { + let blk = ctx.block(); + let ub = blk.sub(I64, &inline_v, &(RECEIVER_BIAS as i64).to_string()); + let h = emit_handle(blk, &ub); + let fp = blk.gep(crate::types::I8, &entry, &[(I64, &abi_code)]); + let f = blk.load(I64, &fp); + let end = blk.label.clone(); + blk.br(&call_l); + (h, f, end) + }; // own spill: meta -> spill buffer -> element, bounds-checked. ctx.current_block = spill_idx; let meta = { @@ -390,7 +421,7 @@ pub(crate) fn emit_method_site( u }; ctx.current_block = own_fn_idx; - let (own_handle, own_func, _own_end) = { + let (own_handle, own_func, own_end) = { let blk = ctx.block(); let kp = emit_field_ptr(blk, &own_ub, kind_offset); let kind = blk.load(crate::types::I16, &kp); @@ -443,7 +474,14 @@ pub(crate) fn emit_method_site( }; // call: the body directly, with the receiver as its `this` parameter. ctx.current_block = call_idx; - let fptr = ctx.block().inttoptr(I64, &own_func); + let handle = ctx.block().phi( + I64, + &[(&own_handle, &own_end), (&constfn_handle, &constfn_end)], + ); + let func = ctx + .block() + .phi(I64, &[(&own_func, &own_end), (&constfn_func, &constfn_end)]); + let fptr = ctx.block().inttoptr(I64, &func); let mut call_args: Vec = lowered_args.to_vec(); // Pad with `undefined` up to the arity the prime admits, so a body that // declares a few more parameters than this call passes is entered @@ -457,7 +495,7 @@ pub(crate) fn emit_method_site( let hit_value = crate::expr::body_call::emit_js_body_call( ctx.block(), crate::expr::body_call::JsBody::Pointer(&fptr), - &own_handle, + &handle, &recv_bits, &call_args, ); diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index 8a1f82b929..af0a51c247 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -276,7 +276,7 @@ pub(crate) use slot_rep::{ deny_canonical_context, deny_canonical_i32, load_canonical_local_boxed, local_is_canonical_str, local_rep_is_canonical_i32, note_canonical_local, ptr_shape_context_rule_text, store_canonical_local_from_double, CanonicalI32Denial, SlotRep, PTR_SHAPE_NO_ACCESS_SITE, - PTR_SHAPE_SCALAR_REPLACED, + PTR_SHAPE_REGION_AUTHORITY, PTR_SHAPE_SCALAR_REPLACED, }; pub(crate) use dispatch::{lower_expr, lower_math_operand}; @@ -1089,19 +1089,6 @@ pub(crate) struct FnCtx<'a> { /// removed the moment the local leaves that scope. pub suppressed_cleared_shadow_slots: std::collections::HashSet, - /// #5093: scoped loop-versioning facts for monomorphic class-field loops. - /// Pushed only around the FAST clone of `lower_class_field_versioned_for` - /// (`stmt/loops.rs`): the loop preheader already proved the receiver's - /// exact class shape (class_id, keys identity, field_count, typed-layout - /// intact bit, not-frozen, inline-guard enable flag), and the matcher - /// proved the fast body is call-free (no allocation ⇒ no GC ⇒ the cached - /// `obj_ptr` cannot move and the shape cannot change mid-loop). Inside - /// that clone, `recv.field` GET/SET on a tracked raw-f64 field lowers to - /// a bare GEP+load/store on `obj_ptr` with no guard and no fallback call; - /// SET keeps an inline plain-finite-number check that side-exits to the - /// slow clone's preheader BEFORE committing any side effect of the - /// current iteration. - pub class_field_loop_facts: Vec, /// Step 4b (#10884): loop / body regions whose body is not lowered yet /// (`stmt::region_loop`), and the facts active while an F-body lowers. pub region_loops: Vec, @@ -2154,30 +2141,6 @@ pub(crate) struct StringWindowArrayFact { pub max_idx_exclusive: i64, } -/// #5093: one fact per (receiver, versioned loop). See -/// `FnCtx::class_field_loop_facts` for the safety argument. -#[derive(Debug, Clone)] -pub(crate) struct ClassFieldLoopFact { - /// LocalId of the loop-invariant receiver (plain local or module global). - pub recv_local_id: u32, - pub scope_id: u32, - /// Class the preheader check proved exactly (by class_id compare). - pub class_name: String, - /// SSA name of the receiver object pointer, `inttoptr`'d in the - /// preheader's deref block. Dominates every block of the fast clone and - /// is stable for the clone's whole lifetime because the fast body is - /// call-free (no allocation ⇒ no GC ⇒ no evacuation). - pub obj_ptr: String, - /// Slow clone's preheader label. A raw-f64 store whose value fails the - /// inline plain-finite check branches here; the slow clone re-executes - /// the current iteration from scratch (no side effect has committed yet). - pub side_exit_label: String, - /// property name -> packed slot index. Every entry is a declared raw-f64 - /// candidate field validated by the matcher via - /// `class_field_global_index` / `class_field_declared_type`. - pub fields: std::collections::BTreeMap, -} - /// #10123: where the fast clone's element index comes from. /// /// The class-keyed arm admits [`Self::Counter`] only — the preheader's @@ -2501,23 +2464,6 @@ pub(crate) fn element_shape_loop_fact_for_property_get<'f>( } } -/// Find the innermost active class-field loop fact covering -/// `(recv_local_id, class_name, property)`. Returns the fact and the packed -/// slot index of the field. -pub(crate) fn class_field_loop_fact_lookup<'f>( - facts: &'f [ClassFieldLoopFact], - recv_local_id: u32, - class_name: &str, - property: &str, -) -> Option<(&'f ClassFieldLoopFact, u32)> { - facts.iter().rev().find_map(|fact| { - if fact.recv_local_id != recv_local_id || fact.class_name != class_name { - return None; - } - fact.fields.get(property).map(|idx| (fact, *idx)) - }) -} - /// Build a linker-unique inline-cache global name. /// /// `ic_site_counter` is only module-wide. LLVM codegen-unit splitting can @@ -2894,8 +2840,9 @@ impl<'a> FnCtx<'a> { } /// The `Ptr` fact for `e` ignoring the context gate — the proof the - /// analysis actually produced, as opposed to the proof codegen is allowed - /// to act on. Report-only. + /// analysis actually produced, as opposed to permission for an unguarded + /// access. Used for reporting and for class provenance in a separately + /// shape-guarded region; never grants native-slot or numeric permission. fn ptr_shape_fact_ignoring_context( &self, e: &perry_hir::Expr, @@ -2907,6 +2854,20 @@ impl<'a> FnCtx<'a> { } } + /// Class provenance for a region's static supplier, never a license for + /// raw access. The supplier must validate the live ShapeId and obtain all + /// offsets/representations from that shape. Unlike the unguarded accessor, + /// this route is valid while region lowering owns representation authority. + /// Other unguarded-context denials do not invalidate a class hint either: + /// region eligibility still rejects unsupported bindings, and its guarded + /// loads re-read tagged roots. No native-slot permission is inherited here. + /// The collection OFF knob removes the fact itself; this accessor cannot + /// recreate it from a type annotation. + pub(crate) fn ptr_shape_region_class(&self, e: &perry_hir::Expr) -> Option { + self.ptr_shape_fact_ignoring_context(e) + .map(|fact| fact.class_name.clone()) + } + /// Record that a selected `Ptr` proof was dropped by the context /// gate (`repsel_context_allows_ptr_shape == false`). /// @@ -2943,7 +2904,7 @@ impl<'a> FnCtx<'a> { tier: crate::opt_report::Tier::CompilerLimitation, issue: Some(issue), detail: Some(format!( - "proven Ptr of class {}; every access site keeps the guard diamond", + "proven Ptr of class {}; this access cannot use the unguarded receiver route", fact.class_name )), }); @@ -2952,7 +2913,10 @@ impl<'a> FnCtx<'a> { /// Record that codegen COMMITTED to a `Ptr` lowering for `e`. /// /// Call from the taken branch of a site that has already decided to emit - /// the guard-free form — never from the accessor, which answers `Some` at + /// the guard-free form, or from an emitted region access whose static + /// supplier was selected using this fact's class provenance. The region's + /// ShapeId guard still owns its slot/representation authority. Never call + /// from the accessor, which answers `Some` at /// sites that then reject the fact on a class or numeric-field mismatch and /// emit the guarded diamond anyway. pub(crate) fn note_ptr_shape_consumed(&self, e: &perry_hir::Expr, site: &'static str) { diff --git a/crates/perry-codegen/src/expr/object_literal.rs b/crates/perry-codegen/src/expr/object_literal.rs index 66aa693eaf..dc9849f1e1 100644 --- a/crates/perry-codegen/src/expr/object_literal.rs +++ b/crates/perry-codegen/src/expr/object_literal.rs @@ -387,7 +387,9 @@ pub(crate) fn lower_object_literal( ); } } - Ok(nanbox_pointer_inline(ctx.block(), obj_handle)) + let final_handle = + crate::codegen::static_constfn::finalize_literal(ctx, props, 0, obj_handle); + Ok(nanbox_pointer_inline(ctx.block(), &final_handle)) }, ); } diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index c0f4ffaf45..41645ac524 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1491,24 +1491,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ctx.class_ids.get(&class_name), ctx.class_keys_globals.get(&class_name).cloned(), ) { - // #5093 loop versioning: inside the fast clone of a - // class-field versioned loop, a tracked field read on - // the proven receiver lowers to a bare slot load on - // the preheader-cached object pointer — no shape - // check, no guard call, no fallback (the preheader - // proved the shape once and the call-free clone keeps - // it true; see stmt/loops.rs). - let loop_fact_ptr = match object.as_ref() { - Expr::LocalGet(recv_id) => crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - .map(|(fact, _)| fact.obj_ptr.clone()), - _ => None, - }; // Representation-selection Phase 3b: shape-proven // Ptr local (collectors/ptr_shape.rs). The // guard diamond is statically proven away — emit the @@ -1591,54 +1573,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { ); return Ok(val); } - if let Some(obj_ptr) = loop_fact_ptr { - let field_idx_str = field_index.to_string(); - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple) - .to_string(); - let blk = ctx.block(); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - let val = blk.load(DOUBLE, &field_ptr); - let fast = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: val.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldGet", - None, - "class_field_get.loop_raw_f64_load", - &fast, - Some(BoundsState::Guarded { - guard_id: "class_field_loop_preheader_check".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_loop_preheader_check", - None, - )], - Vec::new(), - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=loop_preheader_shape_check".to_string(), - "field_layout=raw_f64_slot_array".to_string(), - "loop_versioning=class_field_fast_clone".to_string(), - ], - ); - return Ok(val); - } let requires_raw_f64 = crate::expr::class_field_inline_guard::class_field_site_raw_f64( ctx, diff --git a/crates/perry-codegen/src/expr/property_get/front_contract_tests.rs b/crates/perry-codegen/src/expr/property_get/front_contract_tests.rs new file mode 100644 index 0000000000..9cf4706637 --- /dev/null +++ b/crates/perry-codegen/src/expr/property_get/front_contract_tests.rs @@ -0,0 +1,365 @@ +//! The miss front includes target-specific directory lookup blocks. Follow +//! their CFG and the call operand rather than assuming a single block or ELF. + +use super::{tower_block, tower_blocks, tower_cond_br}; +type Blocks = [(String, Vec)]; + +fn targets(body: &[String]) -> Vec<&str> { + body.last() + .into_iter() + .flat_map(|line| line.split("label %").skip(1)) + .map(|label| label.trim_end_matches([',', ' '])) + .collect() +} + +fn predecessors<'a>(blocks: &'a Blocks, label: &str) -> Vec<&'a str> { + blocks + .iter() + .filter(|(_, body)| targets(body).contains(&label)) + .map(|(name, _)| name.as_str()) + .collect() +} + +fn verify_front_flow(blocks: &Blocks) -> Result { + let calls: Vec<_> = blocks + .iter() + .enumerate() + .filter(|(_, (_, body))| { + body.iter() + .any(|line| line.contains("call double @js_object_get_field_ic_front(")) + }) + .collect(); + let [(index, (call_label, call_body))] = calls.as_slice() else { + return Err(format!("expected exactly one front call: {calls:?}")); + }; + let (entry, _) = tower_block(blocks, "pic.miss.front"); + let (token, token_body) = tower_block(blocks, "pic.token"); + if predecessors(blocks, entry) != [token] || tower_cond_br(token_body).2 != entry { + return Err("the front entry must be dominated by the token compare".into()); + } + // Every branch between the token miss and the front call resolves the + // directory; both lookup failures still call the front using the empty + // directory. No path may escape to a collecting exit or bypass the call. + if call_label != entry { + let (tsd, _) = tower_block(blocks, "agent_ptr.hot_tls.tsd"); + let (fast, _) = tower_block(blocks, "agent_ptr.hot_tls.fast"); + let (slow, _) = tower_block(blocks, "agent_ptr.hot_tls.slow"); + let (join, _) = tower_block(blocks, "agent_ptr.join"); + if call_label != join { + return Err(format!( + "the directory lookup must join at the front call: {call_label}" + )); + } + for (label, expected_targets, expected_preds) in [ + (entry, vec![tsd, slow], vec![token]), + (tsd, vec![fast, slow], vec![entry]), + (fast, vec![join], vec![tsd]), + (slow, vec![join], vec![entry, tsd]), + ] { + let body = &blocks.iter().find(|(l, _)| l == label).unwrap().1; + if targets(body) != expected_targets || predecessors(blocks, label) != expected_preds { + return Err(format!("directory CFG changed at {label}: {body:?}")); + } + if body + .iter() + .any(|line| line.contains("@js_object_get_field")) + { + return Err(format!( + "directory lookup must not call a property exit: {body:?}" + )); + } + } + let lookup_blocks: Vec<_> = blocks + .iter() + .filter(|(l, _)| l.starts_with("agent_ptr.")) + .map(|(l, _)| l.as_str()) + .collect(); + if lookup_blocks != [tsd, fast, slow, join] { + return Err(format!( + "directory lookup must keep exactly four blocks: {lookup_blocks:?}" + )); + } + if predecessors(blocks, join) != [fast, slow] { + return Err( + "the front call must have only the two directory lookup predecessors".into(), + ); + } + } + if !call_body + .last() + .is_some_and(|line| line.starts_with("br i1 %")) + { + return Err("front decline must use a live conditional branch".into()); + } + let (cond, served, declined) = tower_cond_br(call_body); + let call = call_body + .iter() + .find(|line| line.contains("call double @js_object_get_field_ic_front(")) + .unwrap(); + let answer = call.split_once(" = ").unwrap().0; + let bits = call_body + .iter() + .find_map(|line| { + let (reg, rhs) = line.split_once(" = ")?; + (rhs == format!("bitcast double {answer} to i64")).then_some(reg) + }) + .ok_or("the decline must classify the front's own answer")?; + if !call_body.iter().any(|line| { + line == &format!( + "{cond} = icmp ne i64 {bits}, {}", + crate::nanbox::TAG_HOLE_I64 + ) + }) || !served.starts_with("pget.recv_merge.") + || !declined.starts_with("pic.miss.call.") + { + return Err(format!( + "the front must branch on its answer's TAG_HOLE decline: {call_body:?}" + )); + } + let (_, merge) = tower_block(blocks, "pget.recv_merge"); + if !merge.iter().any(|line| { + line.contains(" = phi double ") && line.contains(&format!("[ {answer}, %{call_label} ]")) + }) { + return Err( + "the merge must take the served answer from the actual front call block".into(), + ); + } + if predecessors(blocks, &declined) + .iter() + .any(|p| *p != call_label && !p.starts_with("pget.recv_")) + { + return Err( + "the slow call is reached only from the front decline or receiver failure".into(), + ); + } + Ok(*index) +} + +pub(super) fn front_call_block(blocks: &Blocks) -> (&str, &[String]) { + let index = verify_front_flow(blocks).unwrap_or_else(|e| panic!("{e}: {blocks:?}")); + (&blocks[index].0, &blocks[index].1) +} + +fn def<'a>(blocks: &'a Blocks, reg: &str) -> Result<&'a str, String> { + blocks + .iter() + .flat_map(|(_, body)| body) + .find_map(|line| { + let (lhs, rhs) = line.split_once(" = ")?; + (lhs == reg).then_some(rhs) + }) + .ok_or_else(|| format!("no definition of {reg} in the tower function")) +} + +/// Prove the front's first operand is the directory read, including Apple's +/// guarded phi and Windows' TEB-derived block. Nearby unrelated TLS text is +/// insufficient: every step must define the operand passed to the call. +pub(super) fn verify_front_directory(blocks: &Blocks) -> Result<(), String> { + let index = verify_front_flow(blocks)?; + let call = blocks[index] + .1 + .iter() + .find(|line| line.contains("call double @js_object_get_field_ic_front(")) + .unwrap(); + let dir = call + .split_once("(ptr ") + .unwrap() + .1 + .split(',') + .next() + .unwrap(); + let mut value = dir; + let rhs = def(blocks, value)?; + if let Some(phi) = rhs.strip_prefix("phi ptr [ ") { + let (fast_value, rest) = phi.split_once(", %").ok_or("directory phi's fast value")?; + let (fast, slow) = rest + .split_once(" ], [ ") + .ok_or("directory phi's two incoming edges")?; + let (slow_label, _) = tower_block(blocks, "agent_ptr.hot_tls.slow"); + let (fast_label, _) = tower_block(blocks, "agent_ptr.hot_tls.fast"); + if fast != fast_label || slow != format!("@PERRY_EMPTY_SHAPE_DIR, %{slow_label} ]") { + return Err(format!( + "directory phi must select the slot or empty fallback: {rhs}" + )); + } + value = fast_value; + } + let rhs = def(blocks, value)?; + if rhs == "call ptr @perry_shape_dir_cell()" { + return Ok(()); + } + let slot = rhs + .strip_prefix("load ptr, ptr ") + .ok_or("directory must be a pointer load")?; + let at = def(blocks, slot)?; + let block = at + .strip_prefix("getelementptr i8, ptr ") + .and_then(|s| s.strip_suffix(", i64 0")) + .ok_or_else(|| format!("directory must come from agent pointer slot zero: {at}"))?; + if block == "@PERRY_AGENT_PTRS" { + return Ok(()); + } + let rhs = def(blocks, block)?; + if let Some(field) = rhs.strip_prefix("load ptr, ptr ") { + let field = def(blocks, field)?; + if !field.starts_with("getelementptr i8, ptr %") + || !field.ends_with(&format!( + ", i64 {}", + crate::runtime_abi::HOT_TLS_AGENT_PTRS_OFFSET + )) + { + return Err(format!( + "Apple directory must use HotTls.agent_ptrs: {field}" + )); + } + let hot = field + .strip_prefix("getelementptr i8, ptr ") + .unwrap() + .split(',') + .next() + .unwrap(); + let slot = def(blocks, hot)? + .strip_prefix("load ptr, ptr ") + .ok_or("Apple TSD slot load")?; + let addr = def(blocks, slot)? + .strip_prefix("inttoptr i64 ") + .and_then(|s| s.strip_suffix(" to ptr")) + .ok_or("Apple TSD slot address")?; + let (base, offset) = def(blocks, addr)? + .strip_prefix("add i64 ") + .and_then(|s| s.split_once(", ")) + .ok_or("Apple TSD index")?; + let tsd = def(blocks, base)? + .strip_prefix("and i64 ") + .and_then(|s| s.strip_suffix(", -8")) + .ok_or("Apple TSD base mask")?; + if def(blocks, tsd)? != "call i64 asm sideeffect \"mrs $0, tpidrro_el0\", \"=r\"()" { + return Err("Apple directory must derive from the current thread pointer".into()); + } + let key = def(blocks, offset)? + .strip_prefix("shl i64 ") + .and_then(|s| s.strip_suffix(", 3")) + .ok_or("Apple TSD key offset")?; + if def(blocks, key)? != "load atomic i64, ptr @PERRY_HOT_TSD_KEY monotonic, align 8" { + return Err("Apple directory must use the published TSD key".into()); + } + for (prefix, predicate) in [ + ("pic.miss.front", format!("icmp ne i64 {key}, -1")), + ("agent_ptr.hot_tls.tsd", format!("icmp ne ptr {hot}, null")), + ] { + let (_, body) = tower_block(blocks, prefix); + if !body.last().is_some_and(|l| l.starts_with("br i1 %")) { + return Err(format!( + "Apple lookup must consult its live {prefix} predicate" + )); + } + let cond = tower_cond_br(body).0; + if def(blocks, &cond)? != predicate { + return Err(format!("wrong Apple lookup guard in {prefix}")); + } + } + return Ok(()); + } + // Windows: TLS array -> image's indexed TLS block -> SECREL offset. + let (image, offset) = rhs + .strip_prefix("getelementptr i8, ptr ") + .and_then(|s| s.split_once(", i64 ")) + .ok_or("Windows agent block address")?; + let offset = def(blocks, offset)? + .strip_prefix("zext i32 ") + .and_then(|s| s.strip_suffix(" to i64")) + .ok_or("SECREL extension")?; + if def(blocks, offset)? != "load i32, ptr @PERRY_AGENT_PTRS_SECREL" { + return Err("wrong Windows SECREL".into()); + } + let entry = def(blocks, image)? + .strip_prefix("load ptr, ptr ") + .ok_or("Windows image TLS block load")?; + let (array, index) = def(blocks, entry)? + .strip_prefix("getelementptr ptr, ptr ") + .and_then(|s| s.split_once(", i64 ")) + .ok_or("Windows TLS image entry")?; + let index = def(blocks, index)? + .strip_prefix("zext i32 ") + .and_then(|s| s.strip_suffix(" to i64")) + .ok_or("TLS index extension")?; + if def(blocks, index)? != "load i32, ptr @_tls_index" + || def(blocks, array)? + != "load ptr, ptr addrspace(256) inttoptr (i64 88 to ptr addrspace(256)), align 8" + { + return Err( + "Windows directory must derive from this thread's TEB and image TLS index".into(), + ); + } + Ok(()) +} + +#[test] +fn front_contract_rejects_lookup_bypasses_wrong_slots_and_wrong_declines() { + for target in [ + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + ] { + let mut opts = super::ir_opts(false, None); + opts.target = Some(target.into()); + let ir = String::from_utf8( + crate::compile_module(&super::module_with_nullish_read(), opts).unwrap(), + ) + .unwrap(); + let blocks = tower_blocks(&ir); + let index = verify_front_flow(&blocks).unwrap(); + verify_front_directory(&blocks).unwrap(); + let mut wrong = blocks.clone(); + let cond = tower_cond_br(&wrong[index].1).0; + let term = wrong[index].1.last_mut().unwrap(); + *term = term.replacen(&cond, "true", 1); + assert!( + verify_front_flow(&wrong).is_err(), + "{target}: hardwired decline" + ); + let mut wrong = blocks.clone(); + for (_, body) in &mut wrong { + for line in body { + if line.contains("getelementptr i8, ptr ") && line.ends_with(", i64 0") { + *line = line.strip_suffix(", i64 0").unwrap().to_string() + ", i64 8"; + } + } + } + assert_ne!(wrong, blocks, "{target}: slot sabotage must alter IR"); + assert!( + verify_front_directory(&wrong).is_err(), + "{target}: wrong agent slot" + ); + let mut wrong = blocks.clone(); + let call = wrong[index] + .1 + .iter_mut() + .find(|l| l.contains("call double @js_object_get_field_ic_front(")) + .unwrap(); + let operand = call + .split_once("(ptr ") + .unwrap() + .1 + .split(',') + .next() + .unwrap() + .to_string(); + *call = call.replacen( + &format!("(ptr {operand},"), + "(ptr @PERRY_EMPTY_SHAPE_DIR,", + 1, + ); + assert_ne!(wrong, blocks, "{target}: operand sabotage must alter IR"); + assert!( + verify_front_directory(&wrong).is_err(), + "{target}: disconnected directory operand" + ); + let mut wrong = blocks.clone(); + let (entry, _) = tower_block(&blocks, "pic.miss.front"); + let (_, _, slow) = tower_cond_br(&blocks[index].1); + let body = &mut wrong.iter_mut().find(|(l, _)| l == entry).unwrap().1; + *body.last_mut().unwrap() = format!("br label %{slow}"); + assert!(verify_front_flow(&wrong).is_err(), "{target}: front bypass"); + } +} diff --git a/crates/perry-codegen/src/expr/property_get/helpers.rs b/crates/perry-codegen/src/expr/property_get/helpers.rs index eee5b2514c..b0638acc95 100644 --- a/crates/perry-codegen/src/expr/property_get/helpers.rs +++ b/crates/perry-codegen/src/expr/property_get/helpers.rs @@ -199,6 +199,17 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( return Ok(None); }; + // A typed class read can enter this helper before property_get::lower. + // Consume the same exact fresh R fact there, rather than emitting an + // ordinary class guard inside F (whose fallback can run JS and retire F). + // Without R, the bare word may contain a box: number context must keep + // the existing guarded/coercing path below. + if crate::stmt::region_loop::is_f64_read(ctx, expr) { + if let Some(value) = crate::stmt::region_loop::try_lower_bare_get(ctx, expr)? { + return Ok(Some(value)); + } + } + // Scalar-replaced objects do not have a valid heap receiver. The general // property-get lowering handles this, but native-f64 numeric contexts query // raw class-field lowering first. Keep allocation-elided objects on their @@ -488,70 +499,6 @@ pub(crate) fn lower_raw_f64_class_field_get_for_number_context( return Ok(None); }; - // #5093 loop versioning: inside the fast clone of a class-field versioned - // loop, a tracked number-context field read on the proven receiver lowers - // to a bare slot load on the preheader-cached object pointer — no shape - // check, no guard call, no fallback (see stmt/loops.rs). Mirrors the hook - // in the generic class-field GET diamond (property_get.rs). - let loop_fact_ptr = match object.as_ref() { - Expr::LocalGet(recv_id) => crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - .map(|(fact, _)| fact.obj_ptr.clone()), - _ => None, - }; - if let Some(obj_ptr) = loop_fact_ptr { - let field_idx_str = field_index.to_string(); - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple).to_string(); - let blk = ctx.block(); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]); - let val = blk.load(DOUBLE, &field_ptr); - let fast = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: val.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldGet", - None, - "class_field_get_number.loop_raw_f64_load", - &fast, - Some(BoundsState::Guarded { - guard_id: "class_field_loop_preheader_check".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_loop_preheader_check", - None, - )], - Vec::new(), - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=loop_preheader_shape_check".to_string(), - "field_layout=raw_f64_slot_array".to_string(), - "loop_versioning=class_field_fast_clone".to_string(), - ], - ); - return Ok(Some(val)); - } - // Representation-selection Phase 3b: shape-proven Ptr receiver // whose field is numeric-proven (every reachable store is a number) — // bare fixed-offset load, no guard diamond. The numeric proof is what diff --git a/crates/perry-codegen/src/expr/property_get/tests.rs b/crates/perry-codegen/src/expr/property_get/tests.rs index a17cae655e..6f5c9520c6 100644 --- a/crates/perry-codegen/src/expr/property_get/tests.rs +++ b/crates/perry-codegen/src/expr/property_get/tests.rs @@ -14,6 +14,10 @@ use crate::{compile_module, AppMetadata, CompileOptions}; use perry_hir::{Expr, Module, ModuleInitKind, Stmt}; +#[path = "front_contract_tests.rs"] +mod front_contract; +use front_contract::{front_call_block, verify_front_directory}; + fn ir_opts(debug_locations: bool, module_source: Option<&str>) -> CompileOptions { CompileOptions { static_shape_ids: Vec::new(), @@ -21,6 +25,7 @@ fn ir_opts(debug_locations: bool, module_source: Option<&str>) -> CompileOptions target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), @@ -570,7 +575,7 @@ fn generic_property_get_tries_ways_before_calling_the_miss_handler() { on_miss.starts_with("pic.miss.front"), "the compare's miss edge must reach the front (the ways) first: {token:?}" ); - let (front_label, front) = tower_block(&blocks, "pic.miss.front"); + let (front_label, front) = front_call_block(&blocks); assert!( front .iter() @@ -707,7 +712,7 @@ fn a_spill_entry_is_served_by_the_leaf_front_before_the_slow_call() { use crate::expr::property_get::generic_dispatch::PACKED_SPILL_FLIP; let ir = emit(false, None); let blocks = tower_blocks(&ir); - let (front_label, front) = tower_block(&blocks, "pic.miss.front"); + let (front_label, front) = front_call_block(&blocks); let call = front .iter() .find(|l| l.contains("@js_object_get_field_ic_front(")) @@ -1176,11 +1181,9 @@ fn generic_property_get_slot_load_is_reached_only_through_every_guard() { !blocks.iter().any(|(l, _)| l.starts_with("pic.way")), "no way block may be expanded per site:\n{func}" ); - let front_body = blocks - .iter() - .find(|(l, _)| l.starts_with("pic.miss.front")) - .map(|(_, body)| body.join("\n")) - .expect("the miss front block"); + let front_blocks = tower_blocks(&ir); + let (_, front) = front_call_block(&front_blocks); + let front_body = front.join("\n"); let term = front_body .lines() .rev() @@ -1505,6 +1508,9 @@ fn the_front_reads_its_directory_without_a_call_where_the_target_allows() { .split("\ndefine ") .find(|f| f.contains("\npic.miss.front")) .unwrap_or_else(|| panic!("{target}: no function contains the front:\n{ir}")); + let blocks = tower_blocks(&ir); + front_call_block(&blocks); + verify_front_directory(&blocks).unwrap_or_else(|e| panic!("{target}: {e}\n{func}")); let dir_call = func.contains("call ptr @perry_shape_dir_cell("); match inline_form { Some(form) => { @@ -1637,18 +1643,15 @@ fn the_generic_tower_is_one_leaf_call_two_exits_and_a_bounded_number_of_blocks() fronts[0].contains(" = call double "), "the front is nounwind, a plain call:\n{func}" ); - // A non-`length` site confirms from this agent's own directory: the dir - // operand is slot 0 of `PERRY_AGENT_PTRS` (one initial-exec load in this - // ELF executable), never the empty directory a `length` site passes. + // A non-`length` site confirms from this agent's own directory: slot 0 + // of the target's per-agent block, or the empty directory when Apple's + // direct TLS lookup is unavailable. Follow the actual call operand. assert!( !fronts[0].contains("@PERRY_EMPTY_SHAPE_DIR"), "only a `length` site passes the empty directory:\n{}", fronts[0] ); - assert!( - func.contains("getelementptr i8, ptr @PERRY_AGENT_PTRS, i64 0"), - "the dir operand is PERRY_AGENT_PTRS slot 0:\n{func}" - ); + verify_front_directory(&tower_blocks(&ir)).unwrap_or_else(|e| panic!("{e}\n{func}")); let blocks: Vec<&str> = func .lines() @@ -1769,7 +1772,7 @@ fn the_generic_slow_read_is_called_only_after_the_front_declines() { slot and the packed word:\n{slow_line}" ); // 3. - let (_, front) = tower_block(&blocks, "pic.miss.front"); + let (_, front) = front_call_block(&blocks); let (cond, served, declined) = tower_cond_br(front); assert!( front diff --git a/crates/perry-codegen/src/expr/property_set.rs b/crates/perry-codegen/src/expr/property_set.rs index 4afc4ea904..aa8f7a455f 100644 --- a/crates/perry-codegen/src/expr/property_set.rs +++ b/crates/perry-codegen/src/expr/property_set.rs @@ -986,126 +986,6 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - field_index, ); let requires_raw_f64_str = if requires_raw_f64 { "1" } else { "0" }; - // #5093 loop versioning: inside the fast clone of a - // class-field versioned loop, a tracked raw-f64 field - // store on the proven receiver lowers to an inline - // plain-finite value check + bare slot store on the - // preheader-cached object pointer. A value that is - // not a plain finite double (±Inf/NaN, or any NaN-box - // tag — including INT32-boxed integers) side-exits to - // the slow clone's preheader BEFORE the store, so the - // slow clone re-executes the whole iteration and - // routes the value through the runtime guard exactly - // as today (downgrade semantics preserved). - if requires_raw_f64 { - let loop_fact = - match object.as_ref() { - Expr::LocalGet(recv_id) => { - crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - .map(|(fact, _)| { - ( - fact.obj_ptr.clone(), - fact.side_exit_label.clone(), - ) - }) - } - _ => None, - }; - if let Some((obj_ptr, side_exit_label)) = loop_fact { - let field_idx_str = field_index.to_string(); - let store_idx = - ctx.new_block("class_field_loop_store.fast"); - let store_label = ctx.block_label(store_idx); - { - let blk = ctx.block(); - let val_bits = blk.bitcast_double_to_i64(&val_double); - let finite = crate::expr::class_field_inline_guard:: - emit_plain_finite_number_check(blk, &val_bits); - blk.cond_br(&finite, &store_label, &side_exit_label); - } - ctx.current_block = store_idx; - super::store_census::bump( - ctx, - super::store_census::CFIELD_LOOP_RAW, - ); - { - let header_skip = - crate::target_layout::object_header_size_bytes( - ctx.target_triple, - ) - .to_string(); - let blk = ctx.block(); - let fields_base = - blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = blk.gep( - DOUBLE, - &fields_base, - &[(I64, &field_idx_str)], - ); - // No raw-f64 canonicalization call is needed: - // INT32-boxed and NaN values — the only - // inputs `js_array_numeric_value_to_raw_f64` - // rewrites — cannot pass the finite check. - // - // GC_STORE_AUDIT(POINTER_FREE): the inline - // finite check proved `val_double` is a - // genuine (unboxed, finite) double, never a - // heap pointer — no edge, no write barrier. - blk.store(DOUBLE, &val_double, &field_ptr); - } - let stored = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: val_double.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "class_field_set.loop_raw_f64_store", - &stored, - Some(BoundsState::Guarded { - guard_id: "class_field_loop_preheader_check" - .to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_loop_preheader_check", - None, - )], - Vec::new(), - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), - "receiver_proof=loop_preheader_shape_check" - .to_string(), - "field_layout=raw_f64_slot_array".to_string(), - "loop_versioning=class_field_fast_clone" - .to_string(), - "rhs_numeric_guard=inline_plain_finite_check" - .to_string(), - "store_guard_failure=side_exit_slow_restart" - .to_string(), - ], - ); - return Ok(val_double); - } - } // Representation-selection Phase 3b: shape-proven // Ptr receiver (collectors/ptr_shape.rs) — no // guard call, no shape diamond. Raw-f64 slots keep the @@ -1127,6 +1007,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - .ptr_shape_receiver_fact(object.as_ref()) .map(|fact| fact.class_name == class_name) .unwrap_or(false); + // A contained offset proof may carry completed + // ConstFn facts. Writing that boxed slot must + // deprecate/restamp through the checked funnel. + let ptr_shape_proven = ptr_shape_proven + && (requires_raw_f64 + || !crate::codegen::slot_may_be_constfn( + &keys_global_name, + field_index, + )); if ptr_shape_proven { ctx.note_ptr_shape_consumed(object.as_ref(), "ptr_shape_set"); super::store_census::bump( @@ -1332,18 +1221,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - ); return Ok(val_double); } - // #5093: build the guard operands once, up front, so both - // the inline shape pre-check and the guard-call fallback - // can reference them. - let (obj_bits, obj_handle, key_raw, val_bits) = { + // #5093: build only the noncollecting precheck operands here. + // The guard and fallback materialize fresh key handles + // and consume rooted receiver/value snapshots below. + let (obj_bits, obj_handle, val_bits) = { let blk = ctx.block(); let obj_bits = blk.bitcast_double_to_i64(&recv_box); let obj_handle = blk.and(I64, &obj_bits, POINTER_MASK_I64); - let key_box = blk.load(DOUBLE, &key_handle_global); - let key_bits = blk.bitcast_double_to_i64(&key_box); - let key_raw = blk.and(I64, &key_bits, POINTER_MASK_I64); let val_bits = blk.bitcast_double_to_i64(&val_double); - (obj_bits, obj_handle, key_raw, val_bits) + (obj_bits, obj_handle, val_bits) }; let fast_idx = ctx.new_block("class_field_set.fast"); let fallback_idx = ctx.new_block("class_field_set.fallback"); @@ -1354,7 +1240,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - // #5093: inline shape pre-check. On a hit this branches // straight to the store, skipping the call; on a miss the - // guard-call path below runs unchanged. + // guard-call path below adopts the evaluated operands into roots. // // #7854: this used to be gated on `requires_raw_f64`, // leaving every BOXED declared field (`string`, a class @@ -1370,7 +1256,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - // taken the boxed inline precheck since #7288: the write // barrier, layout note and string demote come from // `emit_jsvalue_slot_store_pointer_tested` (which the - // shared `fast_label` block below calls, with the very + // common store emitter below calls, with the very // same value-side predicates), NOT from the guard; and a // setter in the chain is already refused upstream by // `class_field_global_index`'s `accessor_in_chain`. @@ -1416,154 +1302,144 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - &keys_global_name, field_index, ); - super::store_census::bump(ctx, super::store_census::CFIELD_IC_CALL); - let guard_ok = ctx.block().call( - I32, - "js_typed_feedback_class_field_set_guard", - &[ - (I64, &site_id), - (DOUBLE, &recv_box), - (I32, &expected_class_id_str), - (I32, &expected_shape_id), - (I64, &key_raw), - (I32, &field_idx_str), - (DOUBLE, &val_double), - (I32, requires_raw_f64_str), - ], - ); - let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); - ctx.block() - .cond_br(&guard_pass, &fast_label, &fallback_label); - - ctx.current_block = fast_idx; - super::store_census::bump( - ctx, - super::store_census::CFIELD_GUARD_STORE, - ); - // #5334 lever D: a value that is a non-pointer by - // construction (number / bool / undefined / null / - // comparison / arithmetic) creates no parent→child heap - // reference, so the generational write barrier is a - // semantic no-op and can be skipped. Computed before the - // block builder is borrowed below. The LAYOUT NOTE is - // kept regardless: it records the slot's pointer-ness for - // minor-scan skipping, and a non-pointer write into a - // slot that previously held a pointer is a real - // transition the GC must observe. Same soundness standard - // as the array-store barrier elision. - let field_set_barrier_needed = - !expr_produces_non_pointer_bits_by_construction(ctx, value); - // #7469: value-side elision of the addref and layout - // note on the guarded arm — computed here because the - // predicates take `&FnCtx` and the block builder is - // borrowed below. - let guarded_addref_needed = - class_field_store_needs_string_addref(ctx, value); - let raw_stored_value = { - // arm64_32 watchOS: the object fields region begins at - // `size_of::()` past the user pointer — 16 on - // both LP64 and ILP32 since #8047. A hardcoded offset writes - // class fields to the wrong word when the header changes; the paired inline read - // (`property_get`) and the runtime setter must agree, so - // derive it from the target triple (no-op on 64-bit; see - // `target_layout`). - let header_skip = - crate::target_layout::object_header_size_bytes( - ctx.target_triple, - ) - .to_string(); - let field_ptr = { - let blk = ctx.block(); - let obj_ptr = blk.inttoptr(I64, &obj_handle); - let fields_base = - blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - blk.gep(DOUBLE, &fields_base, &[(I64, &field_idx_str)]) - }; - let raw_stored_value = if requires_raw_f64 { - // Guarded raw-f64 slots are pointer-free by typed - // shape descriptor; non-number writes miss the - // guard and use the boxed setter fallback. - // #10907: canonicalize only off the - // plain-finite path. - // - // GC_STORE_AUDIT(POINTER_FREE): typed raw-f64 class - // slots contain numbers only. - emit_raw_f64_class_field_slot_store( - ctx, - value, - &val_double, - &field_ptr, - ); - Some(val_double.clone()) - } else { - // #5334 lever D: skip the barrier when the value - // is a non-pointer by construction. #7469 extends - // the same value-expression gating to the addref - // and layout note — the Phase 4b.1 predicates are - // value-side-only proofs (see their docs: safe in - // every layout state the receiver can be in), so - // they apply on this guarded arm exactly as on - // the ptr-shape-proven arm above. The guard - // passing does not change what the VALUE can be; - // `requires_raw_f64` is false here, which is the - // precondition `class_field_store_needs_layout_note` - // documents. - // - // #7511: this is the arm the shared - // `_constructor` symbol lands on, where the - // value is an opaque function parameter and lever D - // can never fire. Whatever survives it is decided by - // ONE live test of the stored bits instead of three - // cross-crate calls that each re-ask the same - // question — see - // `emit_jsvalue_slot_store_pointer_tested`. - let field_addr = ctx.block().ptrtoint(&field_ptr, I64); - emit_jsvalue_slot_store_pointer_tested( + let guardcall_idx = ctx.current_block; + // Keep the inline hit free of root traffic. The collecting + // guard has a separate store diamond using refreshed operands. + let emit_guarded_store = + |ctx: &mut FnCtx<'_>, + obj_bits: &str, + obj_handle: &str, + val_double: &str| { + super::store_census::bump( ctx, - &field_ptr, - &val_double, - &obj_handle, - guarded_addref_needed, - &obj_bits, - &field_addr, - field_set_barrier_needed, - "class_field_set", + super::store_census::CFIELD_GUARD_STORE, ); - None - }; - ctx.block().br(&merge_label); - raw_stored_value - }; - if let Some(numeric_value) = raw_stored_value { - let stored = LoweredValue { - semantic: SemanticKind::JsNumber, - rep: NativeRep::F64, - llvm_ty: DOUBLE, - value: numeric_value.clone(), - }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "class_field_set.raw_f64_store", - &stored, - Some(BoundsState::Guarded { - guard_id: "class_field_set_guard".to_string(), - }), - None, - Some(BufferAccessMode::CheckedNative), - None, - None, - None, - vec![raw_f64_layout_fact( - None, - "consumed", - "class_field_set_guard", - None, - )], - Vec::new(), - false, - false, - vec![ + // #5334 lever D: a value that is a non-pointer by + // construction (number / bool / undefined / null / + // comparison / arithmetic) creates no parent→child heap + // reference, so the generational write barrier is a + // semantic no-op and can be skipped. Computed before the + // block builder is borrowed below. The LAYOUT NOTE is + // kept regardless: it records the slot's pointer-ness for + // minor-scan skipping, and a non-pointer write into a + // slot that previously held a pointer is a real + // transition the GC must observe. Same soundness standard + // as the array-store barrier elision. + let field_set_barrier_needed = + !expr_produces_non_pointer_bits_by_construction( + ctx, value, + ); + // #7469: value-side elision of the addref and layout + // note on the guarded arm — computed here because the + // predicates take `&FnCtx` and the block builder is + // borrowed below. + let guarded_addref_needed = + class_field_store_needs_string_addref(ctx, value); + let raw_stored_value = { + // arm64_32 watchOS: the object fields region begins at + // `size_of::()` past the user pointer — 16 on + // both LP64 and ILP32 since #8047. A hardcoded offset writes + // class fields to the wrong word when the header changes; the paired inline read + // (`property_get`) and the runtime setter must agree, so + // derive it from the target triple (no-op on 64-bit; see + // `target_layout`). + let header_skip = + crate::target_layout::object_header_size_bytes( + ctx.target_triple, + ) + .to_string(); + let field_ptr = { + let blk = ctx.block(); + let obj_ptr = blk.inttoptr(I64, obj_handle); + let fields_base = + blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); + blk.gep( + DOUBLE, + &fields_base, + &[(I64, &field_idx_str)], + ) + }; + if requires_raw_f64 { + // Guarded raw-f64 slots are pointer-free by typed + // shape descriptor; non-number writes miss the + // guard and use the boxed setter fallback. + // #10907: canonicalize only off the + // plain-finite path. + // + // GC_STORE_AUDIT(POINTER_FREE): typed raw-f64 class + // slots contain numbers only. + emit_raw_f64_class_field_slot_store( + ctx, value, val_double, &field_ptr, + ); + Some(val_double.to_string()) + } else { + // #5334 lever D: skip the barrier when the value + // is a non-pointer by construction. #7469 extends + // the same value-expression gating to the addref + // and layout note — the Phase 4b.1 predicates are + // value-side-only proofs (see their docs: safe in + // every layout state the receiver can be in), so + // they apply on this guarded arm exactly as on + // the ptr-shape-proven arm above. The guard + // passing does not change what the VALUE can be; + // `requires_raw_f64` is false here, which is the + // precondition `class_field_store_needs_layout_note` + // documents. + // + // #7511: this is the arm the shared + // `_constructor` symbol lands on, where the + // value is an opaque function parameter and lever D + // can never fire. Whatever survives it is decided by + // ONE live test of the stored bits instead of three + // cross-crate calls that each re-ask the same + // question — see + // `emit_jsvalue_slot_store_pointer_tested`. + let field_addr = + ctx.block().ptrtoint(&field_ptr, I64); + emit_jsvalue_slot_store_pointer_tested( + ctx, + &field_ptr, + val_double, + obj_handle, + guarded_addref_needed, + obj_bits, + &field_addr, + field_set_barrier_needed, + "class_field_set", + ); + None + } + }; + if let Some(numeric_value) = raw_stored_value { + let stored = LoweredValue { + semantic: SemanticKind::JsNumber, + rep: NativeRep::F64, + llvm_ty: DOUBLE, + value: numeric_value.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", + None, + "class_field_set.raw_f64_store", + &stored, + Some(BoundsState::Guarded { + guard_id: "class_field_set_guard".to_string(), + }), + None, + Some(BufferAccessMode::CheckedNative), + None, + None, + None, + vec![raw_f64_layout_fact( + None, + "consumed", + "class_field_set_guard", + None, + )], + Vec::new(), + false, + false, + vec![ format!("class={}", class_name), format!("class_id={}", expected_class_id_str), format!("field={}", property), @@ -1573,19 +1449,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - "field_layout=raw_f64_slot_array".to_string(), "pointer_bitmap=non_pointer".to_string(), ], - ); - ctx.record_lowered_value_with_access_mode( - "WriteBarrierElided", - None, - "write_barrier.elided_raw_f64_class_field", - &stored, - None, - None, - None, - None, - false, - false, - vec![ + ); + ctx.record_lowered_value_with_access_mode( + "WriteBarrierElided", + None, + "write_barrier.elided_raw_f64_class_field", + &stored, + None, + None, + None, + None, + false, + false, + vec![ "reason=raw_f64_class_field_pointer_free".to_string(), format!("class={}", class_name), format!("class_id={}", expected_class_id_str), @@ -1596,79 +1472,164 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - "field_layout=raw_f64_slot_array".to_string(), "pointer_bitmap=non_pointer".to_string(), ], - ); - } + ); + } + }; - ctx.current_block = fallback_idx; - super::store_census::bump( - ctx, - super::store_census::CFIELD_GUARD_FALLBACK, - ); - let blk = ctx.block(); - // #5334 lever A: the guard already ran and FAILED in the - // entry block, so this cold arm is a pure guard-miss - // fallback. Outline the two operations it used to emit - // inline (record_fallback + by-name set) into ONE - // `js_class_field_set_fallback` call. Semantics are - // byte-identical; only the emitted IR shrinks (cold path - // → zero hot-loop cost). `obj_bits` keeps the full - // NaN-box tag; `key_raw` is POINTER_MASK-stripped — the - // same operands the two calls received. - blk.call_void( - "js_class_field_set_fallback", - &[ - (I64, &site_id), - (I64, &obj_bits), - (I64, &key_raw), - (DOUBLE, &val_double), - ], - ); - blk.br(&merge_label); - if requires_raw_f64 { - let fallback = LoweredValue { - semantic: SemanticKind::JsValue, - rep: NativeRep::JsValue, - llvm_ty: DOUBLE, - value: val_double.clone(), + ctx.current_block = fast_idx; + emit_guarded_store(ctx, &obj_bits, &obj_handle, &val_double); + let fast_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); + + ctx.current_block = guardcall_idx; + let cold_val = rooting::with_rooted_group(ctx, 2, |ctx, group| { + let receiver = group.adopt_emitted( + ctx, + rooting::Repr::Boxed, + &recv_box, + true, + ); + let rhs = group.adopt_emitted( + ctx, + rooting::Repr::Boxed, + &val_double, + true, + ); + let cold_fast_idx = ctx.new_block("class_field_set.cold_fast"); + let cold_merge_idx = + ctx.new_block("class_field_set.cold_merge"); + let cold_fast_label = ctx.block_label(cold_fast_idx); + let cold_merge_label = ctx.block_label(cold_merge_idx); + let guard_receiver = group.reread_emitted(ctx, receiver); + let guard_rhs = group.reread_emitted(ctx, rhs); + let key_raw = { + let blk = ctx.block(); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + blk.and(I64, &key_bits, POINTER_MASK_I64) }; - ctx.record_lowered_value_with_access_mode_and_facts( - "ClassFieldSet", - None, - "js_object_set_field_by_name", - &fallback, - Some(BoundsState::Unknown), - None, - Some(BufferAccessMode::DynamicFallback), - Some(MaterializationReason::RuntimeApi), - None, - None, - Vec::new(), - vec![ - raw_f64_layout_fact( - None, - "rejected", - "class_field_set_guard", - Some(MaterializationReason::RuntimeApi), - ), - raw_f64_layout_fact( - None, - "invalidated", - "runtime_api", - Some(MaterializationReason::RuntimeApi), - ), + super::store_census::bump( + ctx, + super::store_census::CFIELD_IC_CALL, + ); + let guard_ok = ctx.block().call( + I32, + "js_typed_feedback_class_field_set_guard", + &[ + (I64, &site_id), + (DOUBLE, &guard_receiver), + (I32, &expected_class_id_str), + (I32, &expected_shape_id), + (I64, &key_raw), + (I32, &field_idx_str), + (DOUBLE, &guard_rhs), + (I32, requires_raw_f64_str), ], - false, - false, - vec![ - format!("class={}", class_name), - format!("field={}", property), - format!("field_index={}", field_idx_str), + ); + let guard_pass = ctx.block().icmp_ne(I32, &guard_ok, "0"); + ctx.block().cond_br( + &guard_pass, + &cold_fast_label, + &fallback_label, + ); + + ctx.current_block = cold_fast_idx; + let cold_receiver = group.reread_emitted(ctx, receiver); + let cold_rhs = group.reread_emitted(ctx, rhs); + let cold_bits = + ctx.block().bitcast_double_to_i64(&cold_receiver); + let cold_handle = + ctx.block().and(I64, &cold_bits, POINTER_MASK_I64); + emit_guarded_store(ctx, &cold_bits, &cold_handle, &cold_rhs); + ctx.block().br(&cold_merge_label); + + ctx.current_block = fallback_idx; + super::store_census::bump( + ctx, + super::store_census::CFIELD_GUARD_FALLBACK, + ); + let fallback_receiver = group.reread_emitted(ctx, receiver); + let fallback_rhs = group.reread_emitted(ctx, rhs); + let blk = ctx.block(); + let fallback_bits = + blk.bitcast_double_to_i64(&fallback_receiver); + let key_box = blk.load(DOUBLE, &key_handle_global); + let key_bits = blk.bitcast_double_to_i64(&key_box); + let fallback_key = blk.and(I64, &key_bits, POINTER_MASK_I64); + // #5334 lever A: the guard already ran and FAILED in the + // entry block, so this cold arm is a pure guard-miss + // fallback. Outline the two operations it used to emit + // inline (record_fallback + by-name set) into ONE + // `js_class_field_set_fallback` call. Semantics are + // byte-identical; only the emitted IR shrinks (cold path + // → zero hot-loop cost). The refreshed receiver retains its + // NaN-box tag and the freshly-loaded key is mask-stripped. + blk.call_void( + "js_class_field_set_fallback", + &[ + (I64, &site_id), + (I64, &fallback_bits), + (I64, &fallback_key), + (DOUBLE, &fallback_rhs), ], ); - } + blk.br(&cold_merge_label); + if requires_raw_f64 { + let fallback = LoweredValue { + semantic: SemanticKind::JsValue, + rep: NativeRep::JsValue, + llvm_ty: DOUBLE, + value: fallback_rhs.clone(), + }; + ctx.record_lowered_value_with_access_mode_and_facts( + "ClassFieldSet", + None, + "js_object_set_field_by_name", + &fallback, + Some(BoundsState::Unknown), + None, + Some(BufferAccessMode::DynamicFallback), + Some(MaterializationReason::RuntimeApi), + None, + None, + Vec::new(), + vec![ + raw_f64_layout_fact( + None, + "rejected", + "class_field_set_guard", + Some(MaterializationReason::RuntimeApi), + ), + raw_f64_layout_fact( + None, + "invalidated", + "runtime_api", + Some(MaterializationReason::RuntimeApi), + ), + ], + false, + false, + vec![ + format!("class={}", class_name), + format!("field={}", property), + format!("field_index={}", field_idx_str), + ], + ); + } + + ctx.current_block = cold_merge_idx; + // A fallback setter can collect again. The assignment returns + // the saved RHS, even if user code overwrote its source binding. + Ok(group.reread_emitted(ctx, rhs)) + })?; + let cold_end = ctx.block().label.clone(); + ctx.block().br(&merge_label); ctx.current_block = merge_idx; - Ok(val_double) + Ok(ctx.block().phi( + DOUBLE, + &[(&val_double, &fast_end), (&cold_val, &cold_end)], + )) }, ); } @@ -1701,3 +1662,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr, assignment_strict: bool) - _ => unreachable!("expr/mod.rs dispatched a variant not handled by this submodule"), } } + +#[cfg(test)] +#[path = "collecting_root_tests.rs"] +mod collecting_root_tests; diff --git a/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs b/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs index eeb9f5bdac..b5d53b2a58 100644 --- a/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs +++ b/crates/perry-codegen/src/expr/property_set/sloppy_class_field.rs @@ -98,64 +98,6 @@ pub(crate) fn try_lower_sloppy_class_field_store( // through the unchanged direct path. Its own collection predicate keeps // a compound receiver with an inert RHS byte-identical too. with_class_store_operands(ctx, object, value, |ctx, recv_box, val_double| { - // #7287: inside the fast clone of a #5093 class-field versioned loop, this - // store is covered by the preheader's hoisted shape check — emit the same - // inline plain-finite check + bare slot store the STRICT arm emits (see - // `lower`'s class-field arm), instead of the per-access diamond. - // - // Sound in sloppy mode for the same reason #7423 made the fast arm - // mode-independent: the preheader proved not-frozen, no per-receiver - // descriptors, matching class id and keys token, and an intact typed - // layout, and the loop's body is call-free so none of that can change while - // the clone runs. A store that reaches the raw slot could not have been - // *rejected* in either mode, so there is no sloppy/strict divergence to - // preserve. Everything else — a non-finite or NaN-boxed value — side-exits - // to the slow clone BEFORE storing, and the slow clone re-executes the whole - // iteration through this unchanged sloppy lowering. - if let Expr::LocalGet(recv_id) = object { - if let Some((fact, _)) = crate::expr::class_field_loop_fact_lookup( - &ctx.class_field_loop_facts, - *recv_id, - &class_name, - property, - ) - .filter(|(_, loop_idx)| *loop_idx == field_index) - { - let obj_ptr = fact.obj_ptr.clone(); - let side_exit_label = fact.side_exit_label.clone(); - let store_idx = ctx.new_block("class_field_loop_store.sloppy_fast"); - let store_label = ctx.block_label(store_idx); - { - let blk = ctx.block(); - let val_bits = blk.bitcast_double_to_i64(&val_double); - let finite = - crate::expr::class_field_inline_guard::emit_plain_finite_number_check( - blk, &val_bits, - ); - blk.cond_br(&finite, &store_label, &side_exit_label); - } - ctx.current_block = store_idx; - { - let header_skip = - crate::target_layout::object_header_size_bytes(ctx.target_triple) - .to_string(); - let blk = ctx.block(); - let fields_base = blk.gep(I8, &obj_ptr, &[(I64, &header_skip)]); - let field_ptr = - blk.gep(DOUBLE, &fields_base, &[(I64, &field_index.to_string())]); - // No `js_array_numeric_value_to_raw_f64` canonicalization is - // needed: INT32-boxed and NaN values — the only inputs it - // rewrites — cannot pass the finite check above. - // - // GC_STORE_AUDIT(POINTER_FREE): the finite check proved - // `val_double` is a genuine unboxed double, never a heap - // pointer — no edge, no write barrier. - blk.store(DOUBLE, &val_double, &field_ptr); - } - return Ok(Some(val_double)); - } - } - let key_idx = ctx.strings.intern(property); let key_handle_global = format!("@{}", ctx.strings.entry(key_idx).handle_global); let field_idx_str = field_index.to_string(); diff --git a/crates/perry-codegen/src/expr/receiver_range.rs b/crates/perry-codegen/src/expr/receiver_range.rs index 9e58ab1ae7..36db635a79 100644 --- a/crates/perry-codegen/src/expr/receiver_range.rs +++ b/crates/perry-codegen/src/expr/receiver_range.rs @@ -130,6 +130,9 @@ pub(crate) enum Route { /// A region guard's STATIC supplier matched (DESIGN §4.1): the receiver /// carries the driver's static id, so the region ran with no word. RloopStatic = 28, + /// One F-body iteration whose exact chosen supplier guarantees at least + /// one F64 region key (P7 acceptance census). + RloopFRep = 34, } /// `PERRY_RECV_ROUTE_COUNT=1` at COMPILE time: emit one diff --git a/crates/perry-codegen/src/expr/region_loop_tests.rs b/crates/perry-codegen/src/expr/region_loop_tests.rs index 8793dba439..8048ecb59e 100644 --- a/crates/perry-codegen/src/expr/region_loop_tests.rs +++ b/crates/perry-codegen/src/expr/region_loop_tests.rs @@ -64,7 +64,11 @@ fn put(key: &str, value: Expr) -> Stmt { } /// `function probe(o, v, n) { let h = 0; for (let i = 0; i < n; i++) { body } return h; }` -fn loop_ir(name: &str, body: Vec) -> String { +fn loop_ir_with_return(name: &str, body: Vec, result: Expr) -> String { + loop_ir_with_bound(name, body, result, Expr::LocalGet(N)) +} + +fn loop_ir_with_bound(name: &str, body: Vec, result: Expr, bound: Expr) -> String { let mut m = Module::new(name); m.functions = vec![Function { id: 1, @@ -91,7 +95,7 @@ fn loop_ir(name: &str, body: Vec) -> String { condition: Some(Expr::Compare { op: CompareOp::Lt, left: Box::new(Expr::LocalGet(I)), - right: Box::new(Expr::LocalGet(N)), + right: Box::new(bound), }), update: Some(Expr::Update { id: I, @@ -100,7 +104,7 @@ fn loop_ir(name: &str, body: Vec) -> String { }), body, }, - Stmt::Return(Some(Expr::LocalGet(H))), + Stmt::Return(Some(result)), ], is_async: false, is_generator: false, @@ -115,6 +119,10 @@ fn loop_ir(name: &str, body: Vec) -> String { String::from_utf8(compile_module(&m, opts()).expect("module compiles")).expect("UTF-8 IR") } +fn loop_ir(name: &str, body: Vec) -> String { + loop_ir_with_return(name, body, Expr::LocalGet(H)) +} + /// The blocks of the probe function, label -> (instructions, successors). fn blocks(ir: &str) -> HashMap, Vec)> { let mut out = HashMap::new(); @@ -342,15 +350,15 @@ fn a_region_that_stores_every_key_it_names_has_no_spill_copy() { ); } -/// The prime call's last argument: the boxed-store mask (charter step 5). +/// The prime call's penultimate argument: the boxed-store mask (charter step 5). fn prime_boxed_masks(ir: &str) -> Vec { ir.lines() .filter(|l| l.contains("@js_region_loop_prime(")) .filter_map(|l| { - // The call can carry trailing attributes after its closing parenthesis. - let call = l.split_once(')')?.0; - let last_arg = call.rsplit_once("i32 ")?.1; - last_arg.trim().parse().ok() + // The R mask follows the boxed-store mask; the call may carry + // trailing LLVM attributes after its closing parenthesis. + let (before_r, _) = l.rsplit_once(", i32 ")?; + before_r.rsplit_once("i32 ")?.1.trim().parse().ok() }) .collect() } @@ -389,3 +397,420 @@ fn a_bare_store_of_a_value_not_proven_a_double_names_its_key_to_the_prime() { "a literal double is a valid value of every lane: {masks:?}" ); } + +/// Last prime argument, before LLVM call attributes, is the requested region R mask. +fn prime_rep_masks(ir: &str) -> Vec { + ir.lines() + .filter(|line| line.contains("@js_region_loop_prime(")) + .filter_map(|line| { + line.rsplit_once("i32 ")? + .1 + .split_once(')')? + .0 + .trim() + .parse() + .ok() + }) + .collect() +} + +/// P8: the generic region must carry the class-field increment shape after +/// its older numeric loop tier is retired. The store is bare only when its +/// own exact read is protected by R; a string store cannot clear the boxed +/// mask even when a later read requests R. +#[test] +fn a_region_r_proven_increment_store_clears_only_its_number_boxed_bit() { + let increment = Expr::Binary { + op: BinaryOp::Add, + left: Box::new(get("x")), + right: Box::new(Expr::Integer(1)), + }; + let numeric = loop_ir("region_store_r_number", vec![put("x", increment)]); + let numeric_masks = prime_boxed_masks(&numeric); + assert!( + numeric.contains("rloop.fast"), + "numeric region did not form:\n{numeric}" + ); + assert!( + !numeric_masks.is_empty() && numeric_masks.iter().all(|&m| m == 0), + "R-proven Number store must clear the boxed bit: {numeric_masks:?}\n{numeric}" + ); + let numeric_r = prime_rep_masks(&numeric); + assert!( + !numeric_r.is_empty() && numeric_r.iter().all(|&m| m == 1), + "increment must actually request F64 for its exact read: {numeric_r:?}\n{numeric}" + ); + + let non_number = loop_ir( + "region_store_r_string", + vec![ + put("x", Expr::String("bad".into())), + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(get("x")), + right: Box::new(Expr::Integer(1)), + }), + )), + ], + ); + let non_number_masks = prime_boxed_masks(&non_number); + assert!( + !non_number_masks.is_empty() && non_number_masks.iter().all(|&m| m == 1), + "string store must retain the boxed bit: {non_number_masks:?}\n{non_number}" + ); +} + +/// A fresh bare read used by a Number-consuming add requests an F64 lane. +/// The prime must refuse an Any receiver, so this is an actual R-bearing +/// region rather than a vacuous mask argument. +#[test] +fn a_number_consuming_bare_read_sets_the_prime_rep_mask() { + let ir = loop_ir( + "region_loop_rep", + vec![Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(get("x")), + }), + ))], + ); + assert!(ir.contains("rloop.fast"), "region did not form:\n{ir}"); + let masks = prime_rep_masks(&ir); + assert!(!masks.is_empty(), "no learned prime in\n{ir}"); + assert!( + masks.iter().all(|&m| m == 1), + "fresh x read must request key 0: {masks:?}" + ); +} + +/// The F-local fixed point follows the fresh F64 read through a temporary and +/// a loop-carried accumulator. Entry is strict; G and post-loop code retain +/// the ordinary dynamic add. Removing the scoped materialization or the +/// entry check makes this test fail. +#[test] +fn a_region_number_local_is_admitted_only_in_f() { + const TEMP: u32 = 6; + let body = vec![ + Stmt::Let { + id: TEMP, + name: "temp".to_string(), + ty: Type::Any, + mutable: false, + init: Some(get("x")), + }, + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::LocalGet(TEMP)), + }), + )), + ]; + let ir = loop_ir_with_return( + "region_number_scope", + body, + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::Integer(1)), + }, + ); + assert!(ir.contains("rloop.fast"), "region did not form:\n{ir}"); + let masks = prime_rep_masks(&ir); + assert!( + !masks.is_empty() && masks.iter().all(|&m| m == 1), + "the temp's source must request R=1: {masks:?}\n{ir}" + ); + assert!( + ir.contains("rloop.fast") && ir.contains("fadd double"), + "F must use numeric add:\n{ir}" + ); + assert!( + ir.contains("rloop.guard") && ir.contains("icmp ult i64"), + "A_F must strictly test the loop-carried accumulator:\n{ir}" + ); + assert!( + ir.lines() + .filter(|line| { + line.contains("call ") && line.contains("@js_dynamic_string_or_number_add(") + }) + .count() + >= 2, + "G and post-loop adds must remain dynamic (no scope leak):\n{ir}" + ); +} + +/// Literal-bound flow through temp must receive the same R/5L proof as direct reads. +#[test] +fn flow_derived_number_local_constant_bound_avoids_recheck() { + const TEMP: u32 = 6; + let body = vec![ + Stmt::Let { + id: TEMP, + name: "temp".to_string(), + ty: Type::Any, + mutable: false, + init: Some(get("x")), + }, + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::LocalGet(TEMP)), + }), + )), + ]; + let ir = loop_ir_with_bound( + "region_number_scope_constant_bound", + body, + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::Integer(1)), + }, + Expr::Integer(200), + ); + assert!(ir.contains("rloop.fast"), "region did not form:\n{ir}"); + assert!( + !ir.contains("br i1 true, label %rloop.recheck"), + "constant-bound flow-derived R/5L must avoid an unconditional recheck:\n{ir}" + ); + let bl = blocks(&ir); + let f = f_body_blocks(&bl); + assert!(!f.is_empty(), "F body must be present:\n{ir}"); + for label in f { + let instructions = bl[&label].0.join("\n"); + assert!( + !instructions.contains("@js_object_get_field"), + "{label} must retain the bare R-proven load:\n{instructions}\n{ir}" + ); + } + let masks = prime_rep_masks(&ir); + assert!( + !masks.is_empty() && masks.iter().all(|&m| m == 1), + "the temp's source must request R=1: {masks:?}\n{ir}" + ); + assert!( + ir.contains("rloop.fast") && ir.contains("fadd double"), + "F must use numeric add:\n{ir}" + ); + assert!( + ir.contains("rloop.guard") && ir.contains("icmp ult i64"), + "A_F must strictly test the loop-carried accumulator:\n{ir}" + ); + assert!( + ir.lines() + .filter(|line| { + line.contains("call ") && line.contains("@js_dynamic_string_or_number_add(") + }) + .count() + >= 2, + "G and post-loop adds must remain dynamic (no scope leak):\n{ir}" + ); +} + +/// An unrestricted bound comparison may invoke user code and revoke freshness. +#[test] +fn any_bound_numeric_region_retains_collecting_comparison_recheck() { + const TEMP: u32 = 6; + let body = vec![ + Stmt::Let { + id: TEMP, + name: "temp".to_string(), + ty: Type::Any, + mutable: false, + init: Some(get("x")), + }, + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::LocalGet(TEMP)), + }), + )), + ]; + let ir = loop_ir_with_return( + "region_number_scope_any_bound", + body, + Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::Integer(1)), + }, + ); + assert!(ir.contains("rloop.fast"), "region did not form:\n{ir}"); + assert!( + ir.contains("@js_rel_lt("), + "Any bound must retain its collecting comparison:\n{ir}" + ); + assert!( + ir.contains("br i1 true, label %rloop.recheck"), + "Any-bound user-code comparison must retain the conservative recheck:\n{ir}" + ); + let masks = prime_rep_masks(&ir); + assert!( + !masks.is_empty() && masks.iter().all(|&m| m == 1), + "the temp's source must request R=1: {masks:?}\n{ir}" + ); + assert!( + ir.contains("rloop.fast") && ir.contains("fadd double"), + "F must use numeric add:\n{ir}" + ); + assert!( + ir.contains("rloop.guard") && ir.contains("icmp ult i64"), + "A_F must strictly test the loop-carried accumulator:\n{ir}" + ); + assert!( + ir.lines() + .filter(|line| { + line.contains("call ") && line.contains("@js_dynamic_string_or_number_add(") + }) + .count() + >= 2, + "G and post-loop adds must remain dynamic (no scope leak):\n{ir}" + ); +} + +fn times(e: Expr, factor: i64) -> Expr { + Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(e), + right: Box::new(Expr::Integer(factor)), + } +} + +fn accumulated(reads: &[&str], factor: i64) -> Stmt { + let mut sum = Expr::LocalGet(H); + for key in reads { + sum = Expr::Binary { + op: BinaryOp::Add, + left: Box::new(sum), + right: Box::new(times(get(key), factor)), + }; + } + Stmt::Expr(Expr::LocalSet(H, Box::new(sum))) +} + +/// An arithmetic wrapper must consume the exact R and 5L facts, regardless +/// of its spelling. Sabotaging either proof restores the unconditional +/// recheck and (for four reads) generic reads after the first one. +#[test] +fn numeric_arithmetic_twins_keep_all_reads_bare_without_a_recheck() { + for keys in [&["a"][..], &["a", "b", "c", "e"][..]] { + for factor in [1, 2] { + let ir = loop_ir_with_bound( + "region_arith_twin", + vec![accumulated(keys, factor)], + Expr::LocalGet(H), + Expr::Integer(200), + ); + let masks = prime_rep_masks(&ir); + let expected = (1u32 << keys.len()) - 1; + assert!( + !masks.is_empty() && masks.iter().all(|m| *m == expected), + "every exact arithmetic read must be R-proven: {masks:?}\n{ir}" + ); + assert!(ir.contains("rloop.fast"), "F did not form:\n{ir}"); + assert!( + !ir.contains("rloop.recheck"), + "numeric arithmetic must not recheck every iteration:\n{ir}" + ); + let bl = blocks(&ir); + let f = f_body_blocks(&bl); + for label in f { + let instructions = bl[&label].0.join("\n"); + assert!( + !instructions.contains("@js_object_get_field"), + "{label} must not use a generic read:\n{instructions}\n{ir}" + ); + } + } + } +} + +/// A property read from another object may run a getter. It must kill the +/// receiver fact even when it is nested under native arithmetic, and a +/// later read of the region receiver must not inherit the earlier proof. +#[test] +fn arithmetic_getter_operand_requires_generic_recheck() { + let getter = Expr::PropertyGet { + object: Box::new(Expr::Call { + callee: Box::new(Expr::LocalGet(V)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + property: "x".to_string(), + byte_offset: 0, + }; + let body = vec![ + Stmt::Expr(Expr::LocalSet( + H, + Box::new(Expr::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::LocalGet(H)), + right: Box::new(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(get("a")), + right: Box::new(getter), + }), + }), + )), + Stmt::Expr(get("b")), + ]; + let ir = loop_ir_with_bound( + "region_getter_kill", + body, + Expr::LocalGet(H), + Expr::Integer(200), + ); + assert!( + ir.contains("rloop.fast"), + "fixture must admit the first read:\n{ir}" + ); + assert!( + ir.contains("rloop.recheck") && ir.contains("br i1 true, label %rloop.recheck"), + "getter operand must force the back-edge recheck:\n{ir}" + ); + assert!( + prime_rep_masks(&ir).iter().all(|m| m & 0b10 == 0), + "the later b read must not borrow the stale fact:\n{ir}" + ); +} + +/// A call after the final bare read may mutate the receiver or its +/// prototype. The whole F path, not just prefixes of bare reads, is part of +/// the next iteration's freshness proof. +#[test] +fn post_read_mutation_call_forces_next_iteration_recheck() { + let body = vec![ + accumulated(&["a"], 1), + Stmt::Expr(Expr::Call { + callee: Box::new(Expr::LocalGet(V)), + args: Vec::new(), + type_args: Vec::new(), + byte_offset: 0, + }), + ]; + let ir = loop_ir_with_bound( + "region_post_read_mutation", + body, + Expr::LocalGet(H), + Expr::Integer(200), + ); + assert!( + ir.contains("rloop.fast"), + "fixture must admit the read:\n{ir}" + ); + assert!( + ir.contains("rloop.recheck") && ir.contains("br i1 true, label %rloop.recheck"), + "a post-read JS call must recheck before the next iteration:\n{ir}" + ); +} diff --git a/crates/perry-codegen/src/expr/slot_rep.rs b/crates/perry-codegen/src/expr/slot_rep.rs index c72bb175c2..92643d4e8a 100644 --- a/crates/perry-codegen/src/expr/slot_rep.rs +++ b/crates/perry-codegen/src/expr/slot_rep.rs @@ -526,10 +526,21 @@ pub(crate) const PTR_SHAPE_SCALAR_REPLACED: &str = "scalar_replaced"; /// codegen had silently refused to apply. pub(crate) const PTR_SHAPE_NO_ACCESS_SITE: &str = "no_access_site"; +/// A region owns slot/representation authority; an older unguarded receiver +/// route must not bypass its live ShapeId and store admission. +pub(crate) const PTR_SHAPE_REGION_AUTHORITY: &str = "region_shape_authority"; + /// `(reason, issue)` for a rule that stopped a *selected* `Ptr` proof /// from being consumed by codegen. pub(crate) fn ptr_shape_context_rule_text(rule: &str) -> (&'static str, &'static str) { match rule { + PTR_SHAPE_REGION_AUTHORITY => ( + "the admitted loop/body region owns slot and representation authority: \ + its live ShapeId guard and store admission replace the unguarded \ + receiver route at this access. Class provenance consumed by an \ + emitted static-region access is reported separately", + "#10884 (P8 region handoff)", + ), MODULE_INIT_CONTEXT => ( "module-init / program-entry bodies set \ `repsel_context_allows_canonical_i32: false` (codegen/entry.rs), and \ diff --git a/crates/perry-codegen/src/expr/store_census.rs b/crates/perry-codegen/src/expr/store_census.rs index 15ac110100..b15e2c5703 100644 --- a/crates/perry-codegen/src/expr/store_census.rs +++ b/crates/perry-codegen/src/expr/store_census.rs @@ -29,8 +29,8 @@ pub(crate) const CFIELD_GUARD_STORE: usize = 5; pub(crate) const CFIELD_GUARD_FALLBACK: usize = 6; /// Class-field store: a `js_class_field_set_ic` call. pub(crate) const CFIELD_IC_CALL: usize = 7; -/// Class-field store: the loop-versioned raw store. -pub(crate) const CFIELD_LOOP_RAW: usize = 8; +// Index 8 remains a zero tombstone for the removed class-loop raw store. +// The runtime's diagnostic array keeps its indices stable across the deletion. /// Class setter dispatch (`__set_`). pub(crate) const CFIELD_SETTER: usize = 9; /// Sloppy-mode class-field store. @@ -61,6 +61,9 @@ pub(crate) const ELEM_STORE_APPEND: usize = 38; pub(crate) const ELEM_STORE_GUARD_MISS: usize = 39; /// Array element store: a runtime set / extend call. pub(crate) const ELEM_STORE_FALLBACK: usize = 40; +/// Array element read through a versioned-indexed loop fact specifically. +/// Unlike OTHER_TIER, no region or trusted-parameter read increments this word. +pub(crate) const ELEM_READ_VERSIONED_INDEXED: usize = 41; /// Array element store into an F64 array of a NaN-boxed value: the cold arm /// that clears the kind (header first) or converts an INT32 box. pub(crate) const ELEM_STORE_F64_COLD: usize = 42; diff --git a/crates/perry-codegen/src/fn_info.rs b/crates/perry-codegen/src/fn_info.rs index fe022e3d78..8d4d70cb38 100644 --- a/crates/perry-codegen/src/fn_info.rs +++ b/crates/perry-codegen/src/fn_info.rs @@ -26,7 +26,8 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::runtime_abi::{ FN_ARROW, FN_ASYNC, FN_ASYNC_GENERATOR, FN_GENERATOR, FN_HAS_DECLARED, FN_HAS_LENGTH, - FN_REST_SYNTHETIC_ARGUMENTS, FN_REST_USER, FN_REST_USER_AND_ARGUMENTS, FN_STRICT, + FN_PERMANENT_IMAGE, FN_REST_SYNTHETIC_ARGUMENTS, FN_REST_USER, FN_REST_USER_AND_ARGUMENTS, + FN_STRICT, }; /// The LLVM type of a `JsFunctionInfo`, field for field (perry-abi's @@ -40,6 +41,11 @@ pub(crate) fn info_symbol(body: &str) -> String { format!("{body}$info") } +/// Shared by closure lowering and the static final-shape pre-pass. +pub(crate) fn closure_body_symbol(module_prefix: &str, func_id: u32) -> String { + format!("perry_closure_{module_prefix}__{func_id}") +} + /// A compiler-private direct-call clone of a body. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct CloneTarget { @@ -133,6 +139,8 @@ pub(crate) struct DefinedBody { pub(crate) struct FnInfoState { requested: BTreeSet, facts: BTreeMap, + /// Bodies whose info address a separate static-seed object will name. + static_seed_bodies: BTreeSet, } impl FnInfoState { @@ -145,6 +153,15 @@ impl FnInfoState { format!("@{}", info_symbol(body)) } + /// Reserve a stable body-info symbol for a future static seed unit. + /// This also requests the info definition. The body remains local; only + /// its info becomes linkable when the definer renders this module. + /// No birth collector calls this until the seed ABI has module-init parity. + pub(crate) fn request_static_seed_body(&mut self, body: &str) -> String { + self.static_seed_bodies.insert(body.to_string()); + self.request(body) + } + /// The facts of a body this module defines. pub(crate) fn facts_mut(&mut self, body: &str) -> &mut FnInfoFacts { self.facts.entry(body.to_string()).or_default() @@ -159,6 +176,7 @@ impl FnInfoState { &self, defined: impl Fn(&str) -> Option, exported: impl IntoIterator, + permanent_image: bool, ) -> Vec { let mut bodies: BTreeSet<&str> = self.requested.iter().map(String::as_str).collect(); bodies.extend(self.facts.keys().map(String::as_str)); @@ -171,6 +189,8 @@ impl FnInfoState { body, &def, self.facts.get(body).cloned().unwrap_or_default(), + permanent_image, + self.static_seed_bodies.contains(body), )), None if self.requested.contains(body) => out.push(format!( "@{} = external constant {}", @@ -186,10 +206,20 @@ impl FnInfoState { } } -fn render_definition(body: &str, def: &DefinedBody, facts: FnInfoFacts) -> String { - let linkage = match def.linkage.as_str() { - "" => String::new(), - other => format!("{other} "), +fn render_definition( + body: &str, + def: &DefinedBody, + facts: FnInfoFacts, + permanent_image: bool, + static_seed: bool, +) -> String { + let linkage = if static_seed { + "hidden ".to_string() + } else { + match def.linkage.as_str() { + "" => String::new(), + other => format!("{other} "), + } }; let clone = |target: &Option| match target { Some(t) => (format!("@{}", t.symbol), t.captures, t.boxed_mask), @@ -205,7 +235,12 @@ fn render_definition(body: &str, def: &DefinedBody, facts: FnInfoFacts) -> Strin ty = INFO_TYPE, params = saturate_u16(def.params as u64), rest = facts.rest_fixed, - flags = facts.flags, + flags = facts.flags + | if permanent_image { + FN_PERMANENT_IMAGE + } else { + 0 + }, length = facts.length, tcap = trusted_captures, tcode = trusted_code, @@ -246,6 +281,7 @@ mod tests { .flatten() }, [], + false, ); assert_eq!( lines, @@ -257,11 +293,58 @@ mod tests { ); } + #[test] + fn only_a_permanent_image_marks_defined_body_infos() { + let mut state = FnInfoState::default(); + state.request("perry_closure_m__3"); + let transient = state.render_globals(|_| defined(0, "internal"), [], false); + let permanent = state.render_globals(|_| defined(0, "internal"), [], true); + assert!(transient[0].contains("i32 0, i32 0")); + assert!(permanent[0].contains(&format!("i32 {FN_PERMANENT_IMAGE}, i32 0"))); + } + + #[test] + fn seed_info_has_linkable_stable_symbol_but_body_keeps_local_linkage() { + let mut state = FnInfoState::default(); + let body = "perry_closure_m__3"; + assert_eq!( + state.request_static_seed_body(body), + format!("@{}", info_symbol(body)) + ); + assert_eq!( + state.request_static_seed_body(body), + format!("@{}", info_symbol(body)) + ); + let lines = state.render_globals(|_| defined(0, "internal"), [], true); + assert_eq!( + lines.len(), + 1, + "one body has one info despite fresh closures" + ); + assert!(lines[0].starts_with(&format!("@{} = hidden constant", info_symbol(body)))); + assert!(lines[0].contains(&format!("ptr @{body}"))); + assert!(lines[0].contains(&format!("i32 {FN_PERMANENT_IMAGE}"))); + } + + #[test] + fn foreign_seed_info_is_only_declared_by_importer() { + let mut state = FnInfoState::default(); + let body = "perry_closure_other__3"; + state.request_static_seed_body(body); + assert_eq!( + state.render_globals(|_| None, [], true), + vec![format!( + "@{} = external constant {INFO_TYPE}", + info_symbol(body) + )] + ); + } + #[test] fn a_foreign_body_is_declared_never_copied() { let mut state = FnInfoState::default(); state.request("__perry_wrap_perry_fn_other__f"); - let lines = state.render_globals(|_| None, []); + let lines = state.render_globals(|_| None, [], false); assert_eq!( lines, vec![format!( @@ -280,6 +363,7 @@ mod tests { .flatten() }, ["__perry_wrap_perry_fn_m__g".to_string()], + false, ); assert_eq!(lines.len(), 1); assert!(lines[0].starts_with(&format!( @@ -296,7 +380,7 @@ mod tests { captures: 2, boxed_mask: 0b10, }); - let lines = state.render_globals(|_| defined(0, "internal"), []); + let lines = state.render_globals(|_| defined(0, "internal"), [], false); assert!( lines[0].contains("i32 2, ptr @perry_closure_m__9$trusted_boxes, i64 2, ptr null"), "{}", diff --git a/crates/perry-codegen/src/gc_call_effects.rs b/crates/perry-codegen/src/gc_call_effects.rs index cc33d01f4c..c7c5234bb8 100644 --- a/crates/perry-codegen/src/gc_call_effects.rs +++ b/crates/perry-codegen/src/gc_call_effects.rs @@ -415,6 +415,24 @@ pub(crate) fn transitive_leaf_functions(functions: &[&LlFunction]) -> HashSet CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/lower_call/method_override.rs b/crates/perry-codegen/src/lower_call/method_override.rs index c3d7d9c68c..ab5d665343 100644 --- a/crates/perry-codegen/src/lower_call/method_override.rs +++ b/crates/perry-codegen/src/lower_call/method_override.rs @@ -305,7 +305,14 @@ pub(crate) fn emit_inline_direct_method_shape_guard( let expected_shape_i64 = blk.zext(I32, expected_shape_id, I64); let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32"); let expected_class_shape = blk.or(I64, &expected_shape_high, expected_class_id); - let class_shape_ok = blk.icmp_eq(I64, &class_shape, &expected_class_shape); + let class_shape_ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &class_shape, + expected_class_id, + expected_shape_id, + &expected_class_shape, + &[], + ); // `is_shape_id` is `[0x8000_0000, 0xC000_0000)`. Subtract the base // modulo i32 and compare with the range length, matching the runtime @@ -477,7 +484,14 @@ fn emit_inline_exact_argument_shape_guard( let expected_shape_high = blk.shl(I64, &expected_shape_i64, "32"); let expected_class_shape = blk.or(I64, &expected_shape_high, &expected_class_id.to_string()); - let class_shape_ok = blk.icmp_eq(I64, &class_shape, &expected_class_shape); + let class_shape_ok = crate::typed_shape::emit_compatible_class_shape_eq( + blk, + &class_shape, + &expected_class_id.to_string(), + expected_shape_id, + &expected_class_shape, + &[], + ); let shape_id_rel = blk.add(I32, expected_shape_id, SHAPE_ID_BASE_NEG_I32); let shape_valid = blk.icmp_ult(I32, &shape_id_rel, SHAPE_ID_RANGE_LEN); let pass = blk.and(I1, &gc_header_ok, &class_shape_ok); @@ -1063,7 +1077,12 @@ pub(super) fn emit_guarded_direct_method_call( let next = sub_test_labels[0].clone(); let blk = ctx.block(); let cid_ok = blk.icmp_eq(I32, &cid, &expected_class_id_str); - let shape_ok = blk.icmp_eq(I32, &shape_id, &expected_shape_id); + let shape_ok = crate::typed_shape::emit_compatible_shape_eq( + blk, + &shape_id, + &expected_shape_id, + &[], + ); let pass = blk.and(I1, &cid_ok, &shape_ok); blk.cond_br(&pass, &fast_label, &next); } @@ -1078,7 +1097,8 @@ pub(super) fn emit_guarded_direct_method_call( let arm_shape_id = subclass_shape_ids[i].clone(); let blk = ctx.block(); let cid_ok = blk.icmp_eq(I32, &cid, &class_id_str); - let shape_ok = blk.icmp_eq(I32, &shape_id, &arm_shape_id); + let shape_ok = + crate::typed_shape::emit_compatible_shape_eq(blk, &shape_id, &arm_shape_id, &[]); let pass = blk.and(I1, &cid_ok, &shape_ok); blk.cond_br(&pass, &case_label, &next); } 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 6a9413c9d0..51bb8f46ea 100644 --- a/crates/perry-codegen/src/lower_call/native_module_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/native_module_dispatch.rs @@ -182,6 +182,25 @@ pub fn lower_native_module_dispatch( arg_types.push(DOUBLE); } + let thread_launch = matches!( + sig.runtime, + "js_thread_spawn" | "js_thread_parallel_map" | "js_thread_parallel_filter" + ); + let runtime = if thread_launch { + let prepare = format!( + "__perry_prepare_thread_strings_{}", + ctx.strings.thread_literal_callback_prefix() + ); + ctx.pending_declares + .push((prepare.clone(), crate::types::VOID, vec![])); + llvm_args.push((I64, format!("ptrtoint (ptr @{} to i64)", prepare))); + arg_types.push(I64); + format!("{}_with_literals", sig.runtime) + } else { + sig.runtime.to_string() + }; + let runtime = runtime.as_str(); + // Determine return type for the declare let ret_type = match sig.ret { NativeRetKind::GcPtr @@ -200,7 +219,7 @@ pub fn lower_native_module_dispatch( }; ctx.pending_declares - .push((sig.runtime.to_string(), ret_type, arg_types)); + .push((runtime.to_string(), ret_type, arg_types)); let arg_slices: Vec<(crate::types::LlvmType, &str)> = llvm_args.iter().map(|(t, s)| (*t, s.as_str())).collect(); @@ -295,7 +314,7 @@ pub fn lower_native_module_dispatch( let raw = blk.call(I64, sig.runtime, &arg_slices); Ok(nanbox_bigint_inline(blk, &raw)) } - NativeRetKind::F64 => Ok(ctx.block().call(DOUBLE, sig.runtime, &arg_slices)), + NativeRetKind::F64 => Ok(ctx.block().call(DOUBLE, runtime, &arg_slices)), NativeRetKind::BoolI1 | NativeRetKind::BoolI32 => { let blk = ctx.block(); let raw = blk.call(ret_type, sig.runtime, &arg_slices); diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index edaead4ff4..cd947c2421 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -235,6 +235,30 @@ fn lower_new_impl( // `new` site that roots anything. let mut group = open_rooted_group(args.len() + 1); let result = lower_new_impl_inner(ctx, class_name, args, cap_args_appended, &mut group); + let result = result.map(|boxed| { + if ctx.block().is_terminated() || !crate::codegen::static_constfn::has_final_shapes() { + return boxed; + } + let candidate = ctx + .classes + .get(class_name) + .and_then(|class| crate::codegen::static_constfn::anon_props(class, args)); + if let Some(props) = candidate { + if let Some(rep) = ctx + .class_keys_globals + .get(class_name) + .and_then(|keys| ctx.class_birth_reps.get(keys)) + .copied() + { + let bits = ctx.block().bitcast_double_to_i64(&boxed); + let handle = ctx.block().and(I64, &bits, crate::nanbox::POINTER_MASK_I64); + let handle = + crate::codegen::static_constfn::finalize_literal(ctx, &props, rep, &handle); + return nanbox_pointer_inline(ctx.block(), &handle); + } + } + crate::codegen::static_constfn::finalize_class(ctx, class_name, &boxed) + }); group.release(ctx); result } @@ -1622,16 +1646,10 @@ fn lower_new_impl_inner<'a>( // Field initializers / an inlined constructor body were lowered // between the instance allocation and here, so refresh again. lowered_args = refresh_rooted_args(ctx, group)?; - let marshalled = marshal_imported_ctor_args(ctx, &ctor, &lowered_args); - let mut ctor_args: Vec<(crate::types::LlvmType, &str)> = - Vec::with_capacity(1 + marshalled.len()); - ctor_args.push((DOUBLE, &obj_box)); + let marshalled = marshal_imported_ctor_args(ctx, &ctor, &lowered_args, group); let ctor_param_types: Vec = std::iter::once(DOUBLE) .chain(marshalled.iter().map(|_| DOUBLE)) .collect(); - for la in &marshalled { - ctor_args.push((DOUBLE, la.as_str())); - } // Walked to an ANCESTOR ctor: its return-override does not replace // the leaf instance, so discard the return value. Declared DOUBLE // to match the symbol's real signature (see codegen/mod.rs). @@ -1653,6 +1671,15 @@ fn lower_new_impl_inner<'a>( None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); + // Initializers, argument packing and class-value lookup may + // collect. The rooted this-slot also owns any replacement this. + let ctor_this = ctx.block().load(DOUBLE, &this_slot); + let marshalled: Vec<_> = marshalled + .iter() + .map(|arg| arg.reread(ctx, group)) + .collect(); + let mut ctor_args = vec![(DOUBLE, ctor_this.as_str())]; + ctor_args.extend(marshalled.iter().map(|arg| (DOUBLE, arg.as_str()))); let _ = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); crate::rooting::new_target_restore(ctx, &nt_save); } else if let Some(ctor) = ctx.imported_class_ctors.get(class_name).cloned() { @@ -1662,17 +1689,10 @@ fn lower_new_impl_inner<'a>( // Field initializers / an inlined constructor body were lowered // between the instance allocation and here, so refresh again. lowered_args = refresh_rooted_args(ctx, group)?; - let marshalled = marshal_imported_ctor_args(ctx, &ctor, &lowered_args); - // Pass `this` as NaN-boxed double (same as compile_method's this_arg). - let mut ctor_args: Vec<(crate::types::LlvmType, &str)> = - Vec::with_capacity(1 + marshalled.len()); - ctor_args.push((DOUBLE, &obj_box)); + let marshalled = marshal_imported_ctor_args(ctx, &ctor, &lowered_args, group); let ctor_param_types: Vec = std::iter::once(DOUBLE) .chain(marshalled.iter().map(|_| DOUBLE)) .collect(); - for la in &marshalled { - ctor_args.push((DOUBLE, la.as_str())); - } // The standalone `_constructor` symbol returns DOUBLE: the // value an explicit `return ` produced (ECMAScript ctor // return-override) or `undefined` for an ordinary ctor. Capture it @@ -1691,6 +1711,15 @@ fn lower_new_impl_inner<'a>( None => double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)), }; let nt_save = crate::rooting::new_target_save(ctx, &nt_ref); + // Read after every collecting preparation step, immediately + // before dispatch; obj_box still holds the allocation address. + let ctor_this = ctx.block().load(DOUBLE, &this_slot); + let marshalled: Vec<_> = marshalled + .iter() + .map(|arg| arg.reread(ctx, group)) + .collect(); + let mut ctor_args = vec![(DOUBLE, ctor_this.as_str())]; + ctor_args.extend(marshalled.iter().map(|arg| (DOUBLE, arg.as_str()))); let ctor_ret = ctx.block().call(DOUBLE, &ctor.symbol, &ctor_args); crate::rooting::new_target_restore(ctx, &nt_save); ctx.block().store(DOUBLE, &ctor_ret, &ctor_result_slot); diff --git a/crates/perry-codegen/src/lower_call/new_ctor_args.rs b/crates/perry-codegen/src/lower_call/new_ctor_args.rs index 81ff124c37..5c66ba89f8 100644 --- a/crates/perry-codegen/src/lower_call/new_ctor_args.rs +++ b/crates/perry-codegen/src/lower_call/new_ctor_args.rs @@ -18,6 +18,7 @@ use perry_hir::{Expr, Param}; use super::new_helpers::effective_constructor_param_count; use crate::expr::{lower_expr, nanbox_pointer_inline, FnCtx}; use crate::nanbox::double_literal; +use crate::rooting::{AccArray, RootedGroup}; use crate::types::{DOUBLE, I32, I64}; pub(crate) struct InlineConstructorScope { @@ -303,6 +304,36 @@ pub(super) fn lower_constructor_arg(ctx: &mut FnCtx<'_>, arg: &Expr) -> Result, group: &RootedGroup<'_>) -> String { + match self { + Self::Value(value) => value.clone(), + Self::Array(acc) => { + let handle = group.read_array(ctx, *acc); + nanbox_pointer_inline(ctx.block(), &handle) + } + } + } +} + +fn pack_imported_args_array( + ctx: &mut FnCtx<'_>, + group: &mut RootedGroup<'_>, + args: &[String], +) -> AccArray { + let cap = args.len().to_string(); + let acc = group.begin_array(ctx, &cap); + for value in args { + group.push_array(ctx, acc, value); + } + acc +} + /// Marshal the lowered `new`-site args into the value list a cross-module /// imported constructor symbol expects. The source module compiled the /// standalone `_constructor(this, p0, …)` with `ctor.param_count` @@ -312,40 +343,52 @@ pub(super) fn lower_constructor_arg(ctx: &mut FnCtx<'_>, arg: &Expr) -> Result, ctor: &crate::codegen::ImportedCtor, lowered_args: &[String], -) -> Vec { + group: &mut RootedGroup<'_>, +) -> Vec { let undef = double_literal(f64::from_bits(crate::nanbox::TAG_UNDEFINED)); let param_count = ctor.param_count; let trailing = usize::from(ctor.has_rest) + usize::from(ctor.has_synthetic_arguments); if trailing > 0 && param_count >= trailing { let n_positional = param_count - trailing; - let mut out: Vec = Vec::with_capacity(param_count); + let mut out = Vec::with_capacity(param_count); for i in 0..n_positional { - out.push( + out.push(ImportedCtorArg::Value( lowered_args .get(i) .cloned() .unwrap_or_else(|| undef.clone()), - ); + )); } if ctor.has_rest { let tail: Vec = lowered_args.iter().skip(n_positional).cloned().collect(); - out.push(pack_lowered_args_array(ctx, &tail)); + out.push(ImportedCtorArg::Array(pack_imported_args_array( + ctx, group, &tail, + ))); } if ctor.has_synthetic_arguments { - out.push(pack_lowered_args_array(ctx, lowered_args)); + out.push(ImportedCtorArg::Array(pack_imported_args_array( + ctx, + group, + lowered_args, + ))); } out } else { // No rest: positional, padded to `param_count` with `undefined`. - let mut out: Vec = lowered_args.to_vec(); + let mut out: Vec<_> = lowered_args + .iter() + .cloned() + .map(ImportedCtorArg::Value) + .collect(); while out.len() < param_count { - out.push(undef.clone()); + out.push(ImportedCtorArg::Value(undef.clone())); } // #6537 review: `param_count.max(out.len())` made this a no-op, so a // call site passing MORE args than the imported ctor's fixed arity diff --git a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs index 38467b8aaf..5c47220f40 100644 --- a/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs +++ b/crates/perry-codegen/src/lower_call/property_get/dynamic_dispatch.rs @@ -454,7 +454,12 @@ pub(crate) fn try_lower_instance_method_call( .unwrap_or_else(|| ctx.block_label(own_idx)); let blk = ctx.block(); let cid_ok = blk.icmp_eq(I32, &cid, &class_id.to_string()); - let shape_ok = blk.icmp_eq(I32, &shape_id, expected_shape); + let shape_ok = crate::typed_shape::emit_compatible_shape_eq( + blk, + &shape_id, + expected_shape, + &[], + ); let exact = blk.and(I1, &cid_ok, &shape_ok); blk.cond_br(&exact, &probe_dispatch_label, &miss_label); } diff --git a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs index 40f4fb41e4..2eca37b443 100644 --- a/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs +++ b/crates/perry-codegen/src/lower_call/typed_shape_bake_tests.rs @@ -32,6 +32,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/module.rs b/crates/perry-codegen/src/module.rs index 6b9476d5b1..8c103ee367 100644 --- a/crates/perry-codegen/src/module.rs +++ b/crates/perry-codegen/src/module.rs @@ -323,12 +323,16 @@ impl LlModule { note(self.fn_infos.borrow_mut().facts_mut(body)); } + pub(crate) fn request_static_seed_body(&mut self, body: &str) { + self.fn_infos.borrow_mut().request_static_seed_body(body); + } + /// Emit the module's `JsFunctionInfo` globals (`crate::fn_info`): one /// definition per body this module defines that is allocated here, has /// recorded facts, or is an external-linkage value wrapper another module /// may allocate; an `external` declaration for every allocated body /// another module defines. Runs once, after every function exists. - pub(crate) fn emit_fn_infos(&mut self) { + pub(crate) fn emit_fn_infos(&mut self, permanent_image: bool) { let lines = { let functions = &self.functions; let by_name: std::collections::HashMap<&str, &LlFunction> = @@ -348,6 +352,7 @@ impl LlModule { }) }, exported, + permanent_image, ) }; self.globals.extend(lines); diff --git a/crates/perry-codegen/src/native_root_coverage/mod.rs b/crates/perry-codegen/src/native_root_coverage/mod.rs index 66249d3ae8..fc18b2bc1f 100644 --- a/crates/perry-codegen/src/native_root_coverage/mod.rs +++ b/crates/perry-codegen/src/native_root_coverage/mod.rs @@ -121,6 +121,7 @@ pub(crate) fn ir_opts(target: &str, is_entry: bool) -> CompileOptions { target: Some(target.to_string()), is_entry_module: is_entry, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs index 9087e5c226..1ad1becf06 100644 --- a/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs +++ b/crates/perry-codegen/src/runtime_decls/stdlib_ffi/third_party.rs @@ -48,6 +48,17 @@ pub(crate) fn declare_third_party(module: &mut LlModule) { module.declare_function("js_thread_parallel_map", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_thread_parallel_filter", DOUBLE, &[DOUBLE, DOUBLE]); module.declare_function("js_thread_spawn", DOUBLE, &[DOUBLE]); + module.declare_function( + "js_thread_parallel_map_with_literals", + DOUBLE, + &[DOUBLE, DOUBLE, I64], + ); + module.declare_function( + "js_thread_parallel_filter_with_literals", + DOUBLE, + &[DOUBLE, DOUBLE, I64], + ); + module.declare_function("js_thread_spawn_with_literals", DOUBLE, &[DOUBLE, I64]); // Immutable module-global leaves (codegen/global_transfer.rs): publication // cell, current-agent cache and canonical slot addresses. module.declare_function("js_thread_global_publish", VOID, &[I64, I64, I64]); diff --git a/crates/perry-codegen/src/runtime_decls/strings.rs b/crates/perry-codegen/src/runtime_decls/strings.rs index e483564534..99a45004a6 100644 --- a/crates/perry-codegen/src/runtime_decls/strings.rs +++ b/crates/perry-codegen/src/runtime_decls/strings.rs @@ -1136,7 +1136,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { module.declare_function( "js_region_loop_prime", I64, - &[PTR, I32, I32, I64, I64, I64, I64, I64, I32, I32, I32], + &[PTR, I32, I32, I64, I64, I64, I64, I64, I32, I32, I32, I32], ); // Design step 4: the per-class mint with the driver's static id, and the // literal-shape seed. @@ -1145,6 +1145,16 @@ pub fn declare_phase_b_strings(module: &mut LlModule) { I32, &[I64, I32, I32, I32, I32, I64], ); + module.declare_function( + "js_object_final_shape_id_for_class_keys_static_constfn", + I32, + &[I64, I32, I32, I32, I32, I64, PTR, I32], + ); + module.declare_function( + "js_object_finalize_constfn_static", + I64, + &[I64, I32, PTR, I32, I32, I32, I32, I64, PTR, I32], + ); module.declare_function("js_shape_seed_plain", I32, &[I32, PTR, I32, I32, I32, I64]); module.declare_function("js_shape_register_static_seed", VOID, &[PTR]); module.declare_function("js_shape_run_static_seed", VOID, &[]); diff --git a/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs b/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs index b6c4d04c79..2e77a2ab1d 100644 --- a/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs +++ b/crates/perry-codegen/src/stmt/boxed_slot_no_root_tests.rs @@ -11,6 +11,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs index 683839206f..ec3955dd67 100644 --- a/crates/perry-codegen/src/stmt/class_field_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/class_field_loop_tests.rs @@ -1,29 +1,9 @@ -//! #7287: the #5093 class-field versioned loop must actually be REACHED. -//! -//! `lower_class_field_versioned_for` (`stmt/loops.rs`) hoists a monomorphic -//! `this.field` shape check into a loop preheader and runs a guard-free, -//! call-free fast clone. It was written for `benchmarks/suite/09_method_calls.ts` -//! and it is worth ~9× on it. It also matched **nothing** for months, in either -//! configuration, and nothing noticed: -//! -//! * with representation-selection Phase 1 on (the default), a proven-integer -//! loop counter's *only* storage is its canonical i32 slot — it has no -//! `ctx.locals` entry — and the matcher gated its counter and its bound on -//! `ctx.locals.contains_key(..)`; -//! * with Phase 1 off, the counter regains its `ctx.locals` entry but a bare -//! `i++` counter never earns an i32 *shadow*, which the lowering separately -//! requires. -//! -//! Every existing signal scored it as working. The lowering compiles, the -//! matcher is exercised by no test, `09_method_calls` still printed the right -//! answer, and the emitted object still differed from an unoptimised build (by -//! the *other* class-field lowerings). Only asserting that the versioned blocks -//! appear in the emitted IR distinguishes "implemented" from "reached" — see -//! CLAUDE.md, "a gate must assert its subject was live". -//! -//! So these tests assert on emitted block labels, and every one of them -//! requires the fast clone AND its guard-free store together: a preheader that -//! is emitted but branched around would still print `class_field.loop.*`. +//! P8 replacement IR checks for the original literal/module-bound and strict +//! class increment shapes. Runtime, hostile-value, and cost acceptance remains +//! separate and must use the unchanged original TypeScript fixtures. + +#[path = "ptr_shape_region_report_tests.rs"] +mod ptr_shape_region_report_tests; use crate::{compile_module, AppMetadata, CompileOptions}; use perry_hir::types::Type; @@ -38,6 +18,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), @@ -248,81 +229,61 @@ fn emit(m: &Module) -> String { String::from_utf8(compile_module(m, ir_opts()).unwrap()).expect("LLVM IR should be UTF-8") } -/// Both halves of the transform, asserted together. -/// -/// `class_field.loop.fast.preheader` alone would pass on a lowering that emits -/// the versioned skeleton and then unconditionally branches to the slow clone -/// (which is exactly what `lower_class_field_versioned_for` does when the fast -/// clone turns out not to be call-free). The guard-free store block is the part -/// that only exists when the fast clone was really entered, and the hoisted -/// preheader check is what makes it sound — so require all three. +/// P8: a numeric class-field module loop must use the generic guarded F/G body. +/// Runtime route/shape/store attribution is checked separately by the isolated +/// executable. These IR assertions never certify retained-tier cost parity. fn assert_versioned_loop_lowered(ir: &str, what: &str) { for label in [ - "class_field.loop.fast.preheader", - "class_field_loop.preheader.deref", - "class_field_loop_store.sloppy_fast", + "rloop.guard.", + "rloop.fast", + "rloop.join", + "rloop.version.plain", ] { assert!( ir.contains(label), - "{what}: expected the #5093 class-field versioned loop to be lowered, \ - but `{label}` is absent from the emitted IR. The matcher in \ - stmt/loops.rs declined — check that the loop counter and bound are \ - still admitted through `local_has_readable_slot` (repsel Phase 1 \ - stores a proven-integer local ONLY in its canonical i32 slot, with \ - no `ctx.locals` entry). See #7287." + "{what}: missing generic replacement `{label}`" ); } - // The slow clone must survive as the cold arm: it is what a receiver that - // fails the preheader check (frozen, descriptor-bearing, wrong class) and - // every mid-loop store side exit falls into. - assert!( - ir.contains("for.class_field_slow.cond"), - "{what}: the versioned loop's SLOW clone is missing — a hoisted guard \ - with no fallback arm is worse than no hoist at all" - ); - // #7480 step 4: the clone must be ENTERED, not merely emitted. The lowering - // builds the fast clone first and proves it call-free second; on a failed - // proof it terminates the guard with an UNCONDITIONAL branch to the slow - // clone and leaves the fast blocks as unreachable code — a state in which - // every label assertion above still passes. The twin assertion on the - // element-shape clone caught exactly that: #7690's back-edge polls put a - // `js_gc_loop_safepoint()` inside the clone and silently deleted it. + for label in [ + "class_field.loop.", + "class_field_loop.", + "class_field_loop_store.", + "for.class_field_fast", + "for.class_field_slow", + ] { + assert!(!ir.contains(label), "{what}: legacy tier remains `{label}`"); + } + let fast = ir + .lines() + .skip_while(|line| !line.starts_with("rloop.fast")) + .take_while(|line| !line.starts_with("rloop.join")) + .collect::>() + .join("\n"); assert!( - ir.contains("label %for.class_field_fast.cond") - || ir.contains("label %class_field.loop.fast.preheader"), - "{what}: the guard must branch INTO the fast clone. If it ends in an \ - unconditional branch to the slow clone, the call-free proof failed and \ - the clone is dead code that every label assertion above still accepts" + fast.contains("load double") + && fast.contains("fadd double") + && fast.contains("store double") + && ir + .lines() + .any(|line| line.contains("br i1 ") && line.contains("label %rloop.version.split")), + "{what}: reachable generic F must read, add and commit the increment" ); - // The fast clone must be free of the per-access diamond it exists to - // replace: no volatile gate load between the fast preheader and the store. - let fast = fast_clone_slice(ir); assert!( - !fast.contains("@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"), - "{what}: the fast clone still reads the per-access inline-guard gate; \ - the whole point of the preheader check is that it does not" + !fast.contains("@js_class_field_") + && !fast.contains("@js_object_get_field") + && !fast.contains("@js_number_coerce") + && !fast.contains("@js_dynamic_string_or_number_add") + && !fast.contains("@js_put_value"), + "{what}: typed class read must consume exact R, not its ordinary guard" ); assert!( - !fast.contains("js_typed_feedback_class_field"), - "{what}: the fast clone still calls the class-field guard; it must be \ - call-free (call-free ⇒ allocation-free ⇒ no GC ⇒ the preheader's \ - cached object pointer stays valid)" + ir.lines() + .any(|line| line.contains("call i64 @js_region_loop_prime(") + && line.contains("i32 1, i32 0, i32 1)")), + "{what}: increment must request stored=1, boxed=0 and R=1" ); } -/// The emitted text from the fast clone's condition block up to the slow -/// clone's, i.e. exactly the blocks the fast copy owns. -fn fast_clone_slice(ir: &str) -> &str { - let start = ir - .find("for.class_field_fast.cond") - .expect("fast clone cond block"); - let end = ir[start..] - .find("for.class_field_slow.cond") - .map(|off| start + off) - .unwrap_or(ir.len()); - &ir[start..end] -} - /// The exact `09_method_calls` shape: an integer-literal bound. #[test] fn class_field_versioned_loop_fires_for_literal_bound() { @@ -355,11 +316,9 @@ fn class_field_versioned_loop_fires_for_module_scope_counter() { assert_versioned_loop_lowered(&ir, "module-scope const bound"); } -/// STRICT module scope takes a different store lowering -/// (`put_value_static_property_fast_path` → `property_set::lower`), which has -/// carried its own loop-fact branch since #5093. Both arms must reach the fast -/// clone, or an ESM/CJS difference silently changes which one a file gets — -/// the same class of path-dependence #7288 was. +/// STRICT module scope takes a different ordinary store lowering +/// (`put_value_static_property_fast_path` → `property_set::lower`). Both modes +/// must consume the same guarded region store proof in F. #[test] fn class_field_versioned_loop_fires_in_strict_mode() { let ir = emit(&method_calls_module( @@ -367,18 +326,69 @@ fn class_field_versioned_loop_fires_in_strict_mode() { Vec::new(), true, )); - for label in [ - "class_field.loop.fast.preheader", - "class_field_loop.preheader.deref", - "class_field_loop_store.fast", - ] { - assert!( - ir.contains(label), - "strict mode: expected `{label}` in the emitted IR (#7287)" - ); - } + assert_versioned_loop_lowered(&ir, "strict mode"); +} + +/// Replacement's scoped suppression must not erase the pre-existing receiver +/// proof after F/G joins. The subsequent read still uses a direct Ptr +/// load, but keeps the ordinary boxed-value/coercion check: R must not escape. +#[test] +fn class_loop_replacement_restores_straight_line_receiver_proof() { + let mut module = method_calls_module(Expr::Integer(200), Vec::new(), false); + module.init.push(Stmt::Expr(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "value".to_string(), + byte_offset: 0, + }), + right: Box::new(Expr::Integer(2)), + })); + let ir = emit(&module); + assert_versioned_loop_lowered(&ir, "subsequent read"); + let post = ir + .lines() + .skip_while(|line| !line.starts_with("rloop.version.merge")) + .skip(1) + .take_while(|line| !line.is_empty()) + .collect::>() + .join("\n"); assert!( - !fast_clone_slice(&ir).contains("@PERRY_CLASS_FIELD_INLINE_GUARD_DISABLED"), - "strict mode: the fast clone still reads the per-access gate" + post.contains("load double") + && post.contains("label %ptr_shape_get_number.coerce") + && !post.contains("@js_class_field_") + && !post.contains("class_field_inline"), + "post-loop shape proof must return without leaking Number R:\n{post}" + ); +} + +/// The removed twin admitted only a single expression. Region admission must +/// come from its own effect and representation proof, without keeping that +/// old syntactic matcher as a second authority. +#[test] +fn numeric_class_region_accepts_multiple_commits() { + let mut module = method_calls_module(Expr::Integer(200), Vec::new(), false); + let body = module + .init + .iter_mut() + .find_map(|stmt| match stmt { + Stmt::For { body, .. } => Some(body), + _ => None, + }) + .expect("fixture must contain the original increment loop"); + let increment = body[0].clone(); + body.push(increment); + let ir = emit(&module); + assert_versioned_loop_lowered(&ir, "multiple numeric commits"); + let fast = ir + .lines() + .skip_while(|line| !line.starts_with("rloop.fast")) + .take_while(|line| !line.starts_with("rloop.join")) + .collect::>() + .join("\n"); + assert_eq!( + fast.matches("fadd double").count(), + 2, + "both increment expressions must consume the same guarded numeric lane" ); } diff --git a/crates/perry-codegen/src/stmt/element_shape_loop.rs b/crates/perry-codegen/src/stmt/element_shape_loop.rs index 65f252ef4a..b497315746 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop.rs @@ -148,8 +148,7 @@ use perry_hir::Stmt; use super::loops::{ emit_js_value_is_number, local_bound_is_loop_invariant, local_has_readable_slot, loop_counter_bounds_are_safe, loop_counter_entry_i32_range_is_safe, lower_for_after_init, - lower_for_after_init_with_i32_bound, CLASS_FIELD_LOOP_CLASS_DENYLIST, - CLASS_FIELD_LOOP_PROP_DENYLIST, + lower_for_after_init_with_i32_bound, ELEMENT_SHAPE_CLASS_DENYLIST, ELEMENT_SHAPE_PROP_DENYLIST, }; use crate::expr::{lower_expr, FnCtx}; use crate::types::{DOUBLE, I1, I32, I64}; @@ -1264,7 +1263,7 @@ fn match_element_shape_versioned_loop( // the spec, wherever those two differ. const SHAPE_PROP_DENYLIST: &[&str] = &["__proto__"]; let denylist = match identity { - ElementShapeIdentity::Class { .. } => CLASS_FIELD_LOOP_PROP_DENYLIST, + ElementShapeIdentity::Class { .. } => ELEMENT_SHAPE_PROP_DENYLIST, ElementShapeIdentity::Shape => SHAPE_PROP_DENYLIST, }; for prop in &facts.props { @@ -1348,7 +1347,7 @@ fn match_class_identity( class_name: &str, props: &std::collections::BTreeSet, ) -> Option { - if CLASS_FIELD_LOOP_CLASS_DENYLIST.contains(&class_name) { + if ELEMENT_SHAPE_CLASS_DENYLIST.contains(&class_name) { return None; } let class = ctx.classes.get(class_name)?; @@ -1456,7 +1455,7 @@ fn materialize_loop_i32( } /// Lower the matched loop as a guarded fast clone plus the unchanged generic -/// body, modeled on `lower_class_field_versioned_for`. +/// body, with the guard and call-free clone sharing one dynamic extent. /// /// SAFETY (miscompile class — see the module docs): between the preheader's /// post-guard re-derivation of the elements base pointer and the end of the diff --git a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs index e8e815782a..d1b10db7c6 100644 --- a/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs +++ b/crates/perry-codegen/src/stmt/element_shape_loop_tests.rs @@ -28,6 +28,7 @@ fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/loops.rs b/crates/perry-codegen/src/stmt/loops.rs index f10e8c6000..66eefdb5d9 100644 --- a/crates/perry-codegen/src/stmt/loops.rs +++ b/crates/perry-codegen/src/stmt/loops.rs @@ -3923,12 +3923,12 @@ fn lower_packed_f64_range_versioned_for( Ok(true) } -/// #5093: property names with dedicated branches in the property-get/set +/// Element-shape field access restrictions: property names with dedicated branches in the property-get/set /// lowering dispatch ahead of the class-field diamond (`length` header loads, /// `errors` runtime call, accessor-ish names, …). A tracked field must not /// collide or the fast clone's access would lower through a different — /// possibly calling — path, breaking the call-free guarantee. -pub(super) const CLASS_FIELD_LOOP_PROP_DENYLIST: &[&str] = &[ +pub(super) const ELEMENT_SHAPE_PROP_DENYLIST: &[&str] = &[ "length", "errors", "size", @@ -3944,10 +3944,10 @@ pub(super) const CLASS_FIELD_LOOP_PROP_DENYLIST: &[&str] = &[ "valueOf", ]; -/// #5093: class names with dedicated (builtin-flavored) branches in the +/// Element-shape field access restrictions: class names with dedicated (builtin-flavored) branches in the /// property lowering dispatch; a user class sharing one of these names could /// be intercepted before the class-field diamond. -pub(super) const CLASS_FIELD_LOOP_CLASS_DENYLIST: &[&str] = &[ +pub(super) const ELEMENT_SHAPE_CLASS_DENYLIST: &[&str] = &[ "Headers", "URLPattern", "ClientRequest", @@ -5438,511 +5438,6 @@ fn lower_object_array_write_versioned_for( Ok(true) } -#[derive(Clone, Copy)] -enum ClassFieldLoopBound { - /// `i < `. - Constant(i64), - /// `i < b` where `b` is a loop-invariant plain local or module global. - Local(u32), -} - -struct ClassFieldVersionedLoop { - counter_id: u32, - bound: ClassFieldLoopBound, - recv_id: u32, - class_name: String, - expected_class_id: u32, - keys_global_name: String, - /// property -> (packed slot index, written). All raw-f64 candidates. - fields: std::collections::BTreeMap, -} - -/// #5093: effect-free expression walk for the class-field versioned loop. -/// Tracked `recv.prop` reads, numeric locals, numeric literals and pure -/// arithmetic/Math only — the same shapes `packed_f64_range_loop_pure_expr_ -/// collect` admits, minus array accesses, plus class-field reads. Everything -/// here must lower without emitting a call that can allocate (libm intrinsic -/// calls are fine: they cannot trigger a GC). -fn class_field_loop_pure_expr_collect( - ctx: &FnCtx<'_>, - expr: &perry_hir::Expr, - counter_id: u32, - recv: &mut Option, - props: &mut std::collections::BTreeMap, -) -> bool { - use perry_hir::Expr; - match expr { - Expr::PropertyGet { - object, property, .. - } => { - let Expr::LocalGet(obj_id) = object.as_ref() else { - return false; - }; - if *obj_id == counter_id { - return false; - } - match recv { - Some(r) if *r == *obj_id => {} - Some(_) => return false, // single receiver per loop - None => *recv = Some(*obj_id), - } - props.entry(property.clone()).or_insert(false); - true - } - // Reading the receiver as a VALUE (outside a tracked field access) - // could flow it into arbitrary lowering; only allow scalar reads the - // type analysis proves numeric. - Expr::LocalGet(id) => { - recv.map_or(true, |r| r != *id) && crate::type_analysis::is_numeric_expr(ctx, expr) - } - Expr::Number(_) | Expr::Integer(_) => true, - Expr::Binary { left, right, .. } => { - crate::type_analysis::is_numeric_expr(ctx, expr) - && class_field_loop_pure_expr_collect(ctx, left, counter_id, recv, props) - && class_field_loop_pure_expr_collect(ctx, right, counter_id, recv, props) - } - Expr::NumberCoerce(operand) => { - class_field_loop_pure_expr_collect(ctx, operand, counter_id, recv, props) - } - Expr::MathImul(left, right) | Expr::MathPow(left, right) => { - class_field_loop_pure_expr_collect(ctx, left, counter_id, recv, props) - && class_field_loop_pure_expr_collect(ctx, right, counter_id, recv, props) - } - Expr::MathMin(values) | Expr::MathMax(values) => values - .iter() - .all(|expr| class_field_loop_pure_expr_collect(ctx, expr, counter_id, recv, props)), - Expr::MathAbs(value) - | Expr::MathSqrt(value) - | Expr::MathFloor(value) - | Expr::MathCeil(value) - | Expr::MathRound(value) - | Expr::MathTrunc(value) - | Expr::MathSign(value) - | Expr::MathF16round(value) => { - class_field_loop_pure_expr_collect(ctx, value, counter_id, recv, props) - } - _ => false, - } -} - -/// #5093: class-field versioned loop — the "collapse" this issue tracks. -/// -/// Matches `for (let i = k0; i < B; i++) ` where `B` is an -/// integer literal or a loop-invariant local/module-global and the statement's -/// only side effect is a raw-f64 class-field store on a loop-invariant -/// receiver of statically known class (or a scalar `LocalSet` accumulator), -/// with every other subexpression pure per the walker above. -/// -/// The single-statement / effect-last restriction is the side-exit protocol -/// (same as the #6011 range loop): the fast clone's only mid-loop bail is the -/// store's inline plain-finite value check, which fires BEFORE the store — so -/// jumping to the slow clone's preheader re-executes the current iteration -/// without duplicating any effect. -fn match_class_field_versioned_loop( - ctx: &FnCtx<'_>, - init: Option<&Stmt>, - condition: Option<&perry_hir::Expr>, - update: Option<&perry_hir::Expr>, - body: &[Stmt], -) -> Option { - use perry_hir::{CompareOp, Expr, UpdateOp}; - // Oversized modules full-outline the class-field diamonds for code size; - // keep the versioned clone (which would re-inline them) off there. - if crate::codegen::full_outline_ic_enabled() { - return None; - } - if !ctx.pending_labels.is_empty() { - return None; - } - let (counter_id, start) = match init? { - Stmt::Let { - id, - init: Some(init_expr), - .. - } => { - let start = match init_expr { - Expr::Integer(n) => *n, - Expr::Number(n) if n.is_finite() && n.fract() == 0.0 => *n as i64, - _ => return None, - }; - (*id, start) - } - _ => return None, - }; - if !(0..=i64::from(i32::MAX)).contains(&start) { - return None; - } - let (op, left, right) = match condition? { - Expr::Compare { op, left, right } => (*op, left.as_ref(), right.as_ref()), - _ => return None, - }; - if !matches!(op, CompareOp::Lt) || !matches!(left, Expr::LocalGet(id) if *id == counter_id) { - return None; - } - let bound = match right { - Expr::Integer(k) if (0..=i64::from(i32::MAX)).contains(k) => { - ClassFieldLoopBound::Constant(*k) - } - Expr::LocalGet(bound_id) if *bound_id != counter_id => { - if ctx.boxed_vars.contains(bound_id) { - return None; - } - if !local_has_readable_slot(ctx, *bound_id) - && !ctx.module_globals.contains_key(bound_id) - { - return None; - } - if !local_bound_is_loop_invariant(condition?, update, body, *bound_id) { - return None; - } - ClassFieldLoopBound::Local(*bound_id) - } - _ => return None, - }; - if !matches!( - update?, - Expr::Update { - id, - op: UpdateOp::Increment, - .. - } if *id == counter_id - ) { - return None; - } - if !local_has_readable_slot(ctx, counter_id) - || ctx.boxed_vars.contains(&counter_id) - || !ctx.integer_locals.contains(&counter_id) - || !loop_counter_bounds_are_safe(ctx, counter_id, update, body) - || !loop_counter_entry_i32_range_is_safe(init, counter_id) - { - return None; - } - - // Single-statement body whose only side effect commits after every - // potential side exit. - let [Stmt::Expr(effect)] = body else { - return None; - }; - let mut recv: Option = None; - let mut props: std::collections::BTreeMap = std::collections::BTreeMap::new(); - match effect { - // `recv.prop = ` — the benchmark shape. Lowering - // rewrites the static-key PutValueSet through the PropertySet - // class-field diamond (`put_value_static_property_fast_path`). - Expr::PutValueSet { - target, - key, - value, - receiver, - .. - } => { - let (Expr::LocalGet(t), Expr::LocalGet(r)) = (target.as_ref(), receiver.as_ref()) - else { - return None; - }; - if t != r { - return None; - } - // Keep this class-field clone's existing string-only contract; - // integer keys are handled by the general object-write matcher. - let prop = crate::expr::proxy_reflect::static_string_write_key(ctx, key.as_ref())?; - recv = Some(*t); - if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { - return None; - } - props - .entry(prop) - .and_modify(|written| *written = true) - .or_insert(true); - } - Expr::PropertySet { - object, - property, - value, - } => { - let Expr::LocalGet(obj_id) = object.as_ref() else { - return None; - }; - recv = Some(*obj_id); - if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { - return None; - } - props - .entry(property.clone()) - .and_modify(|written| *written = true) - .or_insert(true); - } - // Scalar accumulator: `acc = `. No - // store side exit exists, so re-execution can never happen; the - // LocalSet itself must still target a plain numeric non-shadow local. - Expr::LocalSet(id, value) => { - if *id == counter_id - || !ctx.locals.contains_key(id) - || ctx.boxed_vars.contains(id) - || ctx.module_globals.contains_key(id) - || ctx.shadow_slot_map.contains_key(id) - || !crate::type_analysis::is_numeric_expr(ctx, &Expr::LocalGet(*id)) - { - return None; - } - if !class_field_loop_pure_expr_collect(ctx, value, counter_id, &mut recv, &mut props) { - return None; - } - if recv == Some(*id) { - return None; - } - if let ClassFieldLoopBound::Local(bound_id) = bound { - if bound_id == *id { - return None; - } - } - } - _ => return None, - } - let recv_id = recv?; - if props.is_empty() || recv_id == counter_id { - return None; - } - if let ClassFieldLoopBound::Local(bound_id) = bound { - if bound_id == recv_id { - return None; - } - } - - // Receiver: loop-invariant, directly addressable, not aliased by another - // representation (POD / scalar replacement take different lowering paths). - if ctx.boxed_vars.contains(&recv_id) - || ctx.pod_records.contains_key(&recv_id) - || ctx.scalar_replaced.contains_key(&recv_id) - { - return None; - } - if !ctx.locals.contains_key(&recv_id) && !ctx.module_globals.contains_key(&recv_id) { - return None; - } - if !local_bound_is_loop_invariant(condition?, update, body, recv_id) { - return None; - } - let class_name = - crate::type_analysis::receiver_class_name(ctx, &perry_hir::Expr::LocalGet(recv_id))?; - if CLASS_FIELD_LOOP_CLASS_DENYLIST.contains(&class_name.as_str()) { - return None; - } - let class = ctx.classes.get(&class_name)?; - if !class.computed_members.is_empty() { - return None; - } - let expected_class_id = *ctx.class_ids.get(&class_name)?; - let keys_global_name = ctx.class_keys_globals.get(&class_name)?.clone(); - - let mut fields = std::collections::BTreeMap::new(); - for (prop, written) in props { - if CLASS_FIELD_LOOP_PROP_DENYLIST.contains(&prop.as_str()) { - return None; - } - // Accessors route through synthesized __get_/__set_ methods before - // the class-field diamond; `class_field_global_index` also rejects - // accessor-shadowed names, but mirror the dispatch gate exactly. - if ctx - .methods - .contains_key(&(class_name.clone(), format!("__get_{prop}"))) - || ctx - .methods - .contains_key(&(class_name.clone(), format!("__set_{prop}"))) - { - return None; - } - let field_index = crate::type_analysis::class_field_global_index(ctx, &class_name, &prop)?; - let raw_f64 = crate::expr::class_field_inline_guard::class_field_site_raw_f64( - ctx, - &class_name, - &prop, - field_index, - ); - if !raw_f64 { - return None; - } - fields.insert(prop, (field_index, written)); - } - - Some(ClassFieldVersionedLoop { - counter_id, - bound, - recv_id, - class_name, - expected_class_id, - keys_global_name, - fields, - }) -} - -/// #5093: lowering for [`match_class_field_versioned_loop`], modeled on -/// [`lower_packed_f64_range_versioned_for`]. The bound is materialized to i32 -/// once (with a finite-integral check for local/global bounds), the inline -/// class-field shape check runs once in the preheader, and the fast clone -/// lowers with a scoped [`crate::expr::ClassFieldLoopFact`] so every tracked -/// field access is a bare GEP load/store on the preheader-cached object -/// pointer. Store side exits resume at the current `i` in the slow clone. -/// -/// SAFETY (memory-corruption class — see #5093): between the preheader's -/// receiver load and the end of the fast clone, NO call may be emitted. The -/// matcher enforces this by shape (single pure-arithmetic statement, all -/// field accesses tracked, counter/bound machinery call-free); the preheader -/// itself emits only bit ops, loads, and the finite-integral bound checks. -/// Call-free ⇒ allocation-free ⇒ no GC ⇒ the object cannot move and none of -/// the checked shape facts can change while the fast clone runs. -fn lower_class_field_versioned_for( - ctx: &mut FnCtx<'_>, - init: Option<&Stmt>, - condition: Option<&perry_hir::Expr>, - update: Option<&perry_hir::Expr>, - body: &[Stmt], -) -> Result { - let Some(matched) = match_class_field_versioned_loop(ctx, init, condition, update, body) else { - return Ok(false); - }; - // The fast clone's cond reads the counter through its i32 slot; without - // one the versioned copy would win nothing. - if !ctx.i32_counter_slots.contains_key(&matched.counter_id) { - return Ok(false); - } - - let fast_pre_idx = ctx.new_block("class_field.loop.fast.preheader"); - let slow_pre_idx = ctx.new_block("class_field.loop.slow.preheader"); - let merge_idx = ctx.new_block("class_field.loop.merge"); - let fast_pre_label = ctx.block_label(fast_pre_idx); - let slow_pre_label = ctx.block_label(slow_pre_idx); - let merge_label = ctx.block_label(merge_idx); - - // One-time i32 materialization of the bound (mirrors the #6011 range - // loop): non-number / NaN / fractional / out-of-range bounds keep full JS - // trip-count semantics in the slow clone. - let bound_i32: String = match matched.bound { - ClassFieldLoopBound::Constant(k) => k.to_string(), - ClassFieldLoopBound::Local(bound_id) => { - let bound_d = lower_expr(ctx, &perry_hir::Expr::LocalGet(bound_id))?; - let is_number = emit_js_value_is_number(ctx, &bound_d); - let range_idx = ctx.new_block("class_field.loop.bound.range"); - let convert_idx = ctx.new_block("class_field.loop.bound.convert"); - let check_idx = ctx.new_block("class_field.loop.shape_check"); - let range_label = ctx.block_label(range_idx); - let convert_label = ctx.block_label(convert_idx); - let check_label = ctx.block_label(check_idx); - ctx.block() - .cond_br(&is_number, &range_label, &slow_pre_label); - - ctx.current_block = range_idx; - let ge_zero = ctx.block().fcmp("oge", &bound_d, "0.0"); - let le_max = { - let max_literal = format!("{:.1}", i32::MAX as f64); - ctx.block().fcmp("ole", &bound_d, &max_literal) - }; - let in_range = ctx.block().and(I1, &ge_zero, &le_max); - ctx.block() - .cond_br(&in_range, &convert_label, &slow_pre_label); - - ctx.current_block = convert_idx; - let bound_i32 = ctx.block().fptosi(DOUBLE, &bound_d, I32); - let roundtrip = ctx.block().sitofp(I32, &bound_i32, DOUBLE); - let is_integral = ctx.block().fcmp("oeq", &roundtrip, &bound_d); - ctx.block() - .cond_br(&is_integral, &check_label, &slow_pre_label); - - ctx.current_block = check_idx; - bound_i32 - } - }; - - // Receiver load + hoisted shape check. From here to loop entry the - // emitted IR is call-free, so the pointer the check validates is the - // pointer the fast clone uses. - let recv_box = lower_expr(ctx, &perry_hir::Expr::LocalGet(matched.recv_id))?; - let expected_shape_id = crate::typed_shape::class_shape_id_operand( - ctx, - &matched.class_name, - &matched.keys_global_name, - ); - let (obj_bits, obj_handle) = { - let blk = ctx.block(); - let obj_bits = blk.bitcast_double_to_i64(&recv_box); - let obj_handle = blk.and(I64, &obj_bits, crate::nanbox::POINTER_MASK_I64); - (obj_bits, obj_handle) - }; - let has_store = matched.fields.values().any(|(_, written)| *written); - let expected_class_id_str = matched.expected_class_id.to_string(); - let (obj_ptr, shape_ok) = - crate::expr::class_field_inline_guard::emit_class_field_loop_preheader_check( - ctx, - &obj_bits, - &obj_handle, - &expected_class_id_str, - &expected_shape_id, - has_store, - &slow_pre_label, - ); - // The deref block is left unterminated on purpose: it branches into the - // fast clone only after the clone is PROVEN call-free below. - let deref_idx = ctx.current_block; - - let scope_id = ctx.next_loop_proof_scope_id(); - let fast_scan_start = ctx.func.num_blocks(); - ctx.current_block = fast_pre_idx; - ctx.class_field_loop_facts - .push(crate::expr::ClassFieldLoopFact { - recv_local_id: matched.recv_id, - scope_id, - class_name: matched.class_name.clone(), - obj_ptr, - side_exit_label: slow_pre_label.clone(), - fields: matched - .fields - .iter() - .map(|(prop, (field_index, _))| (prop.clone(), *field_index)) - .collect(), - }); - lower_for_after_init_with_i32_bound( - ctx, - init, - condition, - update, - body, - "for.class_field_fast", - Some((matched.counter_id, bound_i32)), - )?; - ctx.class_field_loop_facts - .retain(|fact| fact.scope_id != scope_id); - if !ctx.block().is_terminated() { - ctx.block().br(&merge_label); - } - let fast_scan_end = ctx.func.num_blocks(); - - // Compile-time verification of the safety invariant: the fast clone must - // be call-free (no runtime call ⇒ no allocation ⇒ no GC ⇒ the cached - // `obj_ptr` cannot move and the hoisted shape check stays true). The - // matcher makes this true by construction; if some unpredicted lowering - // path emitted a call anyway, never enter the fast clone — run the slow - // clone unconditionally and leave the fast blocks as unreachable code. - let fast_clone_call_free = !ctx.func.blocks()[fast_pre_idx].contains_gc_unsafe_call() - && (fast_scan_start..fast_scan_end) - .all(|idx| !ctx.func.blocks()[idx].contains_gc_unsafe_call()); - ctx.current_block = deref_idx; - if fast_clone_call_free { - ctx.block() - .cond_br(&shape_ok, &fast_pre_label, &slow_pre_label); - } else { - ctx.block().br(&slow_pre_label); - } - - ctx.current_block = slow_pre_idx; - lower_for_after_init(ctx, init, condition, update, body, "for.class_field_slow")?; - if !ctx.block().is_terminated() { - ctx.block().br(&merge_label); - } - - ctx.current_block = merge_idx; - Ok(true) -} - fn record_packed_f64_loop_guard_artifacts( ctx: &mut FnCtx<'_>, arr_id: u32, @@ -7246,13 +6741,6 @@ pub(crate) fn lower_for( return Ok(()); } - // #5093: monomorphic class-field hot loops (`counter.value = counter.value - // + 1` after method inlining). Shape check hoisted to a preheader; fast - // clone is call-free raw slot access. - if lower_class_field_versioned_for(ctx, init, condition, update, body)? { - return Ok(()); - } - // repsel #7480 / #5093: `sum += arr[i].field` over an array carrying the // homogeneous element-shape invariant. Tried last, so every array-shaped // matcher above keeps precedence on the loops it already owns. @@ -7263,7 +6751,7 @@ pub(crate) fn lower_for( } // #8690 owns only loops left over after the established packed-number, - // indexed-method, class-field, and homogeneous element-shape clones have + // indexed-method, and homogeneous element-shape clones have // had first refusal. Its runtime admission is deliberately broader, so // trying it earlier would steal those specialized access shapes. if super::stable_packed_loop::lower(ctx, init, condition, update, body)? { @@ -7273,15 +6761,37 @@ pub(crate) fn lower_for( // Step 4b (#10884): every specialised tier above declined; a loop (or // body) region guards its receivers once here, in the preheader, and // splits the body when the tier below lowers it (`stmt::region_loop`). - let region = super::region_loop::begin(ctx, condition, body, update)?; - let lowered = super::region_loop::lower_loop(ctx, region, &mut |ctx| { - if i32_counter::lower(ctx, init, condition, update, body)? { - Ok(()) - } else { - lower_for_after_init(ctx, init, condition, update, body, "for") - } - }); - super::region_loop::end(ctx, region); + // Named class-field loops use the same fresh shape/representation proof + // as other receiver loops. Straight-line Ptr facts stay recorded, + // but must not bypass the region's exact R/store admission. All specialized + // array/storage tiers above retain first refusal. Restore the previous + // context both when planning declines and when lowering fails. + let saved_ptr_shape_context = ctx.repsel_context_allows_ptr_shape; + let saved_ptr_shape_denial = ctx.repsel_ptr_shape_context_denial; + ctx.repsel_context_allows_ptr_shape = false; + let lowered = (|| -> Result<()> { + let region = super::region_loop::begin(ctx, condition, body, update)?; + // With no region there is no F/G extent: ordinary loop lowering can + // consume its pre-existing straight-line receiver facts as before. + if region.is_none() { + ctx.repsel_context_allows_ptr_shape = saved_ptr_shape_context; + } else if saved_ptr_shape_context { + // Planning alone is not a refusal. Only an admitted region owns + // the accesses lowered below, and its handoff must be visible. + ctx.repsel_ptr_shape_context_denial = Some(crate::expr::PTR_SHAPE_REGION_AUTHORITY); + } + let lowered = super::region_loop::lower_loop(ctx, region, &mut |ctx| { + if i32_counter::lower(ctx, init, condition, update, body)? { + Ok(()) + } else { + lower_for_after_init(ctx, init, condition, update, body, "for") + } + }); + super::region_loop::end(ctx, region); + lowered + })(); + ctx.repsel_context_allows_ptr_shape = saved_ptr_shape_context; + ctx.repsel_ptr_shape_context_denial = saved_ptr_shape_denial; lowered } @@ -7402,7 +6912,7 @@ fn lower_for_after_init_impl( // repsel Phase 1 having done so). Only the inserter removes at loop exit. let mut hoist_counter_i32_was_fresh = false; // #7480 step 4: inside a call-free-by-construction fast clone - // (`lower_element_shape_versioned_for`, `lower_class_field_versioned_for`) + // (`lower_element_shape_versioned_for`) // the caller has ALREADY materialized the trip count and passed it in // `precomputed_i32_bound`, so the cond block never reads this slot. The // hoist would emit a `js_value_length_f64` call whose result nothing @@ -7418,7 +6928,6 @@ fn lower_for_after_init_impl( // clone's other lowering may depend on them; suppressing those too would // trade one silent loss for another. let in_call_free_clone = !ctx.element_shape_loop_facts.is_empty() - || !ctx.class_field_loop_facts.is_empty() || !ctx.stable_packed_loop_facts.is_empty() || precomputed_i32_bound.is_some(); let hoisted_length_slot: Option = if let Some(hoist) = hoist_classification { @@ -8028,7 +7537,7 @@ pub(crate) fn emit_gc_loop_safepoint( } // #7480 step 4: never inside a call-free-by-construction fast clone. // - // `lower_class_field_versioned_for`, `lower_element_shape_versioned_for`, + // `lower_element_shape_versioned_for` // and the stable-packed loop tier hoist a guard into a preheader and clone // the body against it. All rest on the SAME safety argument: the clone makes no call, therefore // allocates nothing, therefore cannot collect, therefore the pointer the @@ -8047,14 +7556,6 @@ pub(crate) fn emit_gc_loop_safepoint( // `stmt/element_shape_loop.rs`'s module docs predicted in as many words, // and `assert_fast_clone_is_entered` is the assertion that now catches it. // - // The class-field clone is NOT affected today, and that was checked rather - // than assumed: removing this suppression leaves its three IR tests green, - // because `loop_may_allocate` already proves an `obj.field`-only body inert - // and emits no poll for it. It is covered here anyway — the two clones rest - // on the identical argument, and the next body shape admitted to the - // class-field matcher that is not provably inert would delete that clone - // the same way. Its tests gained the same liveness assertion. - // // Skipping the poll here is not a new licence — it is the rule the line // below already applies. A poll exists so that an ALLOCATING loop can defer // a collection to a safe point; a body that cannot allocate does not need @@ -8065,7 +7566,6 @@ pub(crate) fn emit_gc_loop_safepoint( // call-free or it is not entered, and the slow clone — lowered after the // scope is popped — keeps its poll either way. if !ctx.element_shape_loop_facts.is_empty() - || !ctx.class_field_loop_facts.is_empty() || !ctx.stable_packed_loop_facts.is_empty() // #9379: the packed-f64 loop clone is the next body the paragraph above // predicted — "the next body shape admitted to the matcher that is not diff --git a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs index 2a281ee91c..e6ce415fc7 100644 --- a/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs +++ b/crates/perry-codegen/src/stmt/prealloc_module_global_tests.rs @@ -46,6 +46,7 @@ pub(super) fn ir_opts() -> CompileOptions { target: None, is_entry_module: true, non_entry_module_prefixes: Vec::new(), + thread_literal_module_prefixes: Vec::new(), nextjs_path_init_modules: Vec::new(), import_function_prefixes: std::collections::HashMap::new(), import_function_ffi_aliases: std::collections::HashMap::new(), diff --git a/crates/perry-codegen/src/stmt/ptr_shape_region_report_tests.rs b/crates/perry-codegen/src/stmt/ptr_shape_region_report_tests.rs new file mode 100644 index 0000000000..72764c92ee --- /dev/null +++ b/crates/perry-codegen/src/stmt/ptr_shape_region_report_tests.rs @@ -0,0 +1,197 @@ +//! The P8 handoff consumes class provenance only when a real static-region +//! access uses it. A learned region must never be counted as that consumption. + +use super::*; +use crate::opt_report::{test_support::Session, Analysis, Outcome}; + +fn fixture() -> Module { + let mut m = method_calls_module(Expr::Integer(200), Vec::new(), false); + // A loop-local receiver forces the body-region handoff that stole the + // original census fixtures. The self-reading store prevents scalarization. + let mut receiver = m.init.remove(0); + if let Stmt::Let { + init: Some(Expr::New { args, .. }), + .. + } = &mut receiver + { + *args = vec![Expr::Integer(0)]; + } + let Stmt::For { body, .. } = &mut m.init[0] else { + panic!("fixture loop") + }; + body.insert(0, receiver); + m +} + +fn static_options(m: &Module) -> CompileOptions { + let births = crate::module_birth_shapes(m, ir_opts()).unwrap(); + let mut opts = ir_opts(); + opts.static_shape_ids = crate::assign_static_shape_ids(births.iter().map(|b| &b.shape)) + .into_iter() + .collect(); + assert!( + !opts.static_shape_ids.is_empty(), + "fixture needs a static supplier" + ); + opts +} + +#[test] +fn selected_class_provenance_is_consumed_by_real_shape_guarded_region_accesses() { + let m = fixture(); + let opts = static_options(&m); + let session = Session::start(); + let ir = String::from_utf8(compile_module(&m, opts).unwrap()).unwrap(); + let entries = session.entries(); + assert!( + entries.iter().any(|e| e.analysis == Analysis::PtrShape + && e.outcome == Outcome::Selected + && e.local_id == Some(1)), + "fixture must actually select its receiver: {entries:?}" + ); + assert!( + ir.contains("rloop.guard.static") && ir.contains("rloop.fast"), + "a reported access must retain the live shape guard: {ir}" + ); + for site in ["ptr_shape_region_get", "ptr_shape_region_set"] { + assert!( + entries.iter().any(|e| e.analysis == Analysis::PtrShape + && e.outcome == Outcome::Consumed + && e.local_id == Some(1) + && e.site.as_deref() == Some(site)), + "missing {site}: {entries:?}" + ); + } + assert!( + entries.iter().any(|e| e.outcome == Outcome::Unconsumed + && e.rule.as_deref() == Some(crate::expr::PTR_SHAPE_REGION_AUTHORITY)), + "the generic copy's unguarded-route refusal must be named: {entries:?}" + ); +} + +#[test] +fn removing_static_supplier_does_not_claim_ptr_shape_consumption_for_learned_accesses() { + let session = Session::start(); + // Sabotage precisely the class-fact consumer's prerequisite, leaving the + // same selected receiver and real region accesses alive. + let ir = emit(&fixture()); + let entries = session.entries(); + assert!(entries.iter().any(|e| e.analysis == Analysis::PtrShape + && e.outcome == Outcome::Selected + && e.local_id == Some(1))); + assert!(ir.contains("rloop.fast") && ir.contains("@js_region_loop_prime(")); + assert!( + !entries.iter().any(|e| e + .site + .as_deref() + .is_some_and(|s| s == "ptr_shape_region_get" || s == "ptr_shape_region_set")), + "learned words do not consume static class provenance: {entries:?}" + ); + assert!(entries.iter().any(|e| e.outcome == Outcome::Unconsumed + && e.rule.as_deref() == Some(crate::expr::PTR_SHAPE_REGION_AUTHORITY))); +} + +#[test] +fn shape_barrier_removes_proof_attribution_but_keeps_guarded_type_hint_route() { + let mut m = fixture(); + // A separate object's delete trips rule 5 without disturbing this loop's + // shape supplier. The same guard/bare instructions must not acquire a + // Ptr consumption row when no such proof was selected. + m.init + .push(Stmt::Expr(Expr::Delete(Box::new(Expr::PropertyGet { + object: Box::new(Expr::New { + class_name: "Counter".into(), + args: vec![Expr::Integer(0)], + type_args: Vec::new(), + byte_offset: 0, + cap_args_appended: 0, + }), + property: "value".into(), + byte_offset: 0, + })))); + let opts = static_options(&m); + let session = Session::start(); + let ir = String::from_utf8(compile_module(&m, opts).unwrap()).unwrap(); + let entries = session.entries(); + assert!(ir.contains("rloop.guard.static") && ir.contains("rloop.fast")); + assert!(!entries.iter().any(|e| e.analysis == Analysis::PtrShape + && e.outcome == Outcome::Selected + && e.local_id == Some(1))); + assert!( + !entries.iter().any(|e| e + .site + .as_deref() + .is_some_and(|s| s == "ptr_shape_region_get" || s == "ptr_shape_region_set")), + "a type hint is not a consumed containment proof: {entries:?}" + ); +} + +#[test] +fn region_consumption_reporting_off_emits_identical_ir_and_no_entries() { + let m = fixture(); + let opts = static_options(&m); + let enabled = { + let session = Session::start(); + let ir = compile_module(&m, opts.clone()).unwrap(); + assert!(session + .entries() + .iter() + .any(|e| e.site.as_deref() == Some("ptr_shape_region_get"))); + ir + }; + let session = Session::start_disabled(); + let disabled = compile_module(&m, opts).unwrap(); + assert!(session.entries().is_empty()); + assert_eq!( + enabled, disabled, + "reporting must not change the emitted program" + ); +} + +#[test] +fn straight_line_numeric_load_and_update_remain_live() { + let mut m = method_calls_module(Expr::Integer(200), Vec::new(), false); + m.init.pop(); + if let Stmt::Let { + init: Some(Expr::New { args, .. }), + .. + } = &mut m.init[0] + { + *args = vec![Expr::Integer(0)]; + } + m.init.push(bump_stmt(1, false)); + m.init.push(Stmt::Expr(Expr::PropertyUpdate { + object: Box::new(Expr::LocalGet(1)), + property: "value".into(), + op: BinaryOp::Add, + prefix: false, + strict: false, + })); + m.init.push(Stmt::Expr(Expr::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::PropertyGet { + object: Box::new(Expr::LocalGet(1)), + property: "value".into(), + byte_offset: 0, + }), + right: Box::new(Expr::Integer(2)), + })); + let session = Session::start(); + let ir = emit(&m); + let entries = session.entries(); + assert!(!ir.contains("rloop.fast")); + for site in [ + "class_field_get_number.shape_proven_load", + "ptr_shape_update", + ] { + assert!( + entries.iter().any(|e| e.outcome == Outcome::Consumed + && e.local_id == Some(1) + && e.site.as_deref() == Some(site)), + "surviving site {site} must have independent coverage: {entries:?}" + ); + } + assert!(!entries + .iter() + .any(|e| e.rule.as_deref() == Some(crate::expr::PTR_SHAPE_REGION_AUTHORITY))); +} diff --git a/crates/perry-codegen/src/stmt/region_loop/arrays.rs b/crates/perry-codegen/src/stmt/region_loop/arrays.rs index 7d3bb73c2a..4a29d8b5ac 100644 --- a/crates/perry-codegen/src/stmt/region_loop/arrays.rs +++ b/crates/perry-codegen/src/stmt/region_loop/arrays.rs @@ -166,7 +166,10 @@ pub(super) fn candidates( extra.extend(cond); extra.extend(update); let written = assigned(body, &extra); - let keyed: HashSet = accesses(body).into_iter().map(|(r, _, _)| r).collect(); + let keyed: HashSet = accesses(body) + .into_iter() + .map(|(r, _, _, _, _)| r) + .collect(); for (id, c) in reads { let r = Recv::Local(id); if written.contains(&id) || keyed.contains(&r) || !receiver_eligible(ctx, r) { diff --git a/crates/perry-codegen/src/stmt/region_loop/bare.rs b/crates/perry-codegen/src/stmt/region_loop/bare.rs index 2192adbb9e..d3bad48c01 100644 --- a/crates/perry-codegen/src/stmt/region_loop/bare.rs +++ b/crates/perry-codegen/src/stmt/region_loop/bare.rs @@ -159,6 +159,19 @@ pub(super) fn note_emitted(ctx: &mut FnCtx<'_>) { } } +/// Count the selected receiver only at an emitted access served by the static +/// supplier its class proof selected. Learned words and type guesses do not +/// consume that proof, nor does merely constructing a guard. +fn note_ptr_shape_access(ctx: &FnCtx<'_>, r: Recv, site: &'static str) { + if ctx.region_loop_facts.last().is_some_and(|a| { + a.receivers + .iter() + .any(|rv| rv.recv == r && rv.uses_ptr_shape_class) + }) { + ctx.note_ptr_shape_consumed(&r.expr(), site); + } +} + /// The address a bare READ loads. In an all-inline copy (and for every store, /// whose key the runtime publishes only when inline) it is the inline slot. /// In a spill copy the key's field says where the value lives: `< 32` is an @@ -256,6 +269,7 @@ pub(crate) fn try_lower_bare_get(ctx: &mut FnCtx<'_>, e: &Expr) -> Result