Skip to content

app-patterns: object_deep_clone and promise_all_chains fail only under the auto-optimize path; blocks the public baseline #7475

Closed

Description

@proggeramlug

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".

Repro

PERRY_RUNTIME_DIR=$PWD/target/release ./target/release/perry \
  benchmarks/app-patterns/kernels/object_deep_clone.ts -o /tmp/k && /tmp/k
kernel PERRY_NO_AUTO_OPTIMIZE=1 auto-optimize (default)
object_deep_clone ✅ checksum: 1249975000 ✗ TypeError: next is not a function
promise_all_chains ✗ Uncaught (in promise) 0 ✗ TypeError: value is not a function

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 over PERRY_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.

Activity

  1. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    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:

    perry-runtime/full, perry-stdlib/async-runtime, perry-runtime/alloc-mimalloc
    

    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.

  2. proggeramlug commented on Aug 6, 2026

    @proggeramlug
    ContributorAuthor

    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-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:

    runtime archive object_deep_clone
    default features, default RUSTFLAGS checksum 1249975000
    default features, auto-opt RUSTFLAGS (-C panic=abort -C force-unwind-tables=yes) checksum 1249975000
    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 over PERRY_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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions