Summary
On the campaign's measurement stack, the three cheapest address predicates in value::addr_class each embed a native-handle question, so every ordinary heap pointer whose numeric arm cannot decide pays an exact malloc-registry lookup. Measured on one claude-code command: 65,133,719 classifications, 43,150,678 of them reaching the registry (66.25 %), and 9,225 finding a real wrapper (0.021 %) — 43,141,453 registry queries per command with nothing in them.
A fix exists and is measured at −7.63 % minimum command CPU (node gap 2.81× → 2.60×). It cannot be opened against main, because the code it fixes is not on main — hence this issue, so the work is not lost when the subsystem lands.
Why it is not a main issue yet
On current main:
pub fn is_above_handle_band(addr: usize) -> bool {
addr >= HANDLE_BAND_MAX // pure numeric test
}
and crates/perry-runtime/src/native_handle/canonical.rs does not exist. The canonical-handle wrapper subsystem — 9fcd96d1c, "wrap timer receivers in managed native handles" — is one of 15 commits on a campaign base that diverged from main 290 commits ago. That commit is what turned the three predicates into:
is_handle_band(addr) = addr < HANDLE_BAND_MAX || is_canonical_handle_addr(addr)
is_small_handle(addr) = (1..HANDLE_BAND_MAX).contains(&addr) || is_canonical_handle_addr(addr)
is_above_handle_band(addr) = addr >= HANDLE_BAND_MAX && !is_canonical_handle_addr(addr)
A filter pass then continues canonical_handle_parts_from_addr → handle_from_addr → gc_malloc_header_is_tracked. Whoever lands the wrapper subsystem should land the fix with it, or the subsystem arrives carrying a 7.63 % command-CPU regression that is invisible in review: the check is correct, cheap-looking and inlined into js_object_get_field_by_name 27 times.
The defect
The guard is a 1,024-bit monotone Bloom filter, and the wrapper population does not fit in it: 643–648 live addresses, 712–721 admissions and 69–76 retirements per command. Selectivity collapses inside a single process — 0.021–0.154 % of addresses pass after startup, 57.7–74.6 % after the command, a 430–3,500× loss with no configuration involved. Sizing dominates: clearing retired bits would still leave ~640 live addresses in a filter that cannot hold 60.
The fix that is already measured
WORDS becomes a per-owner parameter, and a RegistryAddrIndex keeps an exact live-address set — mutated only on wrapper creation and retirement, against tens of millions of probes — from which the bit array is recomputed once enough has changed, which is what releases retired addresses. Refresh is O(live) per O(live) changes: amortized O(1), no interval and no workload-specific setting. The hot side is unchanged: the same three bit tests, no lock, no allocation, no pointer chase. The canonical owner takes 256 words (16,384 bits, 2 KiB) and retires at all three sites that end a registration, each after the authoritative table.
Results: command CPU minima 1.18 → 1.09 s (−7.63 %), faster in four of five interleaved pairs and tied in the fifth, paired median −5.08 %; startup minima unchanged; largest per-pair peak-RSS increase +3.78 %, larger than the change's own footprint (2 KiB plus a ~645-entry set) so its cause was not the structure and was not measured. Mechanism, on a counter arm of the same change: registry-bound passes 43,150,678 → 144,246 (−99.67 %) with resolutions identical at 9,225, so the answers do not change.
Three tests encode it and each fails without it: the measured 644-address population at both widths (a 1,024-bit witness of the same population admits 80.4 % of non-members), retirement narrowing the index while keeping every live address, and 20,000 churned addresses against 64 resident ones not walking back to saturation.
What is deliberately not in it
The 232 call sites of the three range predicates are untouched. Separating the numeric range test from semantic receiver classification at each site is the larger architectural change an earlier caller census recommended, and it is independent of this one.
The measured fix is open for review at proggeramlug#1, against the campaign base rather than main for the reason above. Sibling finding: #10109. Sibling fix already on main-track: #10108.
Summary
On the campaign's measurement stack, the three cheapest address predicates in
value::addr_classeach embed a native-handle question, so every ordinary heap pointer whose numeric arm cannot decide pays an exact malloc-registry lookup. Measured on oneclaude-codecommand: 65,133,719 classifications, 43,150,678 of them reaching the registry (66.25 %), and 9,225 finding a real wrapper (0.021 %) — 43,141,453 registry queries per command with nothing in them.A fix exists and is measured at −7.63 % minimum command CPU (node gap 2.81× → 2.60×). It cannot be opened against
main, because the code it fixes is not onmain— hence this issue, so the work is not lost when the subsystem lands.Why it is not a
mainissue yetOn current
main:and
crates/perry-runtime/src/native_handle/canonical.rsdoes not exist. The canonical-handle wrapper subsystem —9fcd96d1c, "wrap timer receivers in managed native handles" — is one of 15 commits on a campaign base that diverged frommain290 commits ago. That commit is what turned the three predicates into:A filter pass then continues
canonical_handle_parts_from_addr → handle_from_addr → gc_malloc_header_is_tracked. Whoever lands the wrapper subsystem should land the fix with it, or the subsystem arrives carrying a 7.63 % command-CPU regression that is invisible in review: the check is correct, cheap-looking and inlined intojs_object_get_field_by_name27 times.The defect
The guard is a 1,024-bit monotone Bloom filter, and the wrapper population does not fit in it: 643–648 live addresses, 712–721 admissions and 69–76 retirements per command. Selectivity collapses inside a single process — 0.021–0.154 % of addresses pass after startup, 57.7–74.6 % after the command, a 430–3,500× loss with no configuration involved. Sizing dominates: clearing retired bits would still leave ~640 live addresses in a filter that cannot hold 60.
The fix that is already measured
WORDSbecomes a per-owner parameter, and aRegistryAddrIndexkeeps an exact live-address set — mutated only on wrapper creation and retirement, against tens of millions of probes — from which the bit array is recomputed once enough has changed, which is what releases retired addresses. Refresh is O(live) per O(live) changes: amortized O(1), no interval and no workload-specific setting. The hot side is unchanged: the same three bit tests, no lock, no allocation, no pointer chase. The canonical owner takes 256 words (16,384 bits, 2 KiB) and retires at all three sites that end a registration, each after the authoritative table.Results: command CPU minima 1.18 → 1.09 s (−7.63 %), faster in four of five interleaved pairs and tied in the fifth, paired median −5.08 %; startup minima unchanged; largest per-pair peak-RSS increase +3.78 %, larger than the change's own footprint (2 KiB plus a ~645-entry set) so its cause was not the structure and was not measured. Mechanism, on a counter arm of the same change: registry-bound passes 43,150,678 → 144,246 (−99.67 %) with resolutions identical at 9,225, so the answers do not change.
Three tests encode it and each fails without it: the measured 644-address population at both widths (a 1,024-bit witness of the same population admits 80.4 % of non-members), retirement narrowing the index while keeping every live address, and 20,000 churned addresses against 64 resident ones not walking back to saturation.
What is deliberately not in it
The 232 call sites of the three range predicates are untouched. Separating the numeric range test from semantic receiver classification at each site is the larger architectural change an earlier caller census recommended, and it is independent of this one.
The measured fix is open for review at proggeramlug#1, against the campaign base rather than
mainfor the reason above. Sibling finding: #10109. Sibling fix already onmain-track: #10108.