Skip to content

fix(hir): recognize the Bun platform global in diagnostics - #9754

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9745-bun-global-diagnostics
Closed

fix(hir): recognize the Bun platform global in diagnostics#9754
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9745-bun-global-diagnostics

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

In --platform bun mode, ordinary reads of Bun emitted unknown-identifier warnings even though the compiler installs globalThis.Bun before module initialization. Pass the selected platform's supplied global names into HIR lowering and suppress that warning for an unshadowed supplied name, including the constructor-reference diagnostic.

Reads keep the existing by-name runtime lookup, so replacing globalThis.Bun remains observable. Lexical bindings still resolve first. Default-platform diagnostics and unrelated unknown names keep their existing behavior. The existing public lowering entry point retains its signature and uses an empty supplied-global set.

Validation:

  • The end-to-end regression suite failed on both Bun warning assertions before the change, while the default-platform feature-detection test passed.
  • All 3 end-to-end tests pass against a freshly built compiler and coherent runtime/stdlib/network archives, including dependency-module reads, namespace replacement, computed access, destructuring, typeof, constructor failure, and lexical shadowing in both modes.
  • HIR unit suite: 382 passed, 1 ignored. Compiler unit suite: 1,083 passed; the sole failure is the pre-existing PERRY_CONCAT_SITE_CACHE registration gap, fixed separately by fix(cache): register concat switch and explain codegen inputs #9748.
  • Formatting, file-size, test-registration and diff checks pass.

Other baseline CI failures are addressed separately: raw TLS policy by #9750, and Linux pthread declaration warnings by #9752.

Closes #9745. No version bump.

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 6 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 8 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 1e8f917c-ec9f-4735-9e7c-c92bddec428b

📥 Commits

Reviewing files that changed from the base of the PR and between 12efed1 and fc40d95.

📒 Files selected for processing (10)
  • changelog.d/9754-bun-global-diagnostics.md
  • crates/perry-hir/src/lib.rs
  • crates/perry-hir/src/lower/context.rs
  • crates/perry-hir/src/lower/expr_new.rs
  • crates/perry-hir/src/lower/lower_expr/arm_ident.rs
  • crates/perry-hir/src/lower/lower_module_fn.rs
  • crates/perry-hir/src/lower/lowering_context.rs
  • crates/perry-hir/src/lower/mod.rs
  • crates/perry/src/commands/compile/collect_modules.rs
  • crates/perry/tests/issue_9599_bun_platform.rs

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train #9798 (rebase-merged, so your commits keep their authorship). Thanks!

proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Sep 5, 2026
… % more

The five tables PerryTS#9754 converted were valued individually with a
measurement-only `PERRY_YOUNG_LOG=0` gate on
`RuntimeRootVisitor::young_scope()` (all five scanners fall back to their full
walk together inside one binary), plus a third arm — `cc_base_new`, main
`1d63fa91f`, no logs at all. Three interleaved rounds, `stream_scale` len 3300,
identical collection schedule in every arm (minors 196/194/196, budgeted steps
60/59/59), so these are scan costs:

| scanner, ms per turn         | main            | log, full walk  | log, minor walk |
|------------------------------|-----------------|-----------------|-----------------|
| all 95 scanners              | 14761/14105/19411 | 23368/23286/26974 | 2667/2852/3674 |
| scan_shape_table_rekey_mut   | 10884/11077/14458 | 18893/18655/22125 | 1426/1567/1947 |
| scan_descriptor_roots_mut    |   1807/1192/2223  |  2257/2467/2548   |  127/136/145   |
| scan_closure_dynamic_props   |    1014/985/1347  |    890/902/959    |  224/236/209   |
| transition_cache scanner     |     121/123/159   |    254/221/246    |   85/94/145    |
| shape_cache scanner          |      89/89/113    |    159/153/167    |  121/122/151   |

The shape cache is the one table where the log loses to the walk it replaced:
+34 ms (+35 %) against main, having skipped **0.0 % of 3.85 M entry visits in
every one of 107 collections**. The cause was already documented — the
canonical keys arrays are allocated in the LONGLIVED arena, which
`addr_is_minor_relevant` must answer `true` for because a longlived parent is
not write-barriered, and a longlived object is never promoted, so no entry ever
leaves the log.

So it goes back to the plain `values_mut()` walk: the arm helper, its
production and test-seam call sites, the thread-local log, the name constant
and the `debug_assert_logged` re-derivation are all deleted. An inert log is
not free — it is a permanent arming obligation on every future writer of that
cache plus a suppression audit that has to keep proving each site — and it
should not land on the promise of a longlived remembered set that does not
exist yet. When that set exists and makes this table skip something, the log
can come back with a measurement.

