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
array growth forwarding: build()-and-return leaves a stale head in the binding (minimal N = 17) #7661
Split out of #7660, which fixed the consumer half. This is the producer half,
and it is a latent hazard for every future consumer of a raw array head.
The observation
functionbuild(n: number): Node[]{constout: Node[]=[];for(leti=0;i<n;i++)out.push(newNode(i,i*2));returnout;}constkeep=build(1000);// `keep` holds a growth-forwarding STUB, not the live head
keep is demonstrably a stub. Adding a module-scope keep.push(x); keep.pop();
— which forces a write-back of the resolved head into the global — changes
the behaviour of a compiler in which nothing else differs; and #7660's fix,
whose only effect is js_array_refresh_local_head + write-back, would be a
literal no-op if the binding were already live (that helper returns its input
untouched when there is nothing to follow). It is not a no-op: it turns a
SIGBUS into the right answer.
Why that is surprising
expr/array_push.rs already does the write-back. Both reallocating arms of the
inline push emit it:
So out's own slot is kept live inside build. The stub is being reintroduced
somewhere between that slot and the caller's binding — candidates, unverified:
the alwaysinline + $spec_i32_b specialization pair (the return value may be
derived from the head cached in %r59 rather than a fresh load of %r2), or
the return/assignment lowering. Worth an hour with --trace llvm on the
reproducer above.
Growth forwarding is transparent through the runtime — every entry point
calls clean_arr_ptr — so a stale binding is invisible until emitted code
dereferences the head itself. Perry now has several such consumers (4a's inline
element tier, the masked-window reads, #7612's element-shape clone), and each
one has had to remember js_array_refresh_local_head independently. #7612
forgot, and the result was a bus error that shipped. Fixing the producer would
turn "remember the refresh" from a correctness requirement into an
optimization.
Suggested acceptance
A test that asserts the binding, not just the observable behaviour: compile
the reproducer and assert the value reaching the caller resolves to itself
under clean_arr_ptr (i.e. carries no GC_FLAG_FORWARDED). Behaviour alone
cannot see this — every runtime path resolves the chain and prints the right
answer either way, which is exactly why it went unnoticed.
Confirmed with a minimal reproducer, and one framing correction
Re-verified on main @ 384eba78b, both --release and --profile perry-dev,
identical results.
Minimal N is 17 — one past MIN_ARRAY_CAPACITY. build(16) returns a live
head; build(17) returns a stub.
Correction to this issue's framing: the title says "hands the caller a
stale head", which suggested the function boundary was the mechanism. It is
not. The stale head lands in the binding regardless of what the binding is —
including a module-scope const that is never passed anywhere:
constkeep=build(17);// module global, never crosses a boundary againconstlen=keep.length;letsum=0;for(letj=0;j<len;j++)sum+=keep[j].v;
That faults on main, and the identical file with a keep.push(x); keep.pop();
inserted after the build — a no-op on contents, but it routes through the
runtime push helper, which resolves the chain and writes the live head back —
exits 0 with the right answer. So the defect is in what build()returns, not
in how the value is later consumed.
Files: /tmp/s7480-real/e_module_scope_local_bound.ts (faults) and /tmp/s7480-real/g_stub_proof.ts (same file + the write-back, exits 0), plus run_all.sh to scan every arm.
On observability, which is the part that makes this worth fixing rather
than tolerating: the stale binding is invisible through the runtime, because
every entry point calls clean_arr_ptr and resolves the chain. It only became
visible because #7660's clone dereferences the head itself — and even then only
when the loop bound is Expr::Integer/Expr::LocalGet, since an inline arr.length bound makes the matcher decline and no clone is emitted at all.
That is a very narrow window onto a defect that is presumably much wider, which
is the argument for the acceptance test proposed above (assert the binding
resolves to itself, not that the program prints the right answer — behaviour
alone cannot see this).
changed the title [-]array growth forwarding: a function that grows a local array and returns it hands the caller a stale head[/-][+]array growth forwarding: build()-and-return leaves a stale head in the binding (minimal N = 17)[/+]on Aug 8, 2026
Activity
docs(changelog): record the stub evidence and cross-reference #7661
proggeramlug commentedon Aug 8, 2026
Confirmed with a minimal reproducer, and one framing correction
Re-verified on
main@384eba78b, both--releaseand--profile perry-dev,identical results.
Minimal N is 17 — one past
MIN_ARRAY_CAPACITY.build(16)returns a livehead;
build(17)returns a stub.Correction to this issue's framing: the title says "hands the caller a
stale head", which suggested the function boundary was the mechanism. It is
not. The stale head lands in the binding regardless of what the binding is —
including a module-scope
constthat is never passed anywhere:That faults on
main, and the identical file with akeep.push(x); keep.pop();inserted after the build — a no-op on contents, but it routes through the
runtime push helper, which resolves the chain and writes the live head back —
exits 0 with the right answer. So the defect is in what
build()returns, notin how the value is later consumed.
Files:
/tmp/s7480-real/e_module_scope_local_bound.ts(faults) and/tmp/s7480-real/g_stub_proof.ts(same file + the write-back, exits 0), plusrun_all.shto scan every arm.On observability, which is the part that makes this worth fixing rather
than tolerating: the stale binding is invisible through the runtime, because
every entry point calls
clean_arr_ptrand resolves the chain. It only becamevisible because #7660's clone dereferences the head itself — and even then only
when the loop bound is
Expr::Integer/Expr::LocalGet, since an inlinearr.lengthbound makes the matcher decline and no clone is emitted at all.That is a very narrow window onto a defect that is presumably much wider, which
is the argument for the acceptance test proposed above (assert the binding
resolves to itself, not that the program prints the right answer — behaviour
alone cannot see this).
[-]array growth forwarding: a function that grows a local array and returns it hands the caller a stale head[/-][+]array growth forwarding: build()-and-return leaves a stale head in the binding (minimal N = 17)[/+]docs(changelog): record the stub evidence and cross-reference #7661
fix(repsel): resolve the growth-forwarding chain before the element-s…
fix(deforest): the caller's binding kept a growth-forwarding stub aft…
fix(deforest): the caller's binding kept a growth-forwarding stub aft…