Skip to content

fix(gc): Promise.all at scale read globalThis and its own combinator state from retired from-space (#7497) - #7516

Merged
proggeramlug merged 15 commits into
mainfrom
fix/7497-promise-all-chains
Aug 6, 2026
Merged

fix(gc): Promise.all at scale read globalThis and its own combinator state from retired from-space (#7497)#7516
proggeramlug merged 15 commits into
mainfrom
fix/7497-promise-all-chains

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Fixes #7497.

Fixed

Promise.all at scale rejected with a resolution value, or with
TypeError: value is not a function — eight stale from-space reads, none of them
in the promise machinery's logic (#7497).

The app-pattern kernel promise_all_chains printed Uncaught (in promise) 0
under PERRY_NO_AUTO_OPTIMIZE=1 and a rooting-shaped TypeError under the
default link, and was the last blocker on the public benchmark artifact. No
settle/reject/microtask decision changed. Every defect is the #7341 family —
a value read out of a root and held in a register across a call that allocates
is not rooted
— and the fix is the same shape each time: RuntimeHandleScope
plus a re-read at the point of use, so the pre-collection address is never
nameable.

They were found one at a time. Each PERRY_GC_PROTECT_FROMSPACE=1 fault named a
site; fixing it moved the fault and exposed the next.

  1. js_get_global_this_builtin_value — the canonical globalThis.<Builtin>
    read behind instance.constructor, bare Date/Array/Object identifier
    resolution, and is_default_promise_constructor:

    let global_obj = js_nanbox_get_pointer(js_get_global_this());
    let key = js_string_from_bytes(name);            // ALLOCATES -- may collect
    js_object_get_field_by_name(global_obj, key);    // from-space deref

    The root is fine — THREAD_GLOBAL_THIS is registered and evacuation rewrites
    it. The ORDER is not: this lookup interns nothing, so every call mints a fresh
    string, and any of those allocations can be the copying minor that moves
    globalThis.

  2. js_promise_resolve_specPromise.all calls it once per element, and
    it asks is_default_promise_constructor (i.e. 1) before touching its own
    argument. For Promise.all([...promises]) that argument IS a promise, so
    js_promise_resolved dereferenced a from-space GC_TYPE_PROMISE.
    js_promise_reject_spec, js_promise_try_spec,
    js_promise_with_resolvers_spec and promise_prototype_then_thunk share the
    shape.

  3. js_array_clone — the widest. It read the source array pointer, called
    js_array_alloc(len) for the destination (which can collect and MOVE the
    source), then copy_nonoverlapping'd from the pre-collection address. That is
    every [...arr], every Array.from(arr) and every combinator's iterable
    snapshot.

  4. The spec combinators' own locals. perform ran its per-element loop —
    Call(promiseResolve, C, «next») and Invoke(nextPromise, "then", …), both
    of which run USER JS — while holding the elements snapshot, the shared
    values and remaining-count arrays, the capability's resolve/reject and
    the constructor in bare Rust locals.

  5. Two publishers, worse than a stale read. build_element_closure and
    make_resolving_functions take their GC arguments in registers, allocate, and
    then store the pre-collection addresses into capture slots — putting
    from-space into an object the collector goes on maintaining.
    new_promise_capability, the two Promise.allSettled element functions,
    build_settled_{fulfilled,rejected} and combinator_iterable_to_array's
    array fast path had shape (4) or (5).

  6. The microtask runner's dispatch arms. Every arm read its callback pointer
    (and the value it passes) out of the popped Task into a bare local, ran
    async_hooks::before / v8::promise_hook_before — both allocate — and only
    then loaded func_ptr out of that pointer. SIGSEGV handling POST requests in compiled Fastify + @perryts/mysql service (0.5.1026 / 46b80d78); GET unaffected #1663 had already rooted promise
    and next here; the callback was missed.

  7. …and rooting them inside the arm was still too late. A Task stops being
    a scanned root the instant it is popped, and enter_microtask_context runs
    before any of the arm's own bookkeeping. The first attempt seeded the handle
    with an address the collection had already invalidated. Every arm now roots as
    its first statement and re-reads after the context switch. Disassembling the
    faulting instruction — bl get_nanbox_u64; ldr x8,[x21] — is what showed the
    re-read was present and still wrong, which is what made the ordering the
    suspect rather than the rooting.

  8. The producer side. js_async_step_chain carried the step closure through
    adapt_foreign_promise_value, js_promise_new, build_async_step_thunks,
    js_promise_resolved and capture_context and then STORED it into a
    Task::AsyncStep. The queue is a scanned root and the runner now re-reads
    what it pops — neither helps when the pointer was already dead at the push.
    js_async_step_done had the mirror image: it settled trap_next (which
    allocates) and returned the pre-call copy as the async function's own result
    promise.

All handles are NaN-boxed rather than root_raw_*_ptr, so
scripts/raw_handle_debt.py is unchanged at 999. The per-element handles in
perform live in a scope INSIDE the loop, so a 50 000-element combinator does not
push 50 000 entries onto the handle stack.

Three more callers of js_get_global_this() had (1)'s shape and are fixed
the same way. These come from auditing the callers, not from a reproducer, and
are called out as such: class_meta.rs's builtin-constructor name walk (worse
than the proven site — a fresh key allocation inside a ~50-iteration loop against
one address read before the loop), js_globalthis_seed_async_local_storage
(globalThis is the RECEIVER of a store that follows two allocations), and the
Temporal.<Type>.prototype walk. The four sites of this shape in error.rs /
with_env.rs were already fixed by #6943; these are the ones that sweep missed.

Why it read as "a separate promise-rejection defect". A stale read returns
whatever from-space happens to hold, so globalThis.Promise came back as a
non-callable — or, when the garbage was zero, as the resolution value 0
arriving on the rejection path. The two link modes printed different messages for
the same defect. It was untouched by #7495 only because #7495 fixed a different
function.

Localisation, for the next person (each knob against the unfixed binary):
PERRY_GEN_GC=0 and PERRY_WRITE_BARRIERS=0 both make it pass while
PERRY_GC_MOVING_SAFEPOINT=0 does not — the first two make the copying minor
ineligible, the third only disables the safepoint collection, and the one that
matters is the alloc-point direct minor (trigger=ArenaBytes declared_safepoint=false). PERRY_GC_FROMSPACE_SCAN=1 reported clean every
time: no HEAP slot was ever stale, which is the signature of a holder in a native
frame rather than in a table.

What is still open, stated rather than papered over. A protected run of the
auto-optimize binary is not silent: it prints the correct checksum and then
faults inside js_async_step_done on another 72-byte GC_TYPE_PROMISE. That is
a ninth site of the same family, after the program's observable output, and the
kernel matches the oracle byte for byte with and without the instrument. The
PERRY_NO_AUTO_OPTIMIZE=1 binary and the new gap test are both silent under the
instrument. Separately, test_gap_gc_iterator_drain_rooting and
test_gap_iterator_helpers_2874 fail on origin/main too — verified by building
origin/main's runtime from a clean git archive export and running both
against it — and belong to #7498's array_from_spread_value prototype walk.

Added

test-files/test_gap_gc_global_builtin_lookup_rooting.ts, registered in
test-parity/gc_repsel_corpus.txt so gc-moving-witnesses runs it. One wide
Promise.all (50 000 elements) rather than the kernel's 1000 × 50: the single
wide call packs enough lookups between two collections to fail on the shipped
default in a fraction of a second, where the kernel needs ~20× the work for the
same window. Deterministic — 6/6 runs failing before, 5/5 passing after — and
byte-diffed against node 26.5.1.

Verified with the instrument asserted live rather than merely quiet: a protected
run prints [gc-fromspace-protect] retired_set=#0 and
[gc-copy-minor] ran copied_objects=175416, so objects really did move, and no
fault follows.

Changed

scripts/auto_opt_app_patterns.sh no longer skips promise_all_chains; the skip
list is empty and the gate is 12/12. Its rot check means the line had to come out
with the fix. Every array expansion is now guarded on ${#…[@]}: macOS ships
bash 3.2, where set -u turns "${EMPTY[@]}" into an "unbound variable" abort,
so an empty skip list would have stopped the gate before its first kernel —
CLAUDE.md hazard 4 wearing a different hat. --self-test still passes.

Evidence

Release, node 26.5.1 oracle. The "before" column is origin/main's runtime built
from a clean git archive export into its own target dir — not this branch with
the patch reverted.

origin/main this PR
kernel, PERRY_NO_AUTO_OPTIMIZE=1 Uncaught (in promise) 0 checksum: 2500050000
kernel, auto-optimize link SIGBUS / TypeError checksum: 2500050000
scripts/auto_opt_app_patterns.sh 11 PASS + 1 SKIP 12/12, no skips
new gap test TypeError: value is not a function (6/6) byte-exact (5/5)
single Promise.all over 50 000 promises TypeError byte-exact
300x200, 200x300, 60x1000, 30x2000, 10x5000, 3000x50 fail all byte-exact
cargo test -p perry-runtime 1744 passed, 0 failed
gap test + no-auto-opt kernel under PERRY_GC_PROTECT_FROMSPACE=1 DEPTH=300 SIGBUS silent, with retired_set=#0 and copied_objects=175416 proving the instrument was live

The first fault, before any of this landed:

[gc-fromspace-protect] FAULT: signal 10 at 0x20fa3df2fa8
  last-known object: obj_type=2 size=72          (GC_TYPE_OBJECT -- globalThis)
2   js_object_get_field_by_name + 132
3   js_get_global_this_builtin_value + 396
4   js_promise_resolve_spec + 204
5   js_native_call_value + 3408
6   perry_runtime::promise::spec_combinators::call_with_this + 188
7   perry_runtime::promise::spec_combinators::run_combinator + 1048
8   js_promise_all_iterable + 44

Summary by CodeRabbit

  • Bug Fixes

    • Improved reliability of Promise.all, Promise.allSettled, Promise.resolve, Promise.reject, Promise.withResolvers, and Promise.try during garbage collection.
    • Fixed potential failures in promise chaining, async operations, microtasks, array processing, and built-in object lookups.
    • Improved Temporal and AsyncLocalStorage initialization in memory-intensive scenarios.
  • Tests

    • Added regression coverage for large promise workloads, garbage-collection behavior, result ordering, and promise identity checks.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ac4eff1a-f42a-4f1a-93e2-ffe0f1f21714

📥 Commits

Reviewing files that changed from the base of the PR and between a5b995c and 9768cf0.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (17)
  • .github/workflows/auto-opt-app-patterns.yml
  • CLAUDE.md
  • Cargo.toml
  • changelog.d/7516-promise-all-chains.md
  • crates/perry-runtime/src/array/flat_clone.rs
  • crates/perry-runtime/src/object/class_registry/class_meta.rs
  • crates/perry-runtime/src/object/global_this/math_temporal.rs
  • crates/perry-runtime/src/object/native_module_registry.rs
  • crates/perry-runtime/src/object/object_ops/prototype.rs
  • crates/perry-runtime/src/promise/async_step.rs
  • crates/perry-runtime/src/promise/combinators.rs
  • crates/perry-runtime/src/promise/microtasks.rs
  • crates/perry-runtime/src/promise/spec_combinators.rs
  • crates/perry-runtime/src/promise/then.rs
  • scripts/auto_opt_app_patterns.sh
  • test-files/test_gap_gc_global_builtin_lookup_rooting.ts
  • test-parity/gc_repsel_corpus.txt

📝 Walkthrough

Walkthrough

The runtime now roots and refreshes JavaScript values across moving-GC-sensitive promise, async, microtask, global-object, and array operations. A regression test covers large Promise.all workloads. The auto-optimization skip list is empty.

Changes

Promise GC rooting

Layer / File(s) Summary
Global and array lookup rooting
crates/perry-runtime/src/array/flat_clone.rs, crates/perry-runtime/src/object/...
Array cloning and global built-in lookups root values across allocations and reread relocated pointers. Temporal lookup also checks null receivers.
Promise capability and combinator rooting
crates/perry-runtime/src/promise/combinators.rs, crates/perry-runtime/src/promise/spec_combinators.rs, crates/perry-runtime/src/promise/then.rs
Promise capabilities, combinator state, closures, public methods, iterable inputs, and chaining values remain rooted across allocations and user-code execution.
Microtask and async-step rooting
crates/perry-runtime/src/promise/microtasks.rs, crates/perry-runtime/src/promise/async_step.rs
Queued promises, callbacks, values, and async-step closures are rooted before dispatch and reread after allocation-capable hooks.
Moving-GC regression coverage
test-files/test_gap_gc_global_builtin_lookup_rooting.ts, test-parity/gc_repsel_corpus.txt, changelog.d/7516-promise-all-chains.md
A 50,000-element Promise.all test validates ordering, identity, native promise detection, chained workloads, and moving-GC coverage.

Optimization gate update

Layer / File(s) Summary
Skip-list removal and release documentation
scripts/auto_opt_app_patterns.sh, .github/workflows/auto-opt-app-patterns.yml, changelog.d/7516-promise-all-chains.md, Cargo.toml, CLAUDE.md
The script supports empty Bash arrays, validates skips conditionally, reports the no-skip state, and removes promise_all_chains from the skip list. Version metadata changes to 0.5.1297.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • PerryTS/perry#6994 — Related GC-rooting fixes cover global-object and promise runtime paths.
  • PerryTS/perry#7375 — Related fixes protect promise pointers across async operations.
  • PerryTS/perry#7495 — Related moving-GC rooting work affects iterator draining and the optimization workflow.

Suggested labels: type:bug

Suggested reviewers: thehypnoo, andrewtdiz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #7497 by fixing stale GC references and validating Promise.all at scale in both runtime configurations.
Out of Scope Changes check ✅ Passed The rooting fixes, regression test, skip-list removal, and Bash compatibility changes directly support the linked issue objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly identifies the main Promise.all moving-GC stale from-space defect and its scale-dependent globalThis and combinator impact.
Description check ✅ Passed The description gives a detailed summary, concrete changes, issue reference, test evidence, and remaining limitations, despite not using every template heading.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/7497-promise-all-chains

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/perry-runtime/src/object/class_registry/class_meta.rs (1)

320-335: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Root func_value too; the comparison still uses a pre-collection closure address.

The loop allocates a key string on every iteration. globalThis is now re-read across those allocations, but jv was computed at line 139 and is never refreshed. If a copying minor evacuates the ClosureHeader during any key allocation, the globalThis field slot is rewritten to the new address while jv.bits() still holds the from-space address. The equality test at line 332 then fails and the function returns None, which drops callers into the generic construct tail.

Root the searched value in the same scope and compare against the re-read bits.

🛠️ Proposed fix
     let scope = crate::gc::RuntimeHandleScope::new();
     let global_handle = scope.root_nanbox_f64(js_get_global_this());
+    let func_handle = scope.root_nanbox_f64(func_value);
     for name in GLOBAL_THIS_BUILTIN_CONSTRUCTORS.iter().copied() {
         let (key, global_this_f64) = global_handle.across_nanbox(|| {
             crate::string::js_string_from_bytes(name.as_ptr(), name.len() as u32)
         });
         let global_obj =
             crate::value::js_nanbox_get_pointer(global_this_f64) as *const ObjectHeader;
         if global_obj.is_null() {
             return None;
         }
         let v = js_object_get_field_by_name(global_obj, key);
-        if v.bits() == jv.bits() {
+        if v.bits() == func_handle.get_nanbox_f64().to_bits() {
             return Some(name);
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/object/class_registry/class_meta.rs` around lines
320 - 335, Refresh the searched globalThis value after each key-string
allocation in the constructor lookup loop, using the rooted handle in the same
RuntimeHandleScope rather than the stale pre-collection jv. Compare
js_object_get_field_by_name against the re-read rooted value’s bits so a copying
minor preserves the func_value match.
crates/perry-runtime/src/promise/then.rs (1)

1397-1407: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The slow path hands its values to perform_promise_then_with_cap, which still publishes pre-collection addresses.

At lines 1212-1234 that helper allocates ful_wrap, fills its captures, and then allocates rej_wrap. Across that second js_closure_alloc it holds promise and ful_wrap in bare Rust locals, and it writes on_rejected, cap_resolve, and cap_reject into rej_wrap afterwards. A copying minor at that allocation stores from-space addresses into the reject wrapper and passes a stale receiver to js_promise_then. This is the same publishing shape fixed in make_resolving_functions and build_element_closure.

🛠️ Proposed fix for `perform_promise_then_with_cap` (lines 1212-1234, outside this range)
    let scope = crate::gc::RuntimeHandleScope::new();
    let promise_h = scope.root_raw_mut_ptr(promise);
    let on_fulfilled_h = scope.root_nanbox_f64(on_fulfilled);
    let on_rejected_h = scope.root_nanbox_f64(on_rejected);
    let cap_resolve_h = scope.root_nanbox_f64(cap_resolve);
    let cap_reject_h = scope.root_nanbox_f64(cap_reject);
    let cap_promise_h = scope.root_nanbox_f64(cap_promise);

    let ful_wrap_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(
        js_closure_alloc(then_cap_fulfill_fn as *const u8, 3) as i64,
    ));
    let rej_wrap_h = scope.root_nanbox_f64(crate::value::js_nanbox_pointer(
        js_closure_alloc(then_cap_reject_fn as *const u8, 3) as i64,
    ));
    // Both closures exist; every capture below is written at a post-collection address.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/promise/then.rs` around lines 1397 - 1407, Update
perform_promise_then_with_cap to root promise, callback values, capability
values, and both closure wrappers through RuntimeHandleScope before allocating
the second closure. Allocate and root ful_wrap and rej_wrap before populating
captures, then write all captures using their post-collection addresses and pass
the rooted promise to js_promise_then.
🧹 Nitpick comments (1)
crates/perry-runtime/src/promise/spec_combinators.rs (1)

614-699: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: fold the three element-closure branches into one helper.

All, AllSettled, and Any repeat the same sequence: root a guard, build one or two element closures from values_ptr() / state_ptr() / the two capability handles, then bump the remaining count. A small helper that takes the element func pointer and returns the rooted closure value would remove the duplication and keep the re-read order in one place. Behavior stays identical, so defer this if you prefer the explicit form.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/promise/spec_combinators.rs` around lines 614 - 699,
Optionally refactor the repeated closure setup in the combinator loop into a
shared helper used by CombinatorKind::All, CombinatorKind::AllSettled, and
CombinatorKind::Any. Have it root the guard, build the requested element closure
from values_ptr(), state_ptr(), and the capability handles, and preserve the
existing state-count increment and callback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/array/flat_clone.rs`:
- Around line 410-425: Update js_array_values to root arr with
RuntimeHandleScope before calling js_array_alloc, then re-read both arr and the
allocated result from their rooted handles before copying. Preserve the existing
length and value-copy behavior while eliminating use of the pre-allocation arr
address.

In `@crates/perry-runtime/src/promise/combinators.rs`:
- Around line 359-375: Root the incoming value at the start of the function and
use the rooted handle as the source for raw. In the GC_TYPE_OBJECT path,
recompute raw after the user [Symbol.iterator] lookup and again after allocating
the "next" key, before calling js_object_get_field_by_name or js_array_clone.
Apply the same allocation-safe rooted-value pattern already used in the array
branch.

---

Outside diff comments:
In `@crates/perry-runtime/src/object/class_registry/class_meta.rs`:
- Around line 320-335: Refresh the searched globalThis value after each
key-string allocation in the constructor lookup loop, using the rooted handle in
the same RuntimeHandleScope rather than the stale pre-collection jv. Compare
js_object_get_field_by_name against the re-read rooted value’s bits so a copying
minor preserves the func_value match.

In `@crates/perry-runtime/src/promise/then.rs`:
- Around line 1397-1407: Update perform_promise_then_with_cap to root promise,
callback values, capability values, and both closure wrappers through
RuntimeHandleScope before allocating the second closure. Allocate and root
ful_wrap and rej_wrap before populating captures, then write all captures using
their post-collection addresses and pass the rooted promise to js_promise_then.

---

Nitpick comments:
In `@crates/perry-runtime/src/promise/spec_combinators.rs`:
- Around line 614-699: Optionally refactor the repeated closure setup in the
combinator loop into a shared helper used by CombinatorKind::All,
CombinatorKind::AllSettled, and CombinatorKind::Any. Have it root the guard,
build the requested element closure from values_ptr(), state_ptr(), and the
capability handles, and preserve the existing state-count increment and callback
behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0577634c-621f-4f6c-b13a-45cea8d69b6f

📥 Commits

Reviewing files that changed from the base of the PR and between e4ab722 and c64ee44.

📒 Files selected for processing (13)
  • .github/workflows/auto-opt-app-patterns.yml
  • changelog.d/7516-promise-all-chains.md
  • crates/perry-runtime/src/array/flat_clone.rs
  • crates/perry-runtime/src/object/class_registry/class_meta.rs
  • crates/perry-runtime/src/object/global_this/math_temporal.rs
  • crates/perry-runtime/src/object/native_module_registry.rs
  • crates/perry-runtime/src/object/object_ops/prototype.rs
  • crates/perry-runtime/src/promise/combinators.rs
  • crates/perry-runtime/src/promise/spec_combinators.rs
  • crates/perry-runtime/src/promise/then.rs
  • scripts/auto_opt_app_patterns.sh
  • test-files/test_gap_gc_global_builtin_lookup_rooting.ts
  • test-parity/gc_repsel_corpus.txt

Comment thread crates/perry-runtime/src/array/flat_clone.rs
Comment thread crates/perry-runtime/src/promise/combinators.rs
proggeramlug pushed a commit that referenced this pull request Aug 6, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/perry-runtime/src/promise/microtasks.rs (1)

435-444: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

v8::promise_hook_before receives a pointer captured before async_hooks::before allocates. Both microtask arms reload the callback and the value across the two hook calls but leave the promise argument of crate::v8::promise_hook_before reading the local captured before crate::async_hooks::before. The rooting handle is already live at both sites, so each fix is a one-line reload.

  • crates/perry-runtime/src/promise/microtasks.rs#L435-L444: pass promise_handle.get_raw_mut_ptr::<Promise>() to crate::v8::promise_hook_before at line 438 instead of the promise local.
  • crates/perry-runtime/src/promise/microtasks.rs#L840-L847: reload next from next_handle before line 841 and pass the reloaded pointer to crate::v8::promise_hook_before.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/promise/microtasks.rs` around lines 435 - 444, The
promise pointer passed to promise_hook_before becomes stale after
async_hooks::before allocates. In crates/perry-runtime/src/promise/microtasks.rs
lines 435-444, pass a fresh pointer from promise_handle directly; in lines
840-847, reload next from next_handle immediately before promise_hook_before and
pass that reloaded pointer.
🧹 Nitpick comments (2)
crates/perry-runtime/src/promise/async_step.rs (2)

338-351: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share the nanbox encode/decode helpers instead of re-implementing them.

Lines 339-351 duplicate boxed_closure and rooted_closure from crates/perry-runtime/src/promise/microtasks.rs lines 144-171. Lines 461-475 duplicate boxed_promise and rooted_promise from the same file, lines 136-161.

Four copies of the same null/TAG_UNDEFINED encoding contract now exist across two files. If one copy changes its null sentinel, the mismatch is silent and produces a wrong pointer.

Promote the four helpers to crate::promise (for example in mod.rs, next to the ClosurePtr alias) and call them from both files.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/promise/async_step.rs` around lines 338 - 351,
Promote the shared nanbox closure and promise encode/decode helpers to
crate::promise, near the ClosurePtr alias, and replace the local implementations
in async_step.rs and microtasks.rs with calls to those helpers. Preserve the
existing null-to-TAG_UNDEFINED contract and pointer decoding behavior while
removing all four duplicated helper pairs.

557-562: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Read the target from target_h at the call site.

Line 561 passes the raw trap.trap_next copy as the target, while the same line reads value from value_h and line 562 reads the return value from target_h. No allocation happens between line 559 and line 561, so the current behavior is correct.

The mixed style is fragile. Any call inserted between the root and the use reintroduces the stale-pointer defect without a visible signal.

♻️ Proposed change
-        resolve_trap_next_with_adoption(trap.trap_next, value_h.get_nanbox_f64());
+        resolve_trap_next_with_adoption(
+            crate::value::js_nanbox_get_pointer(target_h.get_nanbox_f64()) as *mut Promise,
+            value_h.get_nanbox_f64(),
+        );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/promise/async_step.rs` around lines 557 - 562,
Update the call to resolve_trap_next_with_adoption in the surrounding async-step
logic to pass the rooted target value read from target_h rather than the raw
trap.trap_next pointer. Keep the existing value_h argument and target_h-based
return conversion unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-runtime/src/promise/async_step.rs`:
- Around line 439-455: Apply the existing step_scope rooting and re-read pattern
to all early-return paths around build_async_step_thunks and
js_promise_resolved: root inner, fulfill, reject, and trap_next before
allocation, then re-read each rooted value before passing it to
then_backpatch_result. Cover both resolved-value branches and the pointer-tagged
thenable path, preserving their current control flow and return behavior.

In `@crates/perry-runtime/src/promise/microtasks.rs`:
- Around line 668-674: Update the step_closure handle initialization in the
affected microtask arm to use boxed_closure instead of directly calling
js_nanbox_pointer, matching the encoding expected by rooted_closure and the
sibling arms. Preserve the existing null step_closure behavior while ensuring
null is encoded as TAG_UNDEFINED rather than a nanboxed null pointer.

---

Outside diff comments:
In `@crates/perry-runtime/src/promise/microtasks.rs`:
- Around line 435-444: The promise pointer passed to promise_hook_before becomes
stale after async_hooks::before allocates. In
crates/perry-runtime/src/promise/microtasks.rs lines 435-444, pass a fresh
pointer from promise_handle directly; in lines 840-847, reload next from
next_handle immediately before promise_hook_before and pass that reloaded
pointer.

---

Nitpick comments:
In `@crates/perry-runtime/src/promise/async_step.rs`:
- Around line 338-351: Promote the shared nanbox closure and promise
encode/decode helpers to crate::promise, near the ClosurePtr alias, and replace
the local implementations in async_step.rs and microtasks.rs with calls to those
helpers. Preserve the existing null-to-TAG_UNDEFINED contract and pointer
decoding behavior while removing all four duplicated helper pairs.
- Around line 557-562: Update the call to resolve_trap_next_with_adoption in the
surrounding async-step logic to pass the rooted target value read from target_h
rather than the raw trap.trap_next pointer. Keep the existing value_h argument
and target_h-based return conversion unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 62a8bec9-57d7-4ce9-8a42-95b8b825b6c6

📥 Commits

Reviewing files that changed from the base of the PR and between c64ee44 and 28dd7e8.

📒 Files selected for processing (3)
  • changelog.d/7516-promise-all-chains.md
  • crates/perry-runtime/src/promise/async_step.rs
  • crates/perry-runtime/src/promise/microtasks.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • changelog.d/7516-promise-all-chains.md

Comment thread crates/perry-runtime/src/promise/async_step.rs Outdated
Comment thread crates/perry-runtime/src/promise/microtasks.rs
@proggeramlug

Copy link
Copy Markdown
Contributor Author

@coderabbitai all six findings were valid and are fixed in a9e659f. They are
the same family this PR is about, and three of them are sites I had walked past.

  1. class_meta.rs — root func_value too. Correct, and the failure mode you
    describe is the nastier half: the equality test misses rather than
    crashing, so identify_global_builtin_constructor silently returns None and
    the caller takes the generic construct tail. Rooted in the same scope and
    compared against func_handle.get_nanbox_f64().to_bits().

  2. then.rs::perform_promise_then_with_cap — the publishing shape. Correct.
    Both wrappers are now allocated and rooted before any capture is written,
    and js_promise_then gets the re-read receiver.

  3. flat_clone.rs::js_array_values. Correct — the identical
    memcpy-from-a-moved-source that this PR removed from js_array_clone, on a
    path reached from arr.values(). Same fix.

  4. combinator_iterable_to_array's GC_TYPE_OBJECT arm. Correct: raw was
    computed before a user [Symbol.iterator] getter and a "next" key
    allocation. The receiver is now rooted and re-derived at each use.

  5. async_step.rs's three early-return suspend paths. Correct, and this is
    the one I'd have missed. All three now go through one rooted
    suspend_on_awaited helper, and I also rooted inside
    then_backpatch_result itself — it allocates the result promise and then
    stores it into both thunks, so it was the actual publishing site your comment
    points at.

  6. microtasks.rs encoding mismatch. Correct, and thank you: boxed_closure
    / rooted_closure must agree on the null-as-undefined encoding, and the
    AsyncStep arm was the odd one out.

Re-verified after the fixes: benchmarks/app-patterns/kernels/promise_all_chains.ts
byte-exact vs node 26.5.1 on both link modes, scripts/auto_opt_app_patterns.sh
12/12 with no skips, 12 related promise/array/constructor/Temporal gap tests
green, cargo test -p perry-runtime 1744 passed / 0 failed,
scripts/raw_handle_debt.py still 999, cargo fmt --check clean.

One thing deliberately left open and called out in the PR body: a protected run
(PERRY_GC_PROTECT_FROMSPACE=1) of the auto-optimize binary still faults
inside js_async_step_done after printing the correct checksum — a further
site of this same family that changes no observable output. The
PERRY_NO_AUTO_OPTIMIZE=1 binary and the new gap test are both silent under the
instrument.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@proggeramlug Thanks for the detailed confirmation.

The six findings are addressed in a9e659fd6. The validation results cover the affected promise, array, constructor, and Temporal paths.

The remaining PERRY_GC_PROTECT_FROMSPACE=1 fault in the auto-optimize js_async_step_done path remains open. It is not resolved by the current changes. Would you like me to open a follow-up GitHub issue for that residual GC-safety defect?

🐇 ✅


✏️ Learnings added
Learnt from: proggeramlug
URL: https://github.com/PerryTS/perry/pull/7516

Timestamp: 2026-08-06T12:07:17.193Z
Learning: In `crates/perry-runtime/src/promise/async_step.rs`, the auto-optimize binary can still fault in `js_async_step_done` under `PERRY_GC_PROTECT_FROMSPACE=1` after it prints the correct checksum. The `PERRY_NO_AUTO_OPTIMIZE=1` binary and `test_gap_gc_global_builtin_lookup_rooting.ts` are silent under the same instrument. Treat this as a remaining stale from-space-read investigation, separate from the fixes in commit `a9e659fd6`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

Ralph Küpper added 15 commits August 6, 2026 14:41
)

js_get_global_this_builtin_value read the globalThis object out of its
(correct, rewritten) root into a raw *const ObjectHeader and only then
allocated the lookup key. The root is fine; the ORDER is not. The lookup
interns nothing, so every call mints a fresh string, and any of those
allocations can be the copying minor that evacuates globalThis -- after
which js_object_get_field_by_name dereferences retired from-space.

Promise.all is the shape that finds it: every element runs Promise.resolve,
and js_promise_resolve_spec asks is_default_promise_constructor for
globalThis.Promise through that helper, so one wide combinator call performs
tens of thousands of these lookups and one straddles the collection.

Three more callers of js_get_global_this() had the identical shape and are
fixed the same way (audit, not reproduced): the builtin-constructor name walk
in class_meta.rs (worse -- ~50 key allocations per call against one
pre-loop address), js_globalthis_seed_async_local_storage, and the
Temporal.<Type>.prototype walk.
…user-JS calls (#7497)

With the globalThis lookup fixed, PERRY_GC_PROTECT_FROMSPACE moved the fault
one frame out to run_combinator itself. perform() ran the per-element loop --
Call(promiseResolve, C, next) and Invoke(nextPromise, "then", ...), both of
which run user JS -- while holding the elements snapshot, the shared values and
remaining-count arrays, the capability's resolve/reject and the constructor in
bare Rust locals. build_element_closure read all five of its GC arguments
BEFORE js_closure_alloc and stored them after, publishing from-space addresses
into capture slots. new_promise_capability, the allSettled element functions
and build_settled_* had the same shape.

Everything is rooted in a RuntimeHandleScope and re-read at its point of use.
The per-element handles live in a scope inside the loop so a 50,000-element
combinator does not push 50,000 handle-stack entries. Handles are NaN-boxed, so
scripts/raw_handle_debt.py is unchanged at 999.

Also empties the auto-opt gate's skip list. Every array expansion is guarded on
${#...[@]} because macOS ships bash 3.2, where set -u turns "${EMPTY[@]}" into
an unbound-variable abort -- an empty skip list must leave the gate running.
… array fast path (#7497)

make_resolving_functions held the promise across four allocations and then
STORED it (and the shared already-resolved guard) into two closures' capture
slots -- the from-space-publishing shape. combinator_iterable_to_array's array
fast path carried the array being cloned across well_known_symbol and
own_symbol_property, the latter of which can run a user getter.

Both are on the Promise.all path the #7497 reproducer exercises.
…e globalThis.Promise probe (#7497)

Every spec-entry that asks is_default_promise_constructor whether `this` is the
intrinsic Promise pays a globalThis lookup that allocates a fresh key string,
and each held its own arguments in registers across it.

js_promise_resolve_spec is the one the instrument names: Promise.all calls it
once per element, and for Promise.all([...promises]) the element IS a promise,
so js_promise_resolved dereferenced a from-space GC_TYPE_PROMISE at minor #0.
js_promise_reject_spec, js_promise_try_spec, js_promise_with_resolvers_spec and
promise_prototype_then_thunk have the same shape and are rooted the same way.
…location (#7497)

js_array_clone read the source array pointer, called js_array_alloc(len) --
which can trigger the copying minor and MOVE the source -- and then memcpy'd
from the pre-collection address. That is every [...arr] / Array.from(arr) and
every promise combinator's iterable snapshot: the clone could copy whatever the
recycled from-space bytes now hold. PERRY_GC_PROTECT_FROMSPACE=1 faults inside
js_array_clone on the Promise.all snapshot at minor #0.
…ok calls (#7497)

Every dispatch arm of run_microtasks read its callback pointer (and the value it
passes) out of the popped Task into a bare local, then called async_hooks::before
and v8::promise_hook_before -- both of which allocate -- and only then loaded
func_ptr out of that pointer. The CURRENT_MICROTASK_CALLBACK cell IS a scanned
root, so evacuation rewrote the CELL and left the register copy naming
from-space. Disassembly of the faulting site: promise_hook_before, then
ldr x0,[sp,#0x70]; ldr x8,[x0] -- the closure header load.

#1663 had already rooted promise and next in this arm; the callback was missed.
The Inline, Microtask and AsyncStep arms have the same shape.
…#7497)

The first attempt rooted the callback inside each arm, after
enter_microtask_context had already run. A Task stops being a scanned root the
instant it is popped off TASK_QUEUE, so that seeded the handle with an address
the collection had already invalidated -- the instrument still faulted at
call_async_step_direct's (*step_closure).func_ptr, on a value re-read from a
handle. Disassembly showed the re-read was there and still wrong, which is what
made the ordering the suspect.

Every arm now roots its task's GC values as its first statement and re-reads
them after the context switch.
…s its allocations (#7497)

js_async_step_chain carried the step closure through
adapt_foreign_promise_value, js_promise_new, build_async_step_thunks,
js_promise_resolved and capture_context -- all allocating -- and then STORED it
into a Task::AsyncStep. The task queue is a scanned root and the microtask
runner now re-reads everything it pops, but neither helps when the pointer was
already dead at the push: the runner faithfully dispatched it. That is why the
fault kept reappearing at call_async_step_direct even after the consumer side
was rooted.
…t performs (#7497)

The reuse fast path settled trap_next and then RETURNED the pre-call copy of
that pointer -- and settling enqueues jobs and allocates. The async state
machine therefore received a from-space GC_TYPE_PROMISE as its own result.
The adoption slow path had the same shape around js_promise_new /
enqueue_native_adoption_job. Found by the protected run of the auto-optimize
binary, which faulted at js_async_step_done AFTER printing the right answer.
All six were valid and are the shapes this PR is about:

* class_meta.rs: the SEARCHED closure value was never refreshed, so a key
  allocation that evacuates it makes the equality test miss and the caller
  falls through to the generic construct tail.
* then.rs perform_promise_then_with_cap: filled ful_wrap's captures and THEN
  allocated rej_wrap -- the publishing shape. Both wrappers are now allocated
  and rooted before any capture is written.
* flat_clone.rs js_array_values: the same memcpy-from-a-moved-source shape
  js_array_clone had.
* combinators.rs: the GC_TYPE_OBJECT arm of combinator_iterable_to_array
  carried its receiver across a user [Symbol.iterator] getter and a key
  allocation.
* async_step.rs: the three early-return suspend paths carried the awaited
  promise, both fresh thunks and trap_next across build_async_step_thunks and
  js_promise_resolved; then_backpatch_result then STORES into those thunks.
  Factored into one rooted suspend_on_awaited helper.
* microtasks.rs: the AsyncStep arm boxed its closure with js_nanbox_pointer
  but decoded with rooted_closure, which expects boxed_closure's
  null-as-undefined encoding.
@proggeramlug
proggeramlug force-pushed the fix/7497-promise-all-chains branch from e50a589 to 9768cf0 Compare August 6, 2026 12:44
@proggeramlug
proggeramlug merged commit 5edfe99 into main Aug 6, 2026
9 of 13 checks passed
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

app-patterns: promise_all_chains rejects with a resolution value at scale (Uncaught (in promise) 0)

1 participant