The test is kept as a scanner test (a young entry reachable only through the
cache still moves and is re-keyed in both the inline slot and the overflow map)
and now asserts that NO `[gc-young-log]` row exists for the table, so re-adding
a log here without re-measuring is a red test.

Note for anyone repeating this on another table: the two-arm version of this
experiment gives the wrong answer. With the log merely disabled, the full-walk
arm still pays its upkeep — a `take_sorted()` whose sorted result is discarded
and an `addr_is_minor_relevant` probe per entry to rebuild `kept` — so every
"off" row above is worse than main, by +7.8 s on the shapes table alone. Only
the third arm says whether a log should exist at all.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Sep 5, 2026
… % more

The five tables PerryTS#9754 converted were valued individually with a
measurement-only `PERRY_YOUNG_LOG=0` gate on
`RuntimeRootVisitor::young_scope()` (all five scanners fall back to their full
walk together inside one binary), plus a third arm — `cc_base_new`, main
`1d63fa91f`, no logs at all. Three interleaved rounds, `stream_scale` len 3300,
identical collection schedule in every arm (minors 196/194/196, budgeted steps
60/59/59), so these are scan costs:

| scanner, ms per turn         | main            | log, full walk  | log, minor walk |
|------------------------------|-----------------|-----------------|-----------------|
| all 95 scanners              | 14761/14105/19411 | 23368/23286/26974 | 2667/2852/3674 |
| scan_shape_table_rekey_mut   | 10884/11077/14458 | 18893/18655/22125 | 1426/1567/1947 |
| scan_descriptor_roots_mut    |   1807/1192/2223  |  2257/2467/2548   |  127/136/145   |
| scan_closure_dynamic_props   |    1014/985/1347  |    890/902/959    |  224/236/209   |
| transition_cache scanner     |     121/123/159   |    254/221/246    |   85/94/145    |
| shape_cache scanner          |      89/89/113    |    159/153/167    |  121/122/151   |

The shape cache is the one table where the log loses to the walk it replaced:
+34 ms (+35 %) against main, having skipped **0.0 % of 3.85 M entry visits in
every one of 107 collections**. The cause was already documented — the
canonical keys arrays are allocated in the LONGLIVED arena, which
`addr_is_minor_relevant` must answer `true` for because a longlived parent is
not write-barriered, and a longlived object is never promoted, so no entry ever
leaves the log.

So it goes back to the plain `values_mut()` walk: the arm helper, its
production and test-seam call sites, the thread-local log, the name constant
and the `debug_assert_logged` re-derivation are all deleted. An inert log is
not free — it is a permanent arming obligation on every future writer of that
cache plus a suppression audit that has to keep proving each site — and it
should not land on the promise of a longlived remembered set that does not
exist yet. When that set exists and makes this table skip something, the log
can come back with a measurement.

The test is kept as a scanner test (a young entry reachable only through the
cache still moves and is re-keyed in both the inline slot and the overflow map)
and now asserts that NO `[gc-young-log]` row exists for the table, so re-adding
a log here without re-measuring is a red test.

Note for anyone repeating this on another table: the two-arm version of this
experiment gives the wrong answer. With the log merely disabled, the full-walk
arm still pays its upkeep — a `take_sorted()` whose sorted result is discarded
and an `addr_is_minor_relevant` probe per entry to rebuild `kept` — so every
"off" row above is worse than main, by +7.8 s on the shapes table alone. Only
the third arm says whether a log should exist at all.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
proggeramlug pushed a commit that referenced this pull request Sep 6, 2026
… % more

The five tables #9754 converted were valued individually with a
measurement-only `PERRY_YOUNG_LOG=0` gate on
`RuntimeRootVisitor::young_scope()` (all five scanners fall back to their full
walk together inside one binary), plus a third arm — `cc_base_new`, main
`1d63fa91f`, no logs at all. Three interleaved rounds, `stream_scale` len 3300,
identical collection schedule in every arm (minors 196/194/196, budgeted steps
60/59/59), so these are scan costs:

