Skip to content

feat(gc): per-module raw-handle ceilings — new code is clean by construction (Layer 3) - #7457

Merged
proggeramlug merged 2 commits into
mainfrom
feat/layer3-per-module-debt
Aug 5, 2026
Merged

feat(gc): per-module raw-handle ceilings — new code is clean by construction (Layer 3)#7457
proggeramlug merged 2 commits into
mainfrom
feat/layer3-per-module-debt

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Layer 3 of the engine plan (#7294). The plan's goal for this layer is that RuntimeHandleScope stops being optional; a single global debt total cannot express that, so this changes the shape of the gate rather than its number.

Why the total was the wrong instrument

raw_handle_debt.py tracked one number. That is blind in three ways:

  • Debt moving between modules is invisible — a file can double while another halves.
  • A brand-new file can start dirty, as long as something else got cleaner. Nothing forces new code onto the combinator.
  • There is no finish line. It drifts down; it does not converge.

It also went red twice today (#7424, #7451) precisely because a correct rooting fix spends budget, and the only remedy is to find an unrelated conversion.

The inversion

595 of the 705 runtime modules already carry zero bare reads. So the default should be zero, not "whatever the total allows". scripts/raw_handle_debt_files.txt now lists the 110 modules permitted to carry debt, each with a ceiling. Three rules, all closed:

rule fails when
1 a module not on the list has any bare read — so new code must use across_{mut,const,nanbox}
2 a listed module exceeds its ceiling
3 a listed module reaches zero and its line is still there

Rule 3 is the one that makes cleanup permanent — it is the same matching-nothing rule as gc_root_dominance_allowlist.json. Without it a cleaned module keeps its line forever and silently re-permits the debt on the next edit.

Two details that are easy to get wrong

Ordering. The per-module checks run before the global total. I had it the other way first and my own rule-2 test printed nothing: the total check returned first, so the specific diagnostic ("symbol.rs: 2 bare reads exceeds its ceiling of 1") never appeared for the most common failure shape. The general message does not name the file or the fix.

Self-test. All three rules and the clean case are asserted in --self-test, in the live path — my first attempt appended them after an early return and they never ran, which the original self-test's own output made obvious. Stubbing check_per_module to return [] now produces:

self-test FAILED: rule did not fire: unlisted module carrying debt

Each rule was also exercised end-to-end against the real tree:

rule 1: abi_trampoline.rs: 1 bare read(s) in a module with no ceiling. New code must use RuntimeHandle::across_{mut,const,nanbox}; see #7341.
rule 2: symbol.rs: 2 bare reads exceeds its ceiling of 1
rule 3: symbol.rs: ceiling of 1 matches nothing -- the module is clean (or gone). DELETE its line so the cleanup cannot be undone.

Scope

Seeded at today's state — 1002 sites, 110 modules — so it is green on merge and changes no runtime code. --update rewrites both the total and the ceilings, and still refuses to raise the total.

This does not finish Layer 3. RuntimeHandleScope is still optional inside those 110 modules, and the RFC's end state is that the raw accessor is unreachable rather than merely counted. What it does is stop the other 595 from ever joining them, and give the remaining work a finish line: the list can only shrink.

Ralph Küpper added 2 commits August 5, 2026 17:07
…ruction

The Layer 3 debt was a single global total, which cannot make the
discipline non-optional. A total is blind to debt MOVING between modules,
and it lets a brand-new file start dirty as long as something else got
cleaner. It also has no finish line: it drifts down, it never converges.

595 of the 705 runtime modules already carry zero bare reads. Inverting
the ratchet turns that into the enforced default: list the 110 modules
PERMITTED to carry debt, with a per-module ceiling, and everything else
must be at zero. Three directions, all closed:

  1. An unlisted module with any bare read fails. New code therefore has
     to use RuntimeHandle::across_{mut,const,nanbox}.
  2. A listed module over its ceiling fails.
  3. A listed module that reaches ZERO fails until its line is deleted --
     the same matching-nothing rule as gc_root_dominance_allowlist.json,
     which is what makes a cleanup permanent instead of re-permitting the
     debt on the next edit.

The per-module checks run BEFORE the global total, deliberately: the
specific diagnostic names the file and the fix, and the total check
returning first would suppress it for the most common failure.

All three rules and the clean case are asserted in --self-test, and the
self-test fails when check_per_module is stubbed out.
@proggeramlug
proggeramlug merged commit 662ce78 into main Aug 5, 2026
@proggeramlug
proggeramlug deleted the feat/layer3-per-module-debt branch August 5, 2026 15:07
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@proggeramlug, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

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

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1e5a619a-df26-46d7-8911-84f0e88ff178

📥 Commits

Reviewing files that changed from the base of the PR and between b2ec220 and 62dd7c3.

📒 Files selected for processing (3)
  • changelog.d/7457-per-module-raw-handle-ceilings.md
  • scripts/raw_handle_debt.py
  • scripts/raw_handle_debt_files.txt
✨ 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 feat/layer3-per-module-debt

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 added a commit that referenced this pull request Aug 5, 2026
)

* gc: clean three runtime modules to zero raw-handle debt

First use of #7457's per-module ceilings, and the mechanism did its job:
converting the three sites left the gate RED with

  array/from_concat.rs: ceiling of 1 matches nothing -- the module is
  clean (or gone). DELETE its line so the cleanup cannot be undone.

until the lines were removed. That is rule 3 working -- without it a
cleaned module keeps its allowance and silently re-permits the debt.

  array/from_concat.rs       js_array_grow    -> across_const
  object/array_object_ops.rs js_string_coerce -> across_mut
  symbol.rs                  gc_malloc        -> across_mut

Each is the canonical shape: an allocating call immediately above a
re-read of a rooted receiver. Semantically identical; the ordering is now
structural rather than conventional.

110 -> 107 modules, 1002 -> 999 sites. Those three can never regress:
they are no longer on the list, so rule 1 holds them at zero.

* docs: changelog fragment for #7458

---------

Co-authored-by: Ralph Küpper <ralph@skelpo.com>
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