Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
68b96bc
docs(changelog): fragment for PR 9833
Sep 6, 2026
b41c4b9
diag(hir): report every native-instance tag at the two entry points t…
Sep 6, 2026
42cadbb
fix(codegen): root_reload's cost cap counts root loads, not derived v…
Sep 6, 2026
6d32162
fix(runtime): prove an unpatched iterator prototype without allocating
Sep 6, 2026
e7cbb3b
test: pin the iterator-prototype `next` patch surface against node
Sep 6, 2026
d8a8e8c
test: cover a non-callable prototype `next` in the patch fixture
Sep 6, 2026
1fd247f
chore: renumber the changelog fragment to the filed issue (#9846)
Sep 6, 2026
2385be5
perf(buffer): give the buffer-registry probe the set filter its windo…
Sep 5, 2026
08279ec
docs(changelog): fragment for PR 9828
Sep 5, 2026
e7064b2
test: audit Solid client reactivity under native compilation
Sep 5, 2026
d3d8cb6
docs: identify the retained array-growth alias in the Solid audit
Sep 5, 2026
37a78bb
docs: link verified Solid GC follow-up
Sep 5, 2026
7832e61
feat(ui): bridge Solid universal rendering to native widgets
Sep 5, 2026
6ae411c
docs: number Solid native renderer changeset for PR 9825
Sep 5, 2026
cc2fbba
fix(gc): price the tiny-parse pressure guard by the productivity backoff
Sep 6, 2026
ec45e03
docs(changelog): add the #9838 fragment
Sep 6, 2026
6b4215e
docs(changelog): do not open a line of the #9838 fragment with a bare…
Sep 6, 2026
3ac293b
fix(gc): re-baseline the whole-arena trigger after a malloc-pressure …
Sep 6, 2026
c9dff4c
fix(gates): rebuild the root-holder inventory structurally after #983…
Sep 6, 2026
36fd76b
diag(gc): trigger/full/budgeted/charge attribution, per-minor surviva…
Sep 4, 2026
7a6dc54
perf(runtime): stop minting throwaway strings and per-character descr…
Sep 5, 2026
e54a627
perf(runtime): dispatch String.prototype.codePointAt natively
Sep 5, 2026
b7879e6
style: cargo fmt
Sep 6, 2026
3fd2586
fix(train): drop the duplicated canonical_char_cache test module
Sep 6, 2026
e17e86e
fix(train): carry #9838's PASS1_MARKED re-audit for the #9831 policy.…
Sep 6, 2026
bb66131
fix(train): drop the unused note_descriptor_target re-export
Sep 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions changelog.d/9795-string-code-point-at-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### Runtime

- perf(runtime): `String.prototype.codePointAt` is answered by the native
string-method dispatch instead of falling through to the primitive-method
fallback. It had a prototype thunk but no dispatch arm, so every call
resolved `globalThis.String.prototype.codePointAt`, cloned that closure to
rebind `this`, and — the thunk not being registered strict — ran `ToObject`
on the receiver, minting a `String` wrapper with an own index property per
UTF-16 code unit. Grapheme-aware text measurement calls it once per
character: on the compiled claude-code TUI it was the only method name
reaching the fallback, at 99,008 calls and 99,008 wrappers per 400-character
streamed reply (`PERRY_GC_DIAG=1`, `[gc-primitive-dispatch]`).
1 change: 1 addition & 0 deletions changelog.d/9825-solid-native-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Add `perry-solid`, a Solid universal-renderer bridge for native stacks, text, buttons, spacers, and dividers, with hyperscript authoring, reactive properties, keyed widget moves, and owner disposal. Add a counter/list example, a Node/native release fixture, and a macOS Geisterhand smoke test. Correct macOS indexed stack insertion and retained layout metadata, match the compiler's reorder arguments to the native floating-point ABI, and implement Windows child reordering. Solid JSX compilation remains a separate stage of #4644.
32 changes: 32 additions & 0 deletions changelog.d/9828-buffer-registry-addr-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
**The buffer-registry probe stops answering "maybe" to three quarters of the
addresses it is asked about** (#9828).

`is_registered_buffer` guards its three registries with
`BUFFER_LIKE_ADDR_WINDOW`, a process-global min/max span, and the 98.0 %
rejection rate in its doc comment is measured on `claude-code --help` — a run
that registers **10** buffers. A streaming turn registers **213**, scattered
across a **527 MB** span, so `[lo, hi]` covers half a gigabyte of ordinary heap
and stops discriminating: on one 400-character reply, 34.6 million probes, of
which the window admits **73.63 %** to the out-of-line lookup, and **99.79 % of
those find nothing**.

The probe now consults `RegistryAddrFilter` behind the window — the set filter
added after #9272 for exactly this failure, where a registry's entries are
ordinary heap objects interleaved with everything else. Rejection goes from
26.37 % to **96.46 %**, removing **24.25 million out-of-line calls per reply**,
each of which cost a thread-local resolution and a hash. True positives are
unchanged.

The saturation question that structure demands was answered before adopting it:
`RegistryAddrFilter` accrues bits per admission and never clears them, so a
high-churn set would degrade it into the state #9807 documented for the
per-object layout filter. Buffers are the opposite case — probing is hot,
registration is rare — and 213 cumulative admissions against 1,024 bits gives a
10.0 % false-positive rate. `PERRY_BUFFER_DIAG` reports the occupancy, the
window bounds and the rejection rate so the question stays answerable.

In the profile, `is_registered_buffer_slow` falls from 169 to 25 leaf samples
(−85 %); its inline caller rises 96 to 123 as the filter's hashes move there,
so the pair falls 44 % overall. That is roughly half of the 3.19 % the profile
attributed to the slow path, and it is below the streaming rig's resolution, so
turn CPU is unchanged.
29 changes: 29 additions & 0 deletions changelog.d/9833-probe10-margin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
**The `10_store_receiver_across_alloc` GC-ratchet probe was running no
collection at all, and has been given margin** (#9833, fixes #9832).

The probe exists to catch a store receiver held in a register across an
evacuating minor — the stale-root class of #6970 / #9523 — and its own header
lists three conditions that must all hold for it to bite, the third being an
allocating right-hand side. On `main` it reported `minor_cycles = 0`: no minor
ran, so no evacuation happened, so there was no window and the probe measured
nothing. `freed_bytes = 0` alongside `copied_objects = 0` rules out "a minor ran
and found nothing live".

The cause was margin rather than a bug. At 200,000 iterations the probe crossed
the nursery threshold exactly once, and #8313 — shrinking a two-field object
from 56 to 40 bytes — put it under. A probe that fires exactly one collection is
one optimisation away from firing none. It is now 2,400,000 iterations, which
measured 9 minors and keeps several after a further eightfold reduction in bytes
per object, for about 120 ms on `wall_ms`, which the gate does not band.

Verified by sabotage rather than by the counter moving: removing the allocating
RHS returns `minor_cycles=0 copied_objects=0 freed_bytes=0`, the exact signature
the probe had while broken.

`heap_used_bytes` returns from 464,072 to 244,648 against a pinned baseline of
220,384 — the +110.57 % that cell showed on `main` was the post-`gc()` residue
of a run in which nothing was ever collected, not retention.

Five further probes (`01`, `02`, `03`, `09`, `11`) currently sit at
`minor_cycles == 1` and are one allocation win away from the same silent state;
that is recorded in #9832 and not addressed here.
37 changes: 37 additions & 0 deletions changelog.d/9838-tiny-parse-pressure-pricing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
**The tiny-parse pressure guard now prices the collections it forces by the
adaptive step's productivity backoff (#9831).** On the compiled claude-code
TUI a 3300-character streamed reply spent 30–41 s of CPU in the base arm and
27.8–29.2 s with the fix (mean −19 %, every interleaved pair a win), with
post-turn and post-idle RSS flat within the base's own spread and peak RSS
unchanged.

Issue #9831 measured the `ArenaBytes` arm firing 51 times in one 66-delta reply,
each collection freeing a median 131 KB, while the adaptive step sat
saturated at 1 GiB — and located the discarded backoff in the arm's own
ceiling clamp. That clamp was not what re-fired the arm: between two firings
the arena grew a few hundred KB against a trigger armed 16–128 MB above the
post-collection total. What pulled the trigger down was the tiny-parse
pressure guard, which after every `JSON.parse` growing the arena by ≤ 1 MB
tested the absolute `arena_in_use_bytes() >= 48 MB` and, if it held, set the
trigger to "now". That is a quantity no collection can lower below the live
set, so on a heap that sits above it permanently every small parse (one per
SSE delta) forced a minor whose backoff nothing read — #9589's shape one
trigger over.

The guard now also requires the arena to have grown, since the last
collection of any kind ended, by a headroom priced from the step: the step
rescaled so its power-on value buys the 16 MB headroom floor and each
doubling the arm's clamp discards buys one more doubling, bounded by the
trigger ceiling. A productive collection keeps today's cadence; an
unproductive one earns room. The parse-boundary collector re-prices a
pending request so a collection that already satisfied it is not followed by
a second. `PERRY_GC_DIAG=1` gains a `[gc-tiny-parse] forced collection …`
witness line. The arm's own arithmetic is unchanged and now documents why
(pricing it directly was measured at −10.8 % CPU for +22 % footprint, the
issue's refuted branch).

Validation: `test_memory_json_churn.ts` (the guard's motivating shape) is
byte-identical in output and RSS in all four GC modes; 48/48 `test_gap_gc_*`
and 8/8 `test_gap_json_*` pass; nine new `gc::tests::tiny_parse_pressure`
tests pin the pricing and the predicate, sabotage-proved against both the
old absolute guard and a raw-step pricing.
76 changes: 76 additions & 0 deletions changelog.d/9840-arena-trigger-rebaseline-symmetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
### Fixed

- **A nursery collection triggered by malloc pressure now re-baselines the
whole-arena GC trigger, as `GC_NEXT_TRIGGER_BYTES`'s own contract already
said it did.** The cell is documented as "bumped after each
`gc_collect_inner` based on collection effectiveness". It was not:
`gc_finish_arena_trigger_collection` re-baselined it, and
`gc_finish_malloc_trigger_collection` — the finisher for the *same nursery
collection* with the malloc sweep added — did not. So the whole-arena
threshold was measured from the last **arena-kind** collection rather than
from the last collection, and a run of `MallocCount` minors could walk the
arena total across a threshold nothing had refreshed.

The asymmetry predates the budgeted split (`9d3bd2e3b`'s pre-split
`gc_check_trigger` had the same two branches) and is correct in the *other*
direction, which is unchanged and still pinned by
`test_gc_check_trigger_copied_minor_without_malloc_sweep_preserves_malloc_trigger`:
an arena minor that skips the malloc sweep must not move the malloc trigger.
A `MallocCount` minor has no such exemption — it swept the arena.

Measured on the perry-compiled claude-code TUI (`PERRY_GC_DIAG=1`, per
firing, four 3300-character captures across two independently built
binaries): the streaming turn ran a strict 6:1 pattern in which six
`MallocCount` minors promoting ~3.2 MB each crossed the stale arena
threshold *inside the sixth minor*, and at the very next safepoint the arena
arm fired on a nursery of **856 bytes** (`promoted_bytes=216
freed_bytes=640`) — one collection in seven, paying the whole
per-collection fixed cost (root scan, side-table prune, dirty-page restore)
to free 640 bytes.

**The length matters and every figure here states it.** Shape (b) needs a
run of promoting `MallocCount` minors to walk the total across the stale
threshold, so it exists on the long reply only: the 3300-character captures
run 48–62 `MallocCount` firings each, and the 400-character capture runs
**zero** (its collections are all `ArenaBytes` plus a handful of
`OldGenBytes`). At 400 characters this change is therefore expected to be
flat on every counter, and that is a prediction rather than a hope — there is
no producer for the shape at that length, with or without the in-flight
change that moves `RegExpHeader`s (the arm's only measured input on this
program) to the nursery.

Full collections are deliberately excluded: after a full that released
blocks, the un-moved trigger sits *further* above the new total, which is the
conservative direction, and a full's cadence belongs to the old-generation
band rather than to this arm.

This is the same symmetry #9831 gave the tiny-parse pressure guard's base
cell, applied to the one pacing quantity still keyed to a single collection
kind. The two cells are in different units on purpose and stay that way: the
guard's base is `arena_in_use_bytes()` (bump offsets, what it reads at each
parse boundary); this trigger's base is `arena_total_bytes()` (committed),
which is what `next_arena_trigger_base()` is compared against.

One coupling beyond the trigger, stated because it touches a fix that landed
hours earlier: `GC_STEP_BYTES` had exactly one production writer — the arena
finisher — and #9831 made it an *input* to the tiny-parse pressure guard's
headroom. Scoring a `MallocCount` minor's productivity therefore moves that
guard too. That is the same symmetry rather than a side effect (the step is
documented as "collection effectiveness", not "arena-kind collection
effectiveness"), and on the compiled claude-code TUI it moves the guard in
the *conservative* direction. Estimated over 209 `MallocCount` firings in the
four 3300-character captures, `pct_freed` has a median of 4–5 % and lands
`<10 %` in 194 cases, `10–24 %` in 10, `25–84 %` in 5 and `>84 %` in none —
so 93 % of these collections take the "< 10 % → double" band and push the
step up, which raises the guard's headroom. `[gc-arena-rebaseline]` carries
`pct=` and `step=` for both arms so this is read off a capture rather than
estimated from a neighbouring diagnostic, which is all the pre-fix diag
allowed.

The arm's dueness predicate is byte-identical, so when it is due it still
fires the same collection. `PERRY_GC_ARENA_REBASELINE_ALL=0` restores the old
asymmetry — its OFF state is asserted in CI as the knob kill-policy requires,
by a test that is simultaneously the sabotage proof for the two ON-state
tests (the OFF branch *is* the deleted call) — and `PERRY_GC_DIAG=1` gains a
`[gc-arena-rebaseline] arm=…` line attributing each re-baseline to the
finisher that performed it.
55 changes: 55 additions & 0 deletions changelog.d/9846-iterator-next-override-probe-allocation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
### Fixed

- **Every built-in iterator step allocated a `"next"` key string to learn that
nothing was patched.** `call_overridden_iterator_next` — the per-step probe
that lets a user replacement of `%ArrayIteratorPrototype%.next` (and the Map
/ Set / String family prototypes) drive `for…of`, spread, `Array.from` and
manual `.next()` — ended in a by-name prototype lookup that minted a fresh
4-byte `"next"` string on every call. One 32-byte allocation per iteration
step of every array, Map, Set and string iterator in the program.

The existing early-out could not prevent it. `ITERATOR_PROTOTYPE_PTR == 0`
("the tower was never materialized, so no override can exist") is **dead on
any program that has allocated one iterator**: every iterator allocator calls
`attach_iterator_prototype`, which calls `ensure_iterator_prototypes`, which
builds the tower. The guard is true exactly once and false forever after.

Replaced by an allocation-free proof that runs on the path every real program
takes: the prototype's OWN `next` slot still holds a closure whose native
entry is the canonical thunk (the certified non-allocating own-field read,
#9480), AND no accessor descriptor is recorded for `"next"` on it (the
per-key Bloom bit `set_accessor_descriptor` sets before inserting, #6759 C2 —
needed because `defineProperty(proto, "next", {get})` leaves the old closure
in the data slot and puts the accessor in the side table). Anything else —
replaced, deleted, an accessor, a bound copy — takes the by-name path
unchanged.

Affected files:

- `crates/perry-runtime/src/object/iterator_prototypes.rs` — the
`prototype_next_is_canonical` probe, ahead of the by-name lookup.

Measured: the 2026-09-06 claude-code allocation census ranked this site third
by count — ~122,880 allocations of 32 bytes per 400-character reply, 17.1 %
of the top-30 allocation count — and misattributed it to `Intl.Segmenter`
substring copying. Resolved by an explicit caller walk in the shipped binary:
`js_for_of_next+0xd0` → `dispatch_array_iterator_method_inner+0x218` (a `bl`
to `call_overridden_iterator_next`) → `+0x67c` (a `bl` to
`js_string_from_bytes_with_capacity`) → `string_storage_alloc`.

Validation: `test-files/test_gap_iterator_prototype_next_patch.ts` drives a
replaced `next` through `for…of`, spread, `Array.from` and manual `.next()`
on all four families, and covers restore-by-identity, a second replace after
a restore, a bound copy of the original (which must NOT be mistaken for the
builtin), an accessor `next`, and a deleted `next`. The unit counter asserts
that 1,000 probes on an unpatched iterator with the tower materialized move
the arena by ZERO bytes, with the minor-cycle count pinned so a collection
inside the window cannot manufacture a zero delta.

Counter on a relinked claude-code binary (this fix plus a measurement-only
hit/miss counter; before the fix every probe allocated, so `hits + byname` is
the pre-fix count and `byname` is what survives): a 400-character reply runs
**144,189 / 144,303** probes and a 3300-character reply **887,076**, with
**`byname = 0` on every one of the 173 per-minor reports across three runs**
— the proof answers 100 % of probes on a real program. At 32 B a string that
is 4.6 MB and 28.4 MB of allocation removed per process respectively.
16 changes: 16 additions & 0 deletions changelog.d/9847-nativeinst-registry-diag.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
`PERRY_NATIVEINST_DIAG=1` reports every native-instance tag as it is created.

`register_native_instance` and `push_module_native_instance` are the only two
entry points through which such a tag can come into existence, so a diagnostic
on them cannot miss one the way a diagnostic on guessed construction sites can
— which is why it is placed there. One line per registration:

```
[nativeinst] REGISTER push_module name="O" -> child_process::Instance
```

The env var is excluded from the build-level cache, because a cached build
reuses the finished binary and never lowers HIR, so the report would print
nothing — and nothing is indistinguishable from "no tag was ever registered".

Off, the cost is one relaxed atomic load per registration.
Loading
Loading