You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
codegen: intermittent wrong sum in heavy mixed Array+TypedArray+WeakMap loop (non-GC, post-#6132) #6136
After #6132 was fixed, a smaller, intermittent wrong-answer remains in a heavy mixed loop that reads an object's plain-Array field (for-of) and typed-array field (for-i) plus a WeakMap lookup, every tick, over ~100 retained nodes. It is non-GC — it reproduces with the moving GC disabled (PERRY_GC_MOVING_SAFEPOINT=0), so it is unrelated to relocation. It was masked before by the #6132 crash.
Symptom
The final checksum alternates between two close values across runs — e.g. 275722386 and 276061186 — where 276061186 matches Node. So Perry intermittently computes a slightly-low sum (~339k short). Deterministic programs must not vary run-to-run.
Repro: scratchpad/vB.ts (object { id, payload:number[], buf:Uint32Array }, retained + pruned to ~100, per-tick sum of payload + buf + wm.get(node); async ticks). Reproduces with the flag off.
Two close alternating values suggests one contribution (one node's wm.get, or one element of one array) is occasionally dropped/misread. Next step: bisect vB by removing the WeakMap term, then the payload term, to localize which read intermittently differs; then --trace llvm the surviving loop.
Root-caused, and already fixed on current main by #6190 — recommending we close this. Full diagnosis below, since the mechanism is worth recording (and it leaves one landmine behind).
Determinism fork: same binary varies run-to-run → runtime, address-dependent
Compiled once at the reporter's commit (4ab6ffb4a) and ran that single binary repeatedly: 11/40, 16/40, 13/40 bad. Not compile-time nondeterminism — 8 independently-linked binaries from identical source showed no camps. The decisive control:
ASLR (via lldb)
result
disable-aslr true
12/12 correct
disable-aslr false
2/12 wrong
Which term: the typed-array term, and churn is required
40 runs each at the era build: vB 13/40 bad · vB_payonly 40/40 correct · vB_bufonly 12/40 bad · vB_nochurn 40/40 correct · vB_low 40/40 correct. Both vB and vB_bufonly come up short by exactly 338800.
Per-term tracing made it precise: PAY, CNT, PLEN and BLEN are identical between good and bad runs — only BUF differs, at exactly ticks 60–159 (node id=60's full 100-tick retention), each short by exactly 3388 = that node's entire buf sum. So nd.buf.length still reads 8 (trip count correct) while every nd.buf[i] reads 0. One further necessary condition: the loop bound must be the member read — for (i=0; i < nd.buf.length; i++) fires, the identical loop with i < 8 is 40/40 clean.
Root cause
At the era commit, typed_array_alloc gave any typed array under 16 KB (a Uint32Array(8) = 56 B) a raw std::alloc::alloc block — no GcHeader, side-table tracking only.
The #6132 fix routed member typed-array element loops through lower_guarded_array_index_get, whose runtime guard plain_array_index_guard → gc_header_for_user_addr (crates/perry-runtime/src/typed_feedback.rs:1074) blindly back-reads addr - 8 as a GcHeader after only a coarse address-floor check (is_valid_obj_ptr, whose own doc concedes "the downstream GcHeader.obj_type check is the real liveness guard").
For a header-less block, that reads whatever adjacent heap bytes happen to precede it. When those bytes look like obj_type == GC_TYPE_ARRAY, the guard accepts, and the loop takes the inline plain-ArrayHeader raw-slot reader — reinterpreting the Uint32Array's small-int payload as f64 slots. Those bit patterns are denormals (~1e-312), which vanish in the sum and contribute exactly 0. Hence: length fine, elements zero, permanent for that object's life, ASLR-dependent, and immune to every collector-mode switch.
Two corrections to the original report: it is not "non-GC" (PERRY_GC_MOVING_SAFEPOINT=0 selects the non-moving collector, it doesn't disable GC — the bug still fired 14/40 under it, and under GEN_GC=0, GEN_GC_EVACUATE=0, WRITE_BARRIERS=0), and it is not a codegen miscompile — the emitted code is fine. The unsound part is the runtime guard's header sniff on a header-less allocation.
The fixing commit
ad90c75d1 — #6190, "all buffers/typed arrays become GC-heap objects; registry death pruning" (merged 2026-07-09, one day after this issue was filed). It deleted the raw-malloc tier: every typed array now goes through arena_alloc_gc_old with a real GcHeader (GC_TYPE_TYPED_ARRAY), so the guard reliably rejects and takes the correct boxed fallback.
~500 runs, zero failures: vB 140 · vB_bufonly 60 · vB_bufonly 48 under 8-way concurrency · vB_bufonly 150/150 across all six GC modes (default, MOVING_SAFEPOINT=0, GEN_GC=0, GEN_GC_EVACUATE=0, WRITE_BARRIERS=0, FORCE_EVACUATE=1). Under the same 8-way contention where main is 48/48 clean, the era binary fires 9/24 — so main is genuinely fixed, not load-masked. Also confirmed no header-less typed-array view can still reach that guard (typedarray_view → typed_array_alloc; native_arena view headers → gc_malloc with GC_TYPE_NATIVE_TYPED_VIEW).
Landmine left behind
gc_header_for_user_addrstill header-sniffs addr - 8 with only coarse validation. It is sound today purely because every array-like receiver now carries a real GcHeader. If anyone reintroduces an off-GC-heap tier for small typed arrays or buffers — a tempting perf win — this returns silently wrong numbers again, with no crash and no diagnostic. A regression guard for that is going up as a separate PR.
Closing as already fixed on main by #6190 (ad90c75d1), verified with ~500 clean runs across all six GC modes. A regression guard for the underlying gc_header_for_user_addr header-sniff invariant is going up as a follow-up PR.
Activity
fix(gc): root WeakMap map/key/entries across allocating calls (#6136)
fix(gc): root WeakMap map/key/entries across allocating calls (#6136) (…
proggeramlug commentedon Jul 11, 2026
Root-caused, and already fixed on current
mainby #6190 — recommending we close this. Full diagnosis below, since the mechanism is worth recording (and it leaves one landmine behind).Determinism fork: same binary varies run-to-run → runtime, address-dependent
Compiled once at the reporter's commit (
4ab6ffb4a) and ran that single binary repeatedly: 11/40, 16/40, 13/40 bad. Not compile-time nondeterminism — 8 independently-linked binaries from identical source showed no camps. The decisive control:disable-aslr truedisable-aslr falseWhich term: the typed-array term, and churn is required
40 runs each at the era build:
vB13/40 bad ·vB_payonly40/40 correct ·vB_bufonly12/40 bad ·vB_nochurn40/40 correct ·vB_low40/40 correct. BothvBandvB_bufonlycome up short by exactly 338800.Per-term tracing made it precise:
PAY,CNT,PLENandBLENare identical between good and bad runs — onlyBUFdiffers, at exactly ticks 60–159 (node id=60's full 100-tick retention), each short by exactly 3388 = that node's entire buf sum. Sond.buf.lengthstill reads 8 (trip count correct) while everynd.buf[i]reads 0. One further necessary condition: the loop bound must be the member read —for (i=0; i < nd.buf.length; i++)fires, the identical loop withi < 8is 40/40 clean.Root cause
At the era commit,
typed_array_allocgave any typed array under 16 KB (aUint32Array(8)= 56 B) a rawstd::alloc::allocblock — no GcHeader, side-table tracking only.The #6132 fix routed member typed-array element loops through
lower_guarded_array_index_get, whose runtime guardplain_array_index_guard→gc_header_for_user_addr(crates/perry-runtime/src/typed_feedback.rs:1074) blindly back-readsaddr - 8as a GcHeader after only a coarse address-floor check (is_valid_obj_ptr, whose own doc concedes "the downstreamGcHeader.obj_typecheck is the real liveness guard").For a header-less block, that reads whatever adjacent heap bytes happen to precede it. When those bytes look like
obj_type == GC_TYPE_ARRAY, the guard accepts, and the loop takes the inline plain-ArrayHeaderraw-slot reader — reinterpreting the Uint32Array's small-int payload as f64 slots. Those bit patterns are denormals (~1e-312), which vanish in the sum and contribute exactly 0. Hence: length fine, elements zero, permanent for that object's life, ASLR-dependent, and immune to every collector-mode switch.Two corrections to the original report: it is not "non-GC" (
PERRY_GC_MOVING_SAFEPOINT=0selects the non-moving collector, it doesn't disable GC — the bug still fired 14/40 under it, and underGEN_GC=0,GEN_GC_EVACUATE=0,WRITE_BARRIERS=0), and it is not a codegen miscompile — the emitted code is fine. The unsound part is the runtime guard's header sniff on a header-less allocation.The fixing commit
ad90c75d1— #6190, "all buffers/typed arrays become GC-heap objects; registry death pruning" (merged 2026-07-09, one day after this issue was filed). It deleted the raw-malloc tier: every typed array now goes througharena_alloc_gc_oldwith a real GcHeader (GC_TYPE_TYPED_ARRAY), so the guard reliably rejects and takes the correct boxed fallback.1fdd8cf99(parent)ad90c75d1(#6190)Verification on current main (
bc744f39b)~500 runs, zero failures:
vB140 ·vB_bufonly60 ·vB_bufonly48 under 8-way concurrency ·vB_bufonly150/150 across all six GC modes (default,MOVING_SAFEPOINT=0,GEN_GC=0,GEN_GC_EVACUATE=0,WRITE_BARRIERS=0,FORCE_EVACUATE=1). Under the same 8-way contention where main is 48/48 clean, the era binary fires 9/24 — so main is genuinely fixed, not load-masked. Also confirmed no header-less typed-array view can still reach that guard (typedarray_view→typed_array_alloc;native_arenaview headers →gc_mallocwithGC_TYPE_NATIVE_TYPED_VIEW).Landmine left behind
gc_header_for_user_addrstill header-sniffsaddr - 8with only coarse validation. It is sound today purely because every array-like receiver now carries a real GcHeader. If anyone reintroduces an off-GC-heap tier for small typed arrays or buffers — a tempting perf win — this returns silently wrong numbers again, with no crash and no diagnostic. A regression guard for that is going up as a separate PR.proggeramlug commentedon Jul 11, 2026
Closing as already fixed on main by #6190 (
ad90c75d1), verified with ~500 clean runs across all six GC modes. A regression guard for the underlyinggc_header_for_user_addrheader-sniff invariant is going up as a follow-up PR.test(runtime): guard the typed-array GcHeader invariant behind plain_…