| scanner, ms per turn         | main            | log, full walk  | log, minor walk |
|------------------------------|-----------------|-----------------|-----------------|
| all 95 scanners              | 14761/14105/19411 | 23368/23286/26974 | 2667/2852/3674 |
| scan_shape_table_rekey_mut   | 10884/11077/14458 | 18893/18655/22125 | 1426/1567/1947 |
| scan_descriptor_roots_mut    |   1807/1192/2223  |  2257/2467/2548   |  127/136/145   |
| scan_closure_dynamic_props   |    1014/985/1347  |    890/902/959    |  224/236/209   |
| transition_cache scanner     |     121/123/159   |    254/221/246    |   85/94/145    |
| shape_cache scanner          |      89/89/113    |    159/153/167    |  121/122/151   |

The shape cache is the one table where the log loses to the walk it replaced:
+34 ms (+35 %) against main, having skipped **0.0 % of 3.85 M entry visits in
every one of 107 collections**. The cause was already documented — the
canonical keys arrays are allocated in the LONGLIVED arena, which
`addr_is_minor_relevant` must answer `true` for because a longlived parent is
not write-barriered, and a longlived object is never promoted, so no entry ever
leaves the log.

So it goes back to the plain `values_mut()` walk: the arm helper, its
production and test-seam call sites, the thread-local log, the name constant
and the `debug_assert_logged` re-derivation are all deleted. An inert log is
not free — it is a permanent arming obligation on every future writer of that
cache plus a suppression audit that has to keep proving each site — and it
should not land on the promise of a longlived remembered set that does not
exist yet. When that set exists and makes this table skip something, the log
can come back with a measurement.

The test is kept as a scanner test (a young entry reachable only through the
cache still moves and is re-keyed in both the inline slot and the overflow map)
and now asserts that NO `[gc-young-log]` row exists for the table, so re-adding
a log here without re-measuring is a red test.

Note for anyone repeating this on another table: the two-arm version of this
experiment gives the wrong answer. With the log merely disabled, the full-walk
arm still pays its upkeep — a `take_sorted()` whose sorted result is discarded
and an `addr_is_minor_relevant` probe per entry to rebuild `kept` — so every
"off" row above is worse than main, by +7.8 s on the shapes table alone. Only
the third arm says whether a log should exist at all.

Claude-Session: https://claude.ai/code/session_014UZWia6L37DpA93VLtNK9m
proggeramlug pushed a commit to proggeramlug/perry that referenced this pull request Sep 6, 2026
…rse than full

PerryTS#9754 gave four side tables a minor-scoped root scan. Measured per table on
the compiled claude-code TUI, three of them earn it and this one does not.

Both arms of a 3-turn 3300-character run on a quiet host (`[gc-young-log]`
rows, 273 and 272 minor cycles):

| table | skipped | verdict |
|---|---|---|
| `gc.layout_tables` | 99.0 % (366,347 visited of 36,938,896) | earns it |
| `object.descriptors` | 95.7 % | earns it |
| `object.transition_cache` | 83 % | earns it |
| `shapes.families+indices` | 80 % | earns it |
| **`closure.dynamic_props`** | **2.2-2.7 %** | **WORSE THAN FULL on 223/273 and 217/272 cycles** |

The cause is one predicate, and it is why the same technique lands so
differently on adjacent tables. A SCANNER's keep-test is
`addr_is_minor_relevant`, which returns true for `Longlived` BY DESIGN — a
longlived object can point at a young one — so the "relevant" set is close to
the whole table and the log never drains: `kept/logged` has median 1.000 here.
Multi-round re-logging then makes the young walk visit MORE entries than the
full walk it replaces. That is the same finding, and the same fix, as the
shape-cache young log dropped for skipping 0 % and costing 35 % more.

So every pass over this table is a full pass again.

The LOG ITSELF STAYS. `prune_dead_closure_side_table_owners_young` uses it,
and a PRUNE's predicate is not the scanner's: it asks who DIED, so it excludes
`Longlived` and `Old` both. That asymmetry is the whole point — the same
mechanism is a 99.0 % skip on one walk and a 2.2 % skip on another, decided
entirely by which predicate the walk keeps on. The full scanner rebuilds the
log from what it finds, so the prune's candidate set stays complete.

Rule 2 moves with it. The log-completeness re-derivation ran at the top of the
minor-scoped scanner; that walk is gone, and the prune is now the log's only
consumer, so the machine check that catches a writer publishing without arming
moves into the prune. Dropping the scanner without moving it would have
deleted the only guard on a log a prune still trusts — the failure would not
have been a slow walk but a dead owner's entries surviving in silence.

Not yet verified: this file has only been compiled with `debug_assertions`
OFF, so the moved `#[cfg(debug_assertions)]` call has not been type-checked
and the moved rule 2 has not been observed to fire.
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.

compiler(bun): suppress unknown-identifier warnings for the provided Bun global

1 participant