Skip to content

Merge train 203: null-typed field GC slot mask, un-imported export shadowing (v0.5.1580) - #10367

Merged
proggeramlug merged 6 commits into
mainfrom
train203r
Sep 16, 2026
Merged

proggeramlug merged 6 commits into
mainfrom
train203r

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

This train lands #10352 and #10358 as v0.5.1580. Both sit on fcd108bfb0, touch no file in common, and cherry-pick clean.

  • fix(hir): a null-typed field is not a proof the GC may skip the slot (#10348) #10352 (Fixes #10348) — a null-typed field is not a proof the GC may skip the slot. A record field whose declared type is null was being treated as a proof that the slot can never hold a pointer, so it was masked out of the GC's scan. rhs_certainly_nullish now answers by shape for the same forms rhs_certainly_object_like already decides, so the slot stays scanned.
  • fix(compile): an un-imported export must not shadow a global intrinsic #10358 — an un-imported export must not shadow a global intrinsic. A module exporting a name that matches a global intrinsic made that name resolve to the export at every call site in the program, including in modules that never imported it: the global was shadowed by a binding the consuming module could not see. Resolution now requires the importing module to actually name the binding.

Four source commits, each verified to preserve its patch-id and authorship. The train adds two repairs.

Train repairs

#10358 shipped no changelog.d/ fragment — a hard failure of the changeset gate for any crates/ change. Added, keyed to #10358.

#10358 fails cargo fmt --check: the new is_builtin_global_value_name re-export in analysis.rs is declared before the builtins::{…} group rustfmt orders it after. Whitespace only.

On the version: #10352 carries its own bump to 0.5.1580, which the contributor rules ask PRs not to do — the maintainer bumps at merge time, precisely so two in-flight PRs cannot claim the same patch version. It collided with an in-flight train that had also taken 1580. Since main is at 1579 and this train lands next, 1580 is simply correct, so the bump is kept as authored rather than rewritten, and the other train rebases to 1581 behind it.

Validation

Validated head c0957bc2ee. Five-package release build pinned and hash-verified.

  • Crate suites across every affected crate, and the three integration suites these PRs add: gc_record_null_typed_field_10348, issue_10356_unimported_export_shadows_global, anon_shape_field_types.
  • All nine preflight gates pass, including both the raw-handle and unrooted-local-shape ratchets against main.
  • Gap: filters record, null, object, shape, global, intrinsic, import, export — about 193 fixture selections, each filter asserting it selected fixtures. The weighting is deliberate: fix(compile): an un-imported export must not shadow a global intrinsic #10358 changes name resolution, so its exposure is every module that resolves a name, not just the fixture it ships.

Source CI is the primary gap evidence here and it is already in: #10352's own run completed the full six-shard gap suite with only main's three known regressions (test_gap_iterator_prototype_next_patch, test_gap_2899_2779_2777_static_helpers, test_gap_disposablestack_2875), alongside 19 passing checks. #10358 passes cargo-test, check, warnings and e2e-scoped. Both PRs' remaining red checks are the ones every PR inherits: benchmark-evidence freshness, the pre-existing rustls RUSTSEC-2026-0285 advisory, and the pr-gate fan-in over those.

Before merging, the pushed head and unchanged main are checked again. After merging, the rewritten commits are checked for preserved authorship and the main tree must match the validated train exactly.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed garbage collection for record fields inferred from null or undefined, preventing retained object references from being lost.
    • Prevented unimported exports from shadowing global runtime values such as Request, Response, and Headers.
  • Documentation
    • Added release notes covering the garbage-collection and global-name resolution fixes.
  • Chores
    • Updated the project version to 0.5.1580.

Ralph Küpper added 6 commits September 16, 2026 10:57
…10348)

