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
app-patterns: object_deep_clone and promise_all_chains fail only under the auto-optimize path; blocks the public baseline #7475
Two benchmarks/app-patterns kernels fail only when the auto-optimize path is active — i.e. exactly how the benchmark harness invokes perry. They pass with PERRY_NO_AUTO_OPTIMIZE=1. This is the remaining blocker on regenerating the public benchmark artifact: run_public_baseline.sh now completes all four legs and fails at the end with "app-pattern component is incomplete or has a correctness failure".
With auto-optimize off, object_deep_clone's checksum matches node and bun exactly (all three print 1249975000), so the non-auto-opt result is correct and the auto-opt one is wrong.
Not a regression
The pre-today parent 0402a05af fails both the same way under auto-optimize:
object_deep_clone → TypeError: value is not a function
promise_all_chains → Uncaught (in promise) 0
So this predates today's merges.
Why the error text matters
TypeError: value is not a function is the signature CLAUDE.md names for the GC rooting family — "surfacing cycles later as TypeError: value is not a function". That both kernels produce it (and that the message differs between the auto-opt and non-auto-opt builds of the same source) points at the feature-stripped runtime the auto-optimize path builds, rather than at the kernels.
Worth checking first: whether the auto-opt runtime is built with a feature set that changes GC behaviour, and whether the .a it links is the one being tested. The auto-opt path builds its own target/perry-auto-*/release/libperry_runtime.a and links that overPERRY_RUNTIME_DIR, which also silently defeats A/B testing of any runtime-side patch — that cost me three invalid experiments on #7472 before I noticed.
promise_all_chains has a second, separate failure
It fails under both configurations, differently: Uncaught (in promise) 0 without auto-opt, TypeError: value is not a function with. The former looks like a distinct promise-rejection bug rather than the GC signature, so this kernel probably has two problems.
Impact
The public benchmark artifact cannot be published while any app-pattern kernel is a RUNTIME FAIL — the gate is correct to refuse. Everything else in the sweep now completes: suite, polyglot, and JSON polyglot all pass, the last of those unblocked by #7473.
Isolated to the runtime archive, and the search space is now ~22 features.
It is the archive, not the pipeline
Same compiler, same codegen, same source — only the linked libperry_runtime.a differs:
PERRY_RUNTIME_DIR=$PWD/target/perry-auto-<hash>/release PERRY_NO_AUTO_OPTIMIZE=1 \
./target/release/perry object_deep_clone.ts -o /tmp/k && /tmp/k
→ TypeError: next is not a function
PERRY_RUNTIME_DIR=$PWD/target/release PERRY_NO_AUTO_OPTIMIZE=1 \
./target/release/perry object_deep_clone.ts -o /tmp/k && /tmp/k
→ checksum: 1249975000 (matches node and bun)
So the auto-optimize pipeline is fine; the feature-stripped runtime it builds is not. That one-command discriminator is the reproducer to bisect against.
The feature set it builds with
Instrumented the cargo invocation. Auto-opt passes exactly:
against a default of 26 features, so ~22 are dropped (regex-engine, temporal, url-engine, string-normalize, intl-*, global-*, proc-ipc, diagnostics, mod-*, dyn-eval, keepalive-anchors).
Ruled out
alloc-mimalloc — present in the auto-opt archive (29 mimalloc symbols; nm). This mattered to check because freshness.rs documents that dropping it "produces binaries … that throw spurious TypeErrors" — the same symptom — but it is correctly force-on.
keepalive-anchors — added it unconditionally to the auto-opt set and rebuilt; still fails. (It is otherwise only enabled under PERRY_LLVM_BITCODE_LINK=1.)
Iterator machinery being stripped — well_known_symbol, symbol_iterator, array_values_iter and gc_register_mutable_root_scanner symbol counts are identical between the two archives. The only symbol-diff hits are node_stream iterator helpers, which are intended stripping and unrelated to array spread.
It is scale-dependent, which is what makes it a GC bug rather than a missing symbol
Same binary, only N varies:
N = 100 → ok
N = 5,000 → ok
N = 50,000 → TypeError: next is not a function
And with auto-opt off, even N = 500,000 passes. So it is not "the method was dead-stripped" — the method is there at N=5,000. Something is lost during collection at scale, and only in the stripped runtime.
Minimal source
object_deep_clone.ts reduces to array spread + .map() in a hot loop. A bare [...arr] loop does not reproduce, so it needs the fuller allocation profile.
Not a regression
The pre-today parent 0402a05af fails identically under auto-opt, so this predates today's merges and is not related to #7473 or #7432.
This issue was filed on the reading that the auto-optimize path's
feature-stripped runtime archive is missing something — "worth checking
first: whether the auto-opt runtime is built with a feature set that changes GC
behaviour". It isn't, and nobody should re-walk that path. Nothing in --no-default-features --features full,alloc-mimalloc gates GC behaviour here,
and no missing feature needs adding.
Root cause, fixed in #7495: a #7341-family rooting bug in js_iterator_to_array, the drain behind [...iterable] / Array.from. It
held five live GC values in bare Rust locals across a .next() call that
allocates the { value, done } result — the iterator object, the accumulator
array, the next closure, and the two property keys. One level down, make_iter_result held the caller's element value and its own freshly
allocated result object across four more allocations before storing them; and dispatch_array_iterator_method re-used a backing-array pointer read before
its cursor store, which can allocate.
Any of those allocations can trigger the copying minor. It moves the values and
rewrites only the slots it can see; a bare Rust local is not one. A moved
iterator leaves its pre-move copy in retired from-space, so the next dispatch
reads THAT copy's field 0 — the backing array — and dispatch_array_iterator_method calls js_array_length on a from-space
address.
So the instinct in the issue was half right: the error text was the GC-rooting
signature, and following it was correct. What was wrong was the mechanism —
runtime Rust locals, not the feature set.
The auto-optimize link was the trigger, not the cause
This matters for who was exposed, so it was measured rather than argued.
Both links have the defect.PERRY_GC_PROTECT_FROMSPACE=1 faults on the PERRY_NO_AUTO_OPTIMIZE=1 binary too — the one that prints the correct
checksum — at the same retiring minor (#0):
[gc-fromspace-protect] FAULT: signal 10 at 0x286e5610298
This address is RETIRED FROM-SPACE.
block=0x286e5610000 +664 retired_bytes=1048576 retired_by_minor=#0
last-known object: user_ptr=0x286e56102a0 obj_type=1 size=40
2 js_array_length + 380
3 perry_runtime::array::iter_object::dispatch_array_iterator_method + 1928
4 perry_runtime::object::native_call_method::handle_methods::dispatch_handle + 4264
5 js_native_call_method + 17864
6 js_iterator_to_array + 472
7 main + 1368
obj_type=1 size=40 is the three-element tags array — read through the
iterator's stale pre-move copy. That binary "passed" only because the stale read
landed on bytes that had not yet been recycled. This was a latent correctness
bug for every user of array spread / Array.from, not an auto-optimize-only
one.
Isolated by rebuilding the runtime archive one axis at a time and linking each
through the plain no-auto path, so the link line was identical in all four rows:
auto-opt features (--no-default-features --features full,alloc-mimalloc), default RUSTFLAGS
TypeError: next is not a function
auto-opt features + auto-opt RUSTFLAGS (i.e. the real thing)
TypeError: next is not a function
RUSTFLAGS are irrelevant. The feature set changes only when allocations
happen, and that is enough to move the stale read from "lands on bytes that
still look right" to "lands on recycled bytes". Runtime knobs agree it is the
copying minor and not policy evacuation: PERRY_GEN_GC=0, PERRY_GC_SCAVENGE=0
and PERRY_WRITE_BARRIERS=0 each make the failing binary pass, while PERRY_GEN_GC_EVACUATE=0 does not.
The one thing the issue got exactly right is the follow-on cost it flagged: the
auto-opt archive linking overPERRY_RUNTIME_DIR does silently defeat A/B
testing of a runtime-side patch. #7495 adds auto-opt-app-patterns, which
compiles the app-pattern kernels through the auto-optimize link and asserts the
link line actually named a perry-auto-*/libperry_runtime.a — the blind spot
that let this ship, since every other gate sets PERRY_NO_AUTO_OPTIMIZE=1.
The two residual defects now have their own issues
Neither is the rooting family, and both are unchanged by #7495 — which is
the evidence for that:
With those split out, this issue is fully addressed by #7495 and closes with it.
The other eleven app-pattern kernels pass through the auto-optimize link on that
branch.
Activity
proggeramlug commentedon Aug 6, 2026
Isolated to the runtime archive, and the search space is now ~22 features.
It is the archive, not the pipeline
Same compiler, same codegen, same source — only the linked
libperry_runtime.adiffers:So the auto-optimize pipeline is fine; the feature-stripped runtime it builds is not. That one-command discriminator is the reproducer to bisect against.
The feature set it builds with
Instrumented the cargo invocation. Auto-opt passes exactly:
against a default of 26 features, so ~22 are dropped (
regex-engine,temporal,url-engine,string-normalize,intl-*,global-*,proc-ipc,diagnostics,mod-*,dyn-eval,keepalive-anchors).Ruled out
alloc-mimalloc— present in the auto-opt archive (29 mimalloc symbols;nm). This mattered to check becausefreshness.rsdocuments that dropping it "produces binaries … that throw spurious TypeErrors" — the same symptom — but it is correctly force-on.keepalive-anchors— added it unconditionally to the auto-opt set and rebuilt; still fails. (It is otherwise only enabled underPERRY_LLVM_BITCODE_LINK=1.)well_known_symbol,symbol_iterator,array_values_iterandgc_register_mutable_root_scannersymbol counts are identical between the two archives. The only symbol-diff hits arenode_streamiterator helpers, which are intended stripping and unrelated to array spread.It is scale-dependent, which is what makes it a GC bug rather than a missing symbol
Same binary, only
Nvaries:And with auto-opt off, even N = 500,000 passes. So it is not "the method was dead-stripped" — the method is there at N=5,000. Something is lost during collection at scale, and only in the stripped runtime.
Minimal source
object_deep_clone.tsreduces to array spread +.map()in a hot loop. A bare[...arr]loop does not reproduce, so it needs the fuller allocation profile.Not a regression
The pre-today parent
0402a05affails identically under auto-opt, so this predates today's merges and is not related to #7473 or #7432.docs(engine-plan): 2026-08-06 ticket sync and repsel stack position (#…
test(ci): prove the auto-optimize gate's liveness matcher can fail (#…
docs: point the two residual #7475 defects at their own issues
proggeramlug commentedon Aug 6, 2026
Correction: the feature-set hypothesis was wrong
This issue was filed on the reading that the auto-optimize path's
feature-stripped runtime archive is missing something — "worth checking
first: whether the auto-opt runtime is built with a feature set that changes GC
behaviour". It isn't, and nobody should re-walk that path. Nothing in
--no-default-features --features full,alloc-mimallocgates GC behaviour here,and no missing feature needs adding.
Root cause, fixed in #7495: a #7341-family rooting bug in
js_iterator_to_array, the drain behind[...iterable]/Array.from. Itheld five live GC values in bare Rust locals across a
.next()call thatallocates the
{ value, done }result — the iterator object, the accumulatorarray, the
nextclosure, and the two property keys. One level down,make_iter_resultheld the caller's elementvalueand its own freshlyallocated result object across four more allocations before storing them; and
dispatch_array_iterator_methodre-used a backing-array pointer read beforeits cursor store, which can allocate.
Any of those allocations can trigger the copying minor. It moves the values and
rewrites only the slots it can see; a bare Rust local is not one. A moved
iterator leaves its pre-move copy in retired from-space, so the next dispatch
reads THAT copy's field 0 — the backing array — and
dispatch_array_iterator_methodcallsjs_array_lengthon a from-spaceaddress.
So the instinct in the issue was half right: the error text was the GC-rooting
signature, and following it was correct. What was wrong was the mechanism —
runtime Rust locals, not the feature set.
The auto-optimize link was the trigger, not the cause
This matters for who was exposed, so it was measured rather than argued.
Both links have the defect.
PERRY_GC_PROTECT_FROMSPACE=1faults on thePERRY_NO_AUTO_OPTIMIZE=1binary too — the one that prints the correctchecksum — at the same retiring minor (
#0):obj_type=1 size=40is the three-elementtagsarray — read through theiterator's stale pre-move copy. That binary "passed" only because the stale read
landed on bytes that had not yet been recycled. This was a latent correctness
bug for every user of array spread /
Array.from, not an auto-optimize-onlyone.
Isolated by rebuilding the runtime archive one axis at a time and linking each
through the plain no-auto path, so the link line was identical in all four rows:
object_deep_clone-C panic=abort -C force-unwind-tables=yes)--no-default-features --features full,alloc-mimalloc), default RUSTFLAGSTypeError: next is not a functionTypeError: next is not a functionRUSTFLAGS are irrelevant. The feature set changes only when allocations
happen, and that is enough to move the stale read from "lands on bytes that
still look right" to "lands on recycled bytes". Runtime knobs agree it is the
copying minor and not policy evacuation:
PERRY_GEN_GC=0,PERRY_GC_SCAVENGE=0and
PERRY_WRITE_BARRIERS=0each make the failing binary pass, whilePERRY_GEN_GC_EVACUATE=0does not.The one thing the issue got exactly right is the follow-on cost it flagged: the
auto-opt archive linking over
PERRY_RUNTIME_DIRdoes silently defeat A/Btesting of a runtime-side patch. #7495 adds
auto-opt-app-patterns, whichcompiles the app-pattern kernels through the auto-optimize link and asserts the
link line actually named a
perry-auto-*/libperry_runtime.a— the blind spotthat let this ship, since every other gate sets
PERRY_NO_AUTO_OPTIMIZE=1.The two residual defects now have their own issues
Neither is the rooting family, and both are unchanged by #7495 — which is
the evidence for that:
promise_all_chainsrejects with a resolution value at scale.Uncaught (in promise) 0underPERRY_NO_AUTO_OPTIMIZE=1,Uncaught (in promise) TypeError: value is not a functionunderauto-optimize, both before and after fix(gc): root the iterator drain's live values across .next() (#7475) #7495. Correct at
N_BATCHES=3, BATCH_SIZE=5. This is the remaining blocker on the publicbenchmark artifact.
array_from_spread_value'sSymbol.iteratorlookup derefs afrom-space object. The other
[...obj.arr]lowering, taken when theenclosing function inlines. Deterministic and at minor
#0, which perCLAUDE.md points at a table rather than a register. Both links.
With those split out, this issue is fully addressed by #7495 and closes with it.
The other eleven app-pattern kernels pass through the auto-optimize link on that
branch.
fix(gc): root the iterator receiver before the first allocation (#7475)
fix(gc): root the iterator drain's live values across `.next()` (#7475)
test(gc): witness + auto-optimize gate for the iterator-drain rooting…
test(ci): prove the auto-optimize gate's liveness matcher can fail (#…
docs: point the two residual #7475 defects at their own issues
fix(gc): root the iterator receiver before the first allocation (#7475)
fix(gc): root the iterator drain's live values across .next() (#7475) (…