Skip to content

fix(runtime): explicit gc() runs a full collection so dead old-gen objects are reclaimed - #5714

Merged
TheHypnoo merged 1 commit into
PerryTS:mainfrom
machineloop:fix/explicit-gc-full-collection
Jun 26, 2026
Merged

fix(runtime): explicit gc() runs a full collection so dead old-gen objects are reclaimed#5714
TheHypnoo merged 1 commit into
PerryTS:mainfrom
machineloop:fix/explicit-gc-full-collection

Conversation

@machineloop

@machineloop machineloop commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

An explicit gc() did not reclaim dead old-generation / large objects, so a
large-object allocation loop grew unboundedly (RSS climbed ~175 MB/round with
gc() between rounds) instead of plateauing.

With generational GC on (the default), js_gc_collect() dispatched a minor
cycle. The minor sweep intentionally skips dead-old-block reclamation
(reclaim_dead_old_blocks = false in step_sweep), and large objects (>16 KB)
plus minor-GC survivors live in the old arena — so an explicit gc() never
returned that memory to the OS.

Changes

  • crates/perry-runtime/src/gc/policy.rs — route manual_gc_collect_now (the
    single chokepoint for both the synchronous js_gc_collect path and the
    deferred flush) through gc_collect_full_mark_sweep_with_trigger, so an
    explicit gc() performs a full mark-sweep that reclaims dead old-generation
    blocks. Automatic/threshold-driven minor collections are untouched.

Related issue

n/a — explicit-gc() reclamation of old-generation/large garbage.

Test plan

# A loop allocating 20×8MB arrays/round with gc() between rounds.
# Before: RSS climbs 180 -> 355 -> 547 -> 709 (unbounded).
# After:
$ perry compile bigloop.ts -o bigloop && ./bigloop
largeloop round 1: 275 MB
largeloop round 4: 292 MB        # bounded plateau

$ cargo test -p perry-runtime --lib gc::    # 379 passed
$ cargo fmt --check -p perry-runtime         # clean

Scope / caveats (intentional)

  • V8 semantics: V8's bare gc() is a scavenge (minor); gc(true) is the
    major one. This makes Perry's explicit, user-initiated gc() a thorough
    (full) collection. Gating the full sweep on gc(true) to mirror V8's
    minor/major split is a reasonable follow-up (Perry currently ignores the
    force argument).

  • Pause: an explicit gc() now costs a full collection rather than a minor.
    Since gc() is user-initiated, a thorough collection is the expected trade.

  • Lone large object: a single dead large object whose block is the
    allocator's current old-gen target is kept mapped + reusable (not returned
    to the OS until reused) — a deliberate allocator trade-off, separate from this
    change. This fix addresses the accumulation (unbounded-growth) case.

  • cargo build --release clean — built -p perry; full-workspace build needs the GTK/gdk-pixbuf libs for perry-ui-*, which CI provides.

  • cargo test --workspace --exclude perry-ui-ios --exclude perry-ui-tvos --exclude perry-ui-watchos --exclude perry-ui-gtk4 --exclude perry-ui-android --exclude perry-ui-windows passes — ran cargo test -p perry-runtime --lib gc:: (379/379) plus the end-to-end large-loop check; full workspace deferred to CI.

  • (if user-facing) Added or updated a test — gc:: unit tests cover the collection paths; behavior verified end to end above.

  • (if CLI / stdlib / runtime API changed) Updated docs/src/ — n/a.

  • (if touching a platform UI backend) Built -p perry-ui-<backend> — n/a.

Screenshots / output

n/a — see the bounded plateau in the test plan.

Checklist

  • I have NOT bumped the workspace version or edited CLAUDE.md / CHANGELOG.md (maintainer handles these at merge)
  • My commits follow the loose feat: / fix: / docs: / chore: prefix convention — fix(runtime): …
  • I've read CONTRIBUTING.md and agree to the Code of Conduct

Summary by CodeRabbit

  • Bug Fixes
    • Explicit garbage-collection calls now perform a full collection, improving cleanup of unreachable memory.
    • Automatic, threshold-based garbage-collection behavior remains unchanged.
  • Documentation
    • Added clearer notes explaining the expected behavior of manual collection calls.

…jects are reclaimed

With generational GC on (the default), `js_gc_collect()` dispatched a MINOR
cycle. The minor sweep intentionally skips dead-old-block reclamation
(`reclaim_dead_old_blocks = false` in `step_sweep`), and large objects (>16KB)
plus minor-GC survivors live in the OLD arena. So dead large/tenured objects
were never returned to the OS by an explicit `gc()`: a large-object allocation
loop grew unboundedly (RSS climbed ~175MB/round with `gc()` between rounds)
instead of plateauing.