A closed-shape object literal lowers to `new __AnonShape_*(…)`, and the
synthesized class's field types are not a hint — codegen turns them into the
class's compile-time GC masks (`typed_shape::typed_layout_from_fields`),
`js_gc_typed_shape_id_for_keys` registers those against a dedicated ShapeId,
and every allocation then stamps `SIDE_MASK | TYPED_LAYOUT_INTACT` straight
from the baked header image (#8405). That path has no per-object validation
and no downgrade, so a field the pointer mask omits is a field the collector
never scans: its child is neither marked nor rewritten.

`Type::Null` and `Type::Void` are the two declared types
`type_is_pointer_bearing` answers `false` for, and the two that a *variable*
is most trivially wrong about. Perry infers `var head = null` as `Type::Null`
and repairs it only in the post-lowering widening pass, which runs long after
the anon-shape class has been minted — and that pass did not cover `Null` /
`Void` at all (`_ => false`). So the issue's reproducer

    var head = null;
    for (var i = 0; i < 8; i++) head = { id: …, payload: […], tag: null, next: head };

registered `ptr_mask = 0b0011` for a record whose live pointer slots are
`{payload, next}`: `next` was excluded, and 3.7% of the object graph was
silently truncated and cross-linked (at TOTAL=100000 the walk reported 329,754
nodes for 320,000 ever allocated), then read as a corrupted object once the
freed addresses were reused. `PERRY_GC_VERIFY_EVACUATION=1` aborted on it with
`parent_space=old_page remembered=no visitor=ObjectFields`.

Two edits, both keyed on what is actually provable:

* `lower/expr_object.rs` — a record field takes a `Null` / `Void` type only
  from an expression that IS that value (a literal `null` / `undefined`).
  Anything else contributes `Any`. `{ next: null }` therefore keeps its exact
  field type, its mask and its `POINTER_FREE` eligibility.
* `lower/type_widening.rs` — the `Null` / `Void` arm the pass was missing,
  gated on a new `non_nullish` set so a local that only ever holds
  `null`/`undefined` is not widened for nothing (`object_like` is deliberately
  entered by those assignments, so widening off it alone would demote every
  nullable local in the program).

The issue's narrowing table understates the blast radius: with the same wrong
mask and the `c.tag = …` store dropped, the reproducer prints the CORRECT node
count while `PERRY_GC_FROMSPACE_SCAN=1` still reports 15,123 dangling
references. Output parity was a false green, which is why the regression test
asserts the collector's own whole-heap invariant instead.

Validation on perrybuilder (Linux x86_64, v0.5.1579):
* reproducer 320000/320000, `truncated_chains=0`, byte-identical to node
  v26.8.1; `PERRY_GC_FROMSPACE_SCAN=1` clean on every cycle;
  `PERRY_GC_VERIFY_EVACUATION=1` no longer aborts. Ten shape variants of the
  reproducer: all match node, all report zero offenders (six of them reported
  offenders before).
* No performance tradeoff, shown structurally rather than by timing: the
  object files a base and a fixed compiler emit for honest code — a linked
  list built from `{ value, next: null }`, numeric `{ v: i, w: i + 1 }` record
  churn, and a mixed pointer/primitive record with arrays and strings — are
  BYTE-IDENTICAL. Only the miscompiled shape differs.
* `cargo test -p perry-hir` 50/50 binaries green, `-p perry-codegen --lib`
  1563 green, `cargo fmt --check` clean, no new clippy warnings.

Tests, each verified to fail without its half of the fix:
* `crates/perry/tests/gc_record_null_typed_field_10348.rs` — 2000 retained
  chains under `PERRY_GC_FROMSPACE_SCAN_ABORT=1` (~1 s; aborts with exit 134
  before the fix).
* `anon_shape_field_types.rs` — the minted field types, both directions.
* `type_widening.rs` — widen-on-object, widen-on-string, and preserve-on-
  nullish; the last one fails if the arm is widened off `object_like` alone.
…y_object_like already decides

Mirrors the sibling predicate's structural list the other way round, so the
new nullish test costs no second infer_expr_type on any RHS that was already
decided without one. Behaviour-preserving in the safe direction: the arms that
short-circuit to false can only widen MORE, never less.
#10356. When a module imports anything from another native-compiled module,
run_pipeline registers every exported class of that module for dispatch --
deliberately, "even when the class name wasn't in the specifier list". The
comment argues this is safe because a same-named LOCAL class wins in
compile_module. That holds for local classes, but a global intrinsic is not a
local class, so nothing outranked the implicit entry.

So a module exporting `class Request` made an unrelated `new Request(url, init)`
in ANY importer construct that class instead of the global fetch Request --
`.headers` came back undefined. Generated SDKs exporting Request/Response/
Headers are common (hey-api, openapi-typescript, oazapfts); this is OpenCode's
TUI bootstrap wall, where packages/sdk/js/src/v2/client.ts imports only
OpencodeClient from a gen/sdk.gen.ts that also exports `class Request`.

Skip builtin global names in that implicit loop only. An explicit
`import { Request } from "./mod.js"` is pushed by the specifier-driven sites
above and already wins the name dedup, so it is unaffected -- covered by cell
11 of the test.
@proggeramlug
proggeramlug merged commit 33690c5 into main Sep 16, 2026
21 of 22 checks passed
@proggeramlug
proggeramlug deleted the train203r branch September 16, 2026 09:46
@coderabbitai

coderabbitai Bot commented Sep 16, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: fe138495-0ba0-4900-9e5c-e4c95c303374

📥 Commits

Reviewing files that changed from the base of the PR and between fcd108b and c0957bc.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/10352-null-typed-record-field-gc-mask.md
  • changelog.d/10358-unimported-export-shadows-global.md
  • crates/perry-hir/src/analysis.rs
  • crates/perry-hir/src/lower/expr_object.rs
  • crates/perry-hir/src/lower/type_widening.rs
  • crates/perry-hir/tests/anon_shape_field_types.rs
  • crates/perry/src/commands/compile/run_pipeline.rs
  • crates/perry/tests/gc_record_null_typed_field_10348.rs
  • crates/perry/tests/issue_10356_unimported_export_shadows_global.rs

📝 Walkthrough

Walkthrough

The change fixes nullish record-field GC masks, prevents unimported exports from shadowing global intrinsics, adds regression tests and changelog entries, and updates the project version to 0.5.1580.

Changes

Nullish record-field GC mask

Layer / File(s) Summary
Nullish type widening and record lowering
crates/perry-hir/src/lower/type_widening.rs, crates/perry-hir/src/lower/expr_object.rs
Nullish-declared locals widen to Any when assigned non-nullish values. Closed-shape fields use Any for non-literal values while preserving literal nullish types.
GC regression coverage and changelog
crates/perry-hir/tests/anon_shape_field_types.rs, crates/perry/tests/gc_record_null_typed_field_10348.rs, changelog.d/10352-null-typed-record-field-gc-mask.md
Tests verify field typing and whole-heap from-space safety for retained object chains. The changelog documents the correction.

Global intrinsic resolution

Layer / File(s) Summary
Global intrinsic name detection
crates/perry-hir/src/analysis.rs
Adds is_global_intrinsic_value_name and separates its builtin re-export.
Implicit class registration filtering
crates/perry/src/commands/compile/run_pipeline.rs
Implicit class registration skips names that identify global intrinsic values. Explicit imports remain registered.
Import resolution regression coverage
crates/perry/tests/issue_10356_unimported_export_shadows_global.rs, changelog.d/10358-unimported-export-shadows-global.md
The test covers unimported exports and explicit imports. The changelog records the resolution behavior.

Version metadata

Layer / File(s) Summary
Version metadata
CLAUDE.md, Cargo.toml
The documented and workspace versions change from 0.5.1579 to 0.5.1580.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: High

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch train203r

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant