Repository navigation
GC/array: #10077's dense-queue front offset is nonzero for an unshifted array, hanging test_gap_gc_http2_pending_event_callback_rooting (two independent readers) #10137
Description
Activity
Correction — my suspected mechanism is wrong; disregard it.
I tested it rather than leaving it as a plausible-sounding theory. Guarding the new
gc_element_slot_rangecomparison behind a tracked-header check (aarray_physical_capacity_checkedreturningNonefor an untracked cell, so an unvalidated header read can no longer trip the capacity comparison and suppress the whole slot range) compiles clean and does not fix the hang — still CRASH/TIMEOUT, 2 of 2 runs. I have reverted that change and am not proposing it.So the "unguarded header read in
array_physical_capacitymakes the collector scan no element slots" story is not the cause of this bug. It may still be worth a look on its own merits — the read is unguarded where the branch directly below it validates viatry_read_tracked_gc_header— but it is not what breaks this test, and I do not want that sitting on the issue as a lead for someone to spend time on.What remains established, all with full
run_parity_tests.shbuilds:- pristine
mainbefore the trains (435d6396c) — PASS + train161(c58c1fae5) — PASS+ train162(dc0d876fe) — PASS+ all of #10075(a816fd2e7) — PASS+ all of #10077(9b518346a) — CRASH- current
main(50e08e91dd) — CRASH
#10077 introduces it; #10075 does not; the maintainer commit that landed above them does not. The failure is a hang, not a signal death: 0 lines of output, killed at the harness's 10 s timeout, Node exits 0.
One more thing I checked and can rule out: the
client_request_surface.rshunk infix(ffi): read shifted arrays through the existing accessorlooks like it moves away from the offset-aware accessor to a hardcoded+8, but the pre-#10077 code there wasarray.add(1) as *const f64— pointer arithmetic on*const ArrayHeader, i.e. the same address. That hunk is behaviour-neutral.- pristine
Root cause found
GcHeader.sizeis the PADDED allocation size, and #10077 derives the queue front offset from it as if it were exact.crates/perry-runtime/src/arena/allocators.rs:174:let pad = align.max(8); let total = (GC_HEADER_SIZE + size + pad - 1) & !(pad - 1); // aligned UP ... (*header).size = total as u32;
#10077 encodes the front offset implicitly, as the difference between the physical capacity recovered from that size and the remaining
capacity:pub(crate) unsafe fn array_physical_capacity(arr: *const ArrayHeader) -> usize { let header = (arr as *const u8).sub(GC_HEADER_SIZE) as *const GcHeader; ((*header).size as usize - GC_HEADER_SIZE - size_of::<ArrayHeader>()) / 8 } pub(crate) unsafe fn array_front_offset(arr: *const ArrayHeader) -> usize { array_physical_capacity(arr) - (*arr).capacity as usize }
Whenever the allocation was padded,
array_physical_capacityoverestimates, so an array that was never shifted reports a phantom front offset and every element read lands past true element zero.storage.rs's own header says "the GC header already records the allocation's byte size, so subtracting the remaining capacity from its physical capacity recovers the offset" — that holds only if the recorded size is exact, and it is not.How it was isolated
Each row is a full
run_parity_tests.shbuild oftest_gap_gc_http2_pending_event_callback_rootingonmain(ea3a091b10):change result none (current main)CRASH (hang, 0 output, 10 s timeout) runtime array_front_offset→ 0CRASH codegen array_elements_addr→array+8CRASH ext js_array_get→ direct+8read, both filesPASS ext, server/types.rsonlyPASS runtime → 0 AND codegen → array+8togetherPASS The single-sided neutralisations both fail because there are two independent readers of the offset — the runtime function and the IR emitted by
LlBlock::array_elements_addr— and fixing one leaves the other wrong. Neutralising both together is what passes, which is what identifies the derivation rather than any one call site.The ext rows pass because a raw
+8read bypasses the offset entirely; that is whyparse_listen_argsstops recognising the listen callback onmain(the HTTP server never signals ready, so the test hangs with no output rather than failing an assertion).Correcting two earlier comments on this issue
- My first hypothesis — the unguarded header read in
gc_element_slot_range— was wrong; I tested and retracted it above. - I also suggested the ext accessor swap itself was the defect. That is also wrong: it is a symptom. I built an offset-aware raw-bits FFI accessor (
js_array_raw_element_bits, reading atarray_elements_ptr) and the test still crashed, because the offset it applies is the phantom one. Only bypassing the offset passes.
Why a fix needs a design decision
Encoding the offset as
physical − capacityrequires the recorded size to be exact, which the arena does not guarantee. Options, none of which a merge should pick unilaterally:- store the front offset explicitly in
ArrayHeader(costs a word, removes the inference entirely); - record the exact requested size alongside the padded one;
- guarantee array allocations are never padded (align 8 and size a multiple of 8 on every path —
arena_alloc_longlived/arena_alloc_oldincluded) and assert it.
Happy to implement whichever is preferred. Worth noting the blast radius is wider than this one test: every indexed array read in both the runtime and generated code goes through this offset, so any padded array is affected — this test is just the one that fails loudly.
- My first hypothesis — the unguarded header read in
- changed the title
[-]GC: test_gap_gc_http2_pending_event_callback_rooting hangs on main since #10077 (gc_element_slot_range can return None from an unguarded header read)[/-][+]GC/array: #10077's queue front offset is derived from the PADDED GcHeader.size, so unshifted arrays report a phantom offset (hangs test_gap_gc_http2_pending_event_callback_rooting)[/+]on Sep 12, 2026 Correction: retract the "padded
GcHeader.size" explanation above. It is not established.I posted that from reading the padding in
arena_alloc_longlived/arena_alloc_old:let pad = align.max(8); let total = (GC_HEADER_SIZE + size + pad - 1) & !(pad - 1);
without checking that ordinary arrays take that path. They do not.
js_array_alloccallsarena_alloc_gc(array_byte_size(actual_capacity), 8, GC_TYPE_ARRAY), and withGC_HEADER_SIZE == 8,size_of::<ArrayHeader>() == 8and eight-byte elements the total is already a multiple of 8 — sopad = 8rounds nothing. It also sets(*ptr).capacity = actual_capacity, the same value the allocation was sized for, sophysical_capacity - capacity == 0there. Third time I have been wrong about the mechanism on this issue; posting it rather than quietly deleting it.What remains established, purely empirically — each row a full
run_parity_tests.shbuild onmainea3a091b10:change result none CRASH (hang, 0 output, 10 s timeout) runtime array_front_offset→ 0CRASH codegen array_elements_addr→array+8CRASH ext js_array_get→ raw+8read (server/types.rsalone suffices)PASS runtime → 0 AND codegen → array+8togetherPASS Two conclusions I am confident in:
- The front offset is nonzero for at least one array on this path, and that is the mechanism. Bypassing it passes; applying it crashes. I also built an offset-aware raw-bits FFI accessor (
js_array_raw_element_bits, reading atarray_elements_ptrand skippingjs_array_get's JS-facing resolution) — it still crashed, because it faithfully applies the offset. Only bypassing it works. - There are two independent readers — the runtime function and the IR from
LlBlock::array_elements_addr— so neutralising either alone leaves the other wrong. That is why the single-sided diagnostics both failed, and it is a hazard in its own right for anyone fixing this.
What is not established: why the offset is nonzero. The array reaching
parse_listen_argsis a codegen rest-ABI argument array, which may not come fromjs_array_alloc. The next step is to measure rather than reason — instrumentarray_front_offsetto report the array and the offset when it is nonzero — and I will follow up with that.- The front offset is nonzero for at least one array on this path, and that is the mechanism. Bypassing it passes; applying it crashes. I also built an offset-aware raw-bits FFI accessor (
- changed the title
[-]GC/array: #10077's queue front offset is derived from the PADDED GcHeader.size, so unshifted arrays report a phantom offset (hangs test_gap_gc_http2_pending_event_callback_rooting)[/-][+]GC/array: #10077's dense-queue front offset is nonzero for an unshifted array, hanging test_gap_gc_http2_pending_event_callback_rooting (two independent readers)[/+]on Sep 12, 2026 Where I am stopping, and what is actually verified
Reproducibility settled first, since everything below depends on it. Same build, repeated:
- current
main(ea3a091b10): CRASH 4/4 (and 3/3 in an earlier session) — deterministic, not flaky. mainwithparse_listen_argsreading elements directly instead of throughperry_ffi::js_array_get: PASS 3/3.
So there is one reproducible mitigation:
// crates/perry-ext-http/src/server/types.rs, parse_listen_args -let bits = perry_ffi::js_array_get(arr_ptr, i as u32).bits(); +let bits = *((arr_ptr as *const u8).add(8) as *const u64).add(i);
That is not a fix to propose as-is — the raw
+8is exactly what #10077 set out to remove, and it would reintroduce the shifted-array bug for a genuinely shifted queue. It is a bisect result: it localises the failure to that one read.Verified frame
tree result before the 2026-09-12 trains ( 435d6396c)PASS 2/2 + train161 ( c58c1fae5)PASS + train162 ( dc0d876fe)PASS + all of #10075 ( a816fd2e7)PASS + all of #10077 ( 9b518346a)CRASH current mainCRASH 4/4 #10077 introduces it; #10075 does not; the maintainer commit above them does not.
Two independent readers of the front offset. The runtime's
array_front_offsetand the IR fromLlBlock::array_elements_addrcompute it separately. Neutralising either alone leaves the other live — both single-sided diagnostics still crashed, and only neutralising both passed. Anyone fixing this should expect a one-sided change to appear to do nothing.Mechanisms I proposed and then disproved — please do not re-spend time on these
- Unguarded header read in
gc_element_slot_rangereturningNone(so the collector scans no element slots). Guarded it behindtry_read_tracked_gc_header; compiles, hang persists. - The ext accessor swap as the root cause rather than a symptom. Built an offset-aware raw-bits accessor (
js_array_raw_element_bits, reading atarray_elements_ptr, skippingjs_array_get's JS-facing resolution); still crashes. Only bypassing the offset entirely passes. - Allocator padding inflating
GcHeader.size.js_array_allocuses align 8 with a size already a multiple of 8 and setscapacityto the value it sized for, so nothing is rounded on that path. - Codegen reading the wrong header field.
GcHeaderis#[repr(C)]withsizeat header+4, which is exactly thearray - 4codegen loads;capacityatarray + 4is right too. - "The runtime offset is always 0." I instrumented
array_front_offsetto log every nonzero result and got nothing — but that is a null result, not a measurement: the function is simply not reached on this path, becausejs_array_getgets to elements another way.
What I would look at next
The failing read is
js_array_get→js_array_get_f64on a codegen rest-ABI argument array. That accessor does pointer cleaning, typed-array and buffer registry probes and descriptor gates; one of those, on an argument array that is not an ordinary dense heap array, is where the behaviour diverges from a plain bit read. Instrumentingjs_array_get_f64for this receiver — which branch it takes, and what it returns versus the stored bits — should settle it in one run.I have not fixed it and am not guessing further; the audit trail above is what I can stand behind.
- current
What happened
test_gap_gc_http2_pending_event_callback_rootinghangs onmain— Perry prints 0 lines and is killed after the harness's 10 s timeout, while Node exits 0. It is reported by themainsweep's gap shard as apass -> parity_failregression, and reproduces locally as aCRASH (TIMEOUT).Bisect
Every hop below is a full
run_parity_tests.shbuild (so theperry-ext-*archives are coherent —PERRY_SKIP_BUILD=1is not safe for this test, it linksperry-ext-http).435d6396cc58c1fae5dc0d876fea816fd2e79b518346a50e08e91ddmainSo #10077 (
fix(runtime): make dense Array.shift queue drains linear) introduces it. It is not #10075, and not the maintainer follow-up commit that landed above it.Suspected mechanism
#10077 added a second rejection to
gc_element_slot_range— the function that tells the collector which element slots to scan:array_physical_capacityreads the GC header without validating that one exists:For an array whose header is not a tracked GC allocation that reads garbage, the comparison trips, and
gc_element_slot_rangereturnsNone— i.e. the collector scans no element slots for that array at all. A callback reachable only from such an array is then unreachable to the collector and collected, which matches a pending-event callback rooting test hanging with no output.Note the branch immediately below it does guard the same read, via
try_read_tracked_gc_header, before using the header — so the unguarded read in the new guard looks like the asymmetry to examine first. I have not confirmed this is the mechanism; the bisect is confirmed, this part is a hypothesis with a specific place to look.Also worth fixing: #10077's commits are not individually buildable
fix(runtime): make dense array shift drains linearchangesarray_elements_ptr's semantics inperry-runtimewhile theperry-ext-*crates still call the old form; only the following commit (fix(ffi): read shifted arrays through the existing accessor) repairs them. Anything that builds at per-commit granularity —git bisect, a per-commit CI matrix — fails at that boundary withE0433/type errors rather than a real verdict. Squashing or reordering those two would make the PR bisectable.Why CI did not stop it
The PR tier's 6-shard fast-mode gap run passed this test on #10077's own PR; only the
mainsweep's 3-shard run fails it. Same content, opposite verdict, so the sharding changes the outcome — which is worth understanding on its own, independently of this bug.