Skip to content

fix(gc): admit array-growth forwarding stubs to the budgeted-cycle classifier (#9717) - #9732

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9717-private-field-array-rooting
Closed

fix(gc): admit array-growth forwarding stubs to the budgeted-cycle classifier (#9717)#9732
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:fix/9717-private-field-array-rooting

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Fixes #9717.

Symptom

In a compiled hono server, every route returns 404 forever if the first request
arrives ~10–20 s after startup while background work (a setInterval scheduler)
is allocating. An early first request and the process is healthy for its whole
life. app.routes still lists all 103 routes; only matching fails. The private
field SmartRouter.#routes "is still an array, but empty."

Root cause

hono/router/smart-router keeps not-yet-installed routes in a #private array
and replays them into the concrete router on the first match(). That array is
pushed past its inline capacity, so array growth leaves a permanent forwarding
stub
at the pre-grow address, and the reference is never rewritten (#6228 /
#233) — the live #routes field keeps pointing directly at the stub.

A synchronous full trace handles this: its exact census
(ValidPointerSetBuilder::record_arena_header) admits every arena object, stubs
included, so mark_field_into_worklist marks the stub and
trace_one_worklist_header follows it to the live array.

A budgeted full trace — the one the idle-time reducer (PERRY_GC_IDLE_RECLAIM)
runs when the server goes quiet between requests — resolves membership through
the page-metadata classifier instead. classifier_valid_object_start rejected
every GC_FLAG_FORWARDED header by design (a dead metadata key's recycled bytes
can set that bit, #8040). So the field→stub edge was silently dropped: the stub
was never marked, the FORWARDED-follow never ran, and the array reachable only
through the stub was swept. The field then resolved to reused memory — an empty
array — and every match() returned 404. It reproduces only on a late first
request because an early one builds the router before any idle collection runs.

PERRY_GC_IDLE_RECLAIM=0 makes the bug vanish on the reporter's own binary
(0/2 vs 2/2 by request timing), which localizes it to the budgeted full cycle.

Fix

The classifier is documented as a census superset; for growth stubs it was
not. classifier_valid_object_start now admits a plausible forwarded arena stub
(GC_FLAG_ARENA set, valid obj_type/size — the shape a real growth stub has,
which separates it from off-heap bytes that coincidentally set the bit). The
forwarding target is still validated where it always was, in
trace_one_worklist_header's follow, so a garbage target simply stops the walk.
A PERRY_GC_DIAG counter (forwarded_stub_recoveries= on the [gc-incremental]
line) reports how many such stubs a budgeted cycle recovered.

Testing

  • New regression gc::tests::forwarded_stub_membership (two cases): plants
    the field→stub→array edge, asserts the pre-fix census-superset gate would have
    rejected the stub, drives a budgeted full cycle to completion, and checks
    the array reached only through the stub survives with its contents intact; a
    synchronous control keeps it without needing the recovery path. The
    budgeted case fails without the fix (verified by disabling the new branch)
    and passes with it.
  • cargo test -p perry-runtime gc:: — 1015 passed, 0 failed.
  • Black-box: the reporter's gated apps/api binary returns 404 on a t=16 s
    first request before this change and 200 after (see PR thread).

https://claude.ai/code/session_01GkugRUwRCCjfYYNfzyyvQv

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where idle-time garbage collection could incorrectly reclaim live arrays after they grew beyond their initial capacity.
    • Improved memory safety during budgeted garbage-collection cycles involving array growth.
  • Diagnostics

    • Added a diagnostic count for recovered forwarding stubs during incremental garbage-collection cycles.
  • Documentation

    • Documented the connect and listen methods in the bun API reference and type declarations.
  • Tests

    • Added regression coverage for arrays reachable through growth-related forwarding references.

@coderabbitai

coderabbitai Bot commented Sep 4, 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: Team

Run ID: 6188d796-414c-4620-99a6-c61d396f5881

📥 Commits

Reviewing files that changed from the base of the PR and between e06bb82 and fbb7e32.

📒 Files selected for processing (2)
  • docs/api/perry.d.ts
  • docs/src/api/reference.md

Included review availability: Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The GC now recognizes plausible array-growth forwarding stubs during budgeted full tracing. It records recoveries in a thread-local counter, reports the count in diagnostics, and adds regression tests. Generated Bun API documentation also adds connect and listen.

Changes

Forwarded Stub Membership

Layer / File(s) Summary
Forwarded stub classification
crates/perry-runtime/src/gc/barrier/mod.rs, crates/perry-runtime/src/gc/trace.rs
Adds forwarded arena-stub validation and accepts valid forwarded stubs during budgeted tracing.
Recovery counter diagnostics
crates/perry-runtime/src/gc/trace.rs, crates/perry-runtime/src/gc/mod.rs, scripts/gc_runtime_root_holders.json
Adds the recovery counter, emits it in incremental GC diagnostics, and records it as non-pointer runtime state.
Forwarded stub regression coverage
crates/perry-runtime/src/gc/tests/forwarded_stub_membership.rs, crates/perry-runtime/src/gc/tests/mod.rs, changelog.d/9732-idle-reclaim-growth-stub-membership.md
Adds budgeted and synchronous collection tests, registers the test module, and documents the fix.

Bun API Documentation

Layer / File(s) Summary
Bun API declarations and reference
docs/api/perry.d.ts, docs/src/api/reference.md
Adds connect and listen to the generated Bun API declarations and reference, and updates entry counts.

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

Merge Risk: ⚪ Minimal · up to fbb7e

This change preserves arrays reached through valid forwarding stubs during budgeted collection, preventing late route matching from losing routes. Regression coverage and reported runtime checks confirm the intended behavior, with no remaining merge-blocking risk.

Sequence Diagram(s)

sequenceDiagram
  participant BudgetedFullTrace
  participant classifier_valid_object_start
  participant plausible_forwarded_arena_stub
  participant trace_one_worklist_header
  participant emit_incremental_liveness_diag
  BudgetedFullTrace->>classifier_valid_object_start: classify candidate pointer
  classifier_valid_object_start->>plausible_forwarded_arena_stub: validate forwarded arena header
  plausible_forwarded_arena_stub-->>classifier_valid_object_start: accept plausible stub
  BudgetedFullTrace->>trace_one_worklist_header: validate forwarding target
  trace_one_worklist_header-->>BudgetedFullTrace: mark reachable object
  emit_incremental_liveness_diag->>classifier_valid_object_start: read recovery counter
Loading

Suggested reviewers: jdalton

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The GC changes are in scope, but the additions of connect and listen to docs/api/perry.d.ts and docs/src/api/reference.md are unrelated to issue #9717 and the stated GC objectives. Remove the unrelated connect and listen documentation changes, or provide a linked issue and explicit PR objective that requires them.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main GC fix: admitting array-growth forwarding stubs to the budgeted-cycle classifier.
Description check ✅ Passed The description is detailed and covers the symptom, root cause, fix, related issue, and test results. It does not use the template headings or checklist, but the required information is mostly present…
Linked Issues check ✅ Passed The changes address issue #9717 by preserving private-field arrays reached through forwarding stubs during budgeted idle-time GC, while retaining synchronous tracing behavior and adding regression cov…
Docstring Coverage ✅ Passed Docstring coverage is 81.82% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 11 functions across 6 files. (1 skipped: 1 …
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

…assifier (PerryTS#9717)

A `#private` array pushed past its inline capacity leaves a permanent
forwarding stub at the pre-grow address, and the reference pointing at it is
never rewritten (PerryTS#6228/PerryTS#233), so a live slot can keep naming the stub. A
synchronous full trace handles this: its exact census (`record_arena_header`)
admits every arena object, stubs included, so `mark_field_into_worklist` marks
the stub and `trace_one_worklist_header` follows it to the live array.

A budgeted full trace — what the idle-time reducer (`PERRY_GC_IDLE_RECLAIM`)
runs when a server goes quiet — resolves membership through the page-metadata
classifier instead. `classifier_valid_object_start` rejected every FORWARDED
header (a dead metadata key's recycled bytes can set the bit, PerryTS#8040), so the
field->stub edge was dropped: the stub was never marked, the FORWARDED-follow
never ran, and the array reachable only through it was swept. The private
field then resolved to reused memory as an empty array, so every hono route
`match()` returned 404 for the life of the process — but only when the first
request arrived ~10-20s after startup while background work allocated.

The classifier is documented as a census superset; for growth stubs it was
not. It now admits a plausible forwarded arena stub (`GC_FLAG_ARENA` set,
valid obj_type/size), the shape a real growth stub has. The forwarding target
is still validated in the follow, so a garbage target stops the walk. A
`PERRY_GC_DIAG` counter (`forwarded_stub_recoveries=`) reports recoveries.

Regression: gc::tests::forwarded_stub_membership plants the edge, asserts the
pre-fix gate would have rejected the stub, drives a budgeted full cycle, and
checks the stub-reached array survives; a synchronous control keeps it without
recovery. The budgeted test fails without the fix and passes with it.

Claude-Session: https://claude.ai/code/session_01GkugRUwRCCjfYYNfzyyvQv
@proggeramlug
proggeramlug force-pushed the fix/9717-private-field-array-rooting branch from b51c51b to e06bb82 Compare September 4, 2026 13:35
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Black-box confirmation on the reporter's apps/api

Built the reporter's gated apps/api (all 14 scheduler jobs, WebSocket handler, DB pool) with a pre-fix runtime and with this branch, and ran the exact repro — first request at t≈16 s while the scheduler ticks. The in-process probe at t=20 s (app.request after the idle collection) measures #routes integrity directly, independent of socket timing.

runtime /v1/health socket @ t=16 s in-process probe @ t=20 s
pre-fix (api-gate4) 404 (5/5) 404, router permanently empty (5/5)
this branch 200 (5/5) 200, routes=103 (5/5)

The permanent 404 is gone and the private-field array survives the idle-time collection with all 103 routes intact.

For reference, on the pre-fix binary PERRY_GC_IDLE_RECLAIM=0 alone also flips it to 200 (2/2), which is what localized the trigger to the budgeted full cycle this PR fixes.

Validation: cargo test -p perry-runtime gc:: → 1015 passed, 0 failed; the new gc::tests::forwarded_stub_membership budgeted case fails without the fix (verified by disabling the new branch) and passes with it. The full local lint-gate runner reports one failure — warnings: cargo check --workspace --all-targets — which is the known Linux-only pthread_* redeclaration on origin/main (CI runs on macOS, where those #[cfg(target_os = "linux")] blocks compile out); perry-runtime --lib is clean under -D warnings with only that clash allowed. The red self-test-checkers context is a pre-existing check_thread_locals.py failure on files byte-identical to origin/main, unrelated to this change.

@proggeramlug
proggeramlug marked this pull request as ready for review September 4, 2026 14:04
Pre-existing drift on main: the runtime exports `bun.connect` and `bun.listen`
(manifest 2089->2091 entries) but `docs/api/perry.d.ts` and
`docs/src/api/reference.md` were not regenerated, so the `check` job's API-docs
drift gate is red for every PR branched from main. `scripts/regen_api_docs.sh`
produces exactly this diff (deterministic; unrelated to the PerryTS#9717 GC fix in
this PR). Committing the generated artifacts as the gate instructs.

Claude-Session: https://claude.ai/code/session_01GkugRUwRCCjfYYNfzyyvQv
@proggeramlug

Copy link
Copy Markdown
Contributor Author

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

@proggeramlug

Copy link
Copy Markdown
Contributor Author

CI note: pr-gate is red only on a pre-existing, repo-wide warnings failure

The required pr-gate will be red, but not on anything in this PR:

  • check (API-docs drift) — pass (the second commit here regenerates the manifest docs for bun.connect/bun.listen).
  • e2e-scopedpass. cargo-test, lint, gap-suite still running.
  • warningsfail, and this is the blocker.

warnings (runs-on: ubuntu-latest, -D warnings) fails on -D clashing-extern-declarations:

error: `pthread_getattr_np` redeclared with a different signature
error: `pthread_attr_getstack` redeclared with a different signature
error: `pthread_attr_destroy` redeclared with a different signature
error: could not compile `perry-runtime` (lib) due to 3 previous errors

Root cause is pre-existing and unrelated to this change:

Both extern "C" blocks are #[cfg(target_os = "linux")], so the clash fires only on the Ubuntu warnings runner — which is every PR and main. Neither file is in this PR's diff, and both carry the same declarations on origin/main, so warnings fails identically there. Harmonising the two signatures is a one-line fix, but it belongs in its own change, not folded into this GC fix.

Everything this PR actually touches is green or validated: new gc::tests::forwarded_stub_membership fails without the fix and passes with it, the full gc:: suite is green, and the reporter's app goes 404→200 with the fix.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Second pre-existing pr-gate blocker: cargo-test

pr-gate is red on a second count unrelated to this PR. In the cargo-test job, all of perry-runtime passes (3100 passed, 0 failed), including this PR's two new tests (gc::tests::forwarded_stub_membership::… both ok). The one failure is in the perry CLI crate:

commands::compile::build_cache::tests::codegen_env_vars_are_build_cache_inputs ... FAILED
  these codegen env vars key neither the build cache nor an exclusion (#6394's rule):
  ["PERRY_CONCAT_SITE_CACHE"]

That test scans the source tree for env::var("PERRY_…") and requires each to be registered in BUILD_CACHE_ENV_VARS or BUILD_CACHE_ENV_EXCLUSIONS. PERRY_CONCAT_SITE_CACHE was introduced by #9514 (2026-09-02, crates/perry-codegen/src/concat_site_cache.rs) without registering it. It is not in this PR's diff, so the test fails identically on origin/main. One-line fix, in its own change.

Summary of pr-gate state for this PR — both red jobs are pre-existing repo-wide breakage from 2026-09-02, neither from #9717:

job result cause
check (API-docs drift) pass fixed by this PR's docs commit
lint, gap-suite, e2e-scoped, gc-stress-build pass
cargo-test fail #9514PERRY_CONCAT_SITE_CACHE unregistered (perry-runtime itself, incl. this PR's tests, is green)
warnings fail #9521 — clashing pthread_getattr_np extern decls (Ubuntu -D warnings)

Both need a one-line fix each, separate from this GC change.

@proggeramlug
proggeramlug deleted the fix/9717-private-field-array-rooting branch September 4, 2026 17:32
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.

Private-field array loses its contents under background load: Hono matches nothing when the first request is late

1 participant