Route `manual_gc_collect_now` (the single chokepoint for both the synchronous
`js_gc_collect` path and the deferred flush) through
`gc_collect_full_mark_sweep_with_trigger`, so an explicit `gc()` performs a full
mark-sweep that reclaims dead old-generation blocks. The same allocation loop
now plateaus. Automatic/threshold-driven minor collections are untouched.

Notes:
- V8's bare `gc()` is a scavenge (minor); this makes Perry's explicit,
  user-initiated `gc()` a thorough (full) collection. Gating the full sweep on
  `gc(true)` to mirror V8's minor/major split is a reasonable follow-up.
- A single lone dead large object whose block is the allocator's *current*
  old-gen target is kept mapped + reusable (not returned to the OS until
  reused) — a deliberate allocator trade-off, separate from this change.
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b894b521-23cb-49f1-bf6b-5b7399e26fa5

📥 Commits

Reviewing files that changed from the base of the PR and between cab9bca and 056b1a0.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/gc/policy.rs

📝 Walkthrough

Walkthrough

Explicit gc() calls now run a full mark-sweep collection with a manual trigger instead of the generational inner collection path. The added comments document the previous minor-cycle behavior and leave threshold-driven minors unchanged.

Changes

Manual GC semantics

Layer / File(s) Summary
Manual GC uses full sweep
crates/perry-runtime/src/gc/policy.rs
manual_gc_collect_now now calls gc_collect_full_mark_sweep_with_trigger(...) with GcTriggerKind::Manual, and the surrounding comments describe the explicit gc() behavior.

🎯 2 (Simple) | ⏱️ ~10 minutes

🐰 Hop-hop, a full sweep now gleams,
gc() follows its manual dreams.
Dead old blocks? Poof, away,
Little rabbit code runs clean today!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main runtime GC behavior change.
Description check ✅ Passed The description follows the template well, with summary, changes, issue, test plan, output, and checklist sections filled in.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@TheHypnoo
TheHypnoo merged commit b1fce68 into PerryTS:main Jun 26, 2026
15 checks passed
proggeramlug added a commit that referenced this pull request Jun 27, 2026
…n compute-only workloads (#5734)

* fix(gc): #5476 — reclaim dead old-gen blocks under reclaim pressure on compute-only workloads

A workload that churns large temporaries (>16 KB, born directly in the old
arena because they exceed LARGE_OBJECT_THRESHOLD_BYTES) grows the old generation
without ever exercising the nursery. Old-gen reclaim pressure schedules a
budgeted full cycle that *would* return the dead old blocks to the OS, but:

  1. the budgeted stepper is blocked whenever synchronous-only root scanners are
     registered (the common case in a compiled program), and
  2. even when it runs it only advances through bounded mutator-assist steps that
     a compute-only loop never drives to completion — no event-loop safepoint
     ever runs.

Either way no collection completes, so dead old blocks are never reclaimed and
RSS climbs unbounded (the #5476 benchmark reached 1.8 GB RSS / 164 MB live).

Fix: when old-gen reclaim pressure is what's due in gc_check_trigger — a rare
event gated by the ~32 MB growth / 48 MB absolute baseline, so it never fires on
the common nursery-churn path — run a direct full mark-sweep to completion, the
same non-budgeted collection an explicit gc() performs (#5714). The conservative
native-stack scan keeps it safe: anything still referenced from the stack or
registers at the allocation point is retained; only genuinely unreachable old
blocks are returned. A re-entrancy guard prevents a nested trigger from
recursing, and the full cycle's completion rebaselines the reclaim watermark.

Repro (50× new Array(100k) → filter→map→reduce, no gc() calls):
  before: RSS 58→103→149→191 MB, monotonic and unbounded
  after:  RSS 51→66→67→67 MB, stabilizes (matches Node's bounded RSS)

Adds a regression test asserting gc_check_trigger alone drives the old-gen
reclaim cycle to completion (collection runs, dead old bytes freed, live root
preserved) without any host GC step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(gc): isolate #5476 reclaim test's dead-old allocation in a non-inlined helper

Address CodeRabbit feedback on #5734: hold the unreachable old-arena pointer
entirely within an `#[inline(never)]` helper that returns only its size, so the
raw pointer never lands on the test's stack frame where a conservative scan
could pin it. The GC test guard already pins `Auto` scan mode (which skips the
native-stack scan), so the reclaim was real before this change too, but the
isolation makes the assertion robust regardless of scan mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ralph <ralph@skelpo.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.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.

2 participants