Skip to content

Merge train: #9728, #9729, #9730, #9731, #9732, #9733 - #9735

Merged
proggeramlug merged 9 commits into
mainfrom
train120
Sep 4, 2026
Merged

Merge train: #9728, #9729, #9730, #9731, #9732, #9733#9735
proggeramlug merged 9 commits into
mainfrom
train120

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Merge train: #9728, #9729, #9730, #9731, #9732, #9733, plus one gate verdict. Validated once as a tree, rebase-merged so each commit keeps its author.

Three of these are GC changes that interact — when idle reclaim runs (#9731), what a budgeted cycle will trace (#9732), and when a descriptor may be retired (#9733) — which is why they were validated together rather than landed separately.

#9732 — a hono server 404s forever on a late first request (#9717). hono/router/smart-router replays not-yet-installed routes from a #private array on the first match(). Growing that array past its inline capacity leaves a permanent forwarding stub, and the live field keeps pointing at the stub. A synchronous full trace copes: its exact census admits every arena object, stubs included. A budgeted full trace — what the idle reducer runs when the server goes quiet — resolves membership through the page-metadata classifier, which rejected every GC_FLAG_FORWARDED header by design (#8040). So the field→stub edge was dropped, the array reachable only through the stub was swept, and every route returned 404. Only reproduces on a late first request, because an early one builds the router before any idle collection.

The classifier now admits a plausible forwarded arena stub — GC_FLAG_ARENA plus a valid obj_type/size. That is precisely what separates it from #8040's case, whose recycled bytes carried obj_type = 104 (no GcTypeInfo recognises it, per copying.rs:335); the pre-existing #8040 tests still pass. The forwarding target is still validated in the follow, so a garbage target stops the walk. The new regression asserts the pre-fix gate would have rejected the stub and fails without the fix, verified by disabling the branch.

#9731 — right-size idle arena capacity. Adds no new env knob (reuses PERRY_GC_DIAG), so there is no new mode needing a CI arm to exercise its OFF state. Its tests discriminate in both directions: !owed() for a single low collection, owed() for two consecutive — the negative assertion is what proves the hysteresis gates rather than always firing.

#9733 — retire uncarried shape descriptors (#9726). Stacked on #9724 (landed in #9734); only its top commit is picked here. Retirement is gated on a full-trace census of reachable objects, complete by construction, with per-record carrier flags set at each stamp site covering ids held outside object headers — better than a central enumeration that must be kept in sync. Minor and budgeted cycles stay conservative. It preserves all 17 of shape_descriptor_census.py's pinned patterns (13 on main before #9724).

#9730 — labeled escape out of nested loops (#9199). break O from a nested loop produced Cannot read properties of undefined (reading 'done'); continue O produced nothing at all, with the for await never yielding and never settling. Handles Stmt::Try/finally along the unwind path, and keeps the rows that already worked via the switch-wrapped route (#9186/#9189) as regression guards.

#9729 — inline caches per used site (#9708). 262k caches on the Claude Code bundle, 25 MB of zero-fill, 18.7 MB dirty resident at idle, replaced by an 8-byte slot per site with words allocated on first priming miss and published by CAS. GC-neutral: the cache word is (ShapeId << 32) | class_id — packed integer ids, never a pointer — and perry_ic appears nowhere under gc/, so these were never traced roots on main either.

#9728 — dynamic x["toString"]() on a number (#9713). Formatted with bare Rust f64::to_string(), which never switches to scientific notation and spells the infinities inf, while the same value's four static renderings were correct. All three arms now route through js_number_to_string, and the radix argument the old code ignored is honoured. Gap test pins -0, 1e21, 1e-6/1e-7, 2.2e-308, 1e-310, Infinity, NaN.

Gate verdict (mine). #9732 adds a PERRY_GC_DIAG counter, FORWARDED_STUB_MEMBERSHIP_RECOVERIES, without classifying it, so gc_runtime_root_holders went red. It is a Cell<u64> tally holding no address; recorded not_a_gc_pointer.

Validation

64/64 lint gates; release build; perry-runtime, perry-transform, perry-codegen (all RUST_TEST_THREADS=1) — all green.

Ralph Küpper added 9 commits September 4, 2026 16:24
…isplay

A dynamically dispatched `x["toString"]()` on a number reached three arms of
the native-method tower that formatted with a bare `f64::to_string()`. That
is Rust's Display: it never switches to scientific notation and spells the
infinities `inf`, so `2.2e-308` printed ~308 decimal digits and `Infinity`
printed `inf` — while the same value's four static renderings were correct
in the same program.

The three arms are the plain-number and boxed-`Number` `toString` in
`dispatch_common` and the boxed-`Number` `toString`/`toLocaleString` in
`dispatch_primitive`. All now call `js_number_to_string`, which carries the
spec's `|n| >= 1e21 || |n| < 1e-6` switch and its own integer fast path.
This is the same mistake #3987 fixed in the string-concat fast paths; these
arms were not part of that sweep.

A neighbouring defect in the same arms rides along: a boxed receiver dropped
an explicit radix, so `new Number(255).toString(16)` answered "255". Both
boxed arms now route an explicit radix through `js_jsvalue_to_string_radix`,
as the unboxed arm already did. `toLocaleString`'s argument is a locale, not
a radix, so it keeps ignoring it.

Closes #9713
…9708)

Every inline-cache site owned a `[12 x i64] zeroinitializer` global, 96 B
of __bss per site whether or not the program executed it; on the Claude
Code bundle that was 262k caches and 18.7 MB dirty at idle. A site now
owns `@perry_ic_N = private global ptr null`; the runtime allocates the
cache words from an arena on the site's first priming miss and publishes
them into the slot with a CAS. Hit paths load the slot, fold `!= null`
into the receiver guard they already evaluate, and read the words through
the pointer; every miss entry takes the slot's address. Cache layout and
prime/evict policy are unchanged; IC hit counters are identical.

Claude-Session: https://claude.ai/code/session_014RVEmbrpNKHwaQdrcMgMHc
A `break label` / `continue label` that targets an outer loop from inside a
nested loop threw `TypeError: Cannot read properties of undefined (reading
'done')` (break) or silently produced nothing (continue), in sync
generators, async generators and async functions alike.

Generator linearization gives each loop one break sentinel and one continue
sentinel, so a completion can only name the loop it sits in.
`rewrite_labeled_bc_in_stmts` converts labeled completions to plain ones at
the labeled loop's own body level and stops at nested loops — a plain
completion there would bind to the nested loop. The escape that was left
survived into a state body, where the dispatch lowering has no sentinel for
it and dropped it. The code noted the gap ("the single-sentinel scheme
can't yet distinguish targets").

Unwind it through a carrier local instead, so every completion the
linearizer sees is plain: the escape sets the carrier and plain-breaks out
of its loop, each intermediate loop propagates with `if (carrier != 0)
break`, and the labeled loop turns the carrier back into the real
`break`/`continue`. A switch carrying an escape is desugared to `if`s
first, since a plain `break` inside a switch binds to the switch.

The issue's own repro was already fixed by #9189; the surviving hole is the
cross-loop target, where the switch turns out to be incidental.

Closes #9199
…assifier (#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 (#6228/#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, #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
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