Skip to content

GC: async_step_fulfill_thunk holds two bare *mut Promise locals across the step-body call (asyncpipe SIGBUS) #7794

Description

@proggeramlug

Summary

async_step_fulfill_thunk keeps two bare *mut Promise Rust locals alive across the step-body call, which is arbitrary user code that allocates and can trigger an evacuating minor. The GC rewrites every slot it can see — the closure capture, and INLINE_TRAP.trap_next via scan_inline_trap_step — but it cannot see a bare local. After the collection both locals name retired from-space, and one of them is immediately dereferenced.

This is the unexplained asyncpipe SIGBUS: exit 138 under PERRY_GC_PROTECT_FROMSPACE=1, faulting on a GC_TYPE_PROMISE (obj_type=5 size=72).

It is the same family as #7154 / #7184 / #7192, and specifically the same residual shape that #7497 already fixed one function away in js_async_step_done ("the displaced value rides a bare Rust local ... and is republished by _end. Rooting the cell protects the ARMED value, not the saved one").

Location

crates/perry-runtime/src/promise/async_step.rs, async_step_fulfill_thunk (~line 913):

let captured_trap_next = js_closure_get_capture_ptr(closure, 1) as *mut Promise;  // (A) bare local
...
let prev = INLINE_TRAP.with(|c| { let old = c.get();                              // (B) bare local
    c.set(InlineTrap { trap_next: captured_trap_next, current_step: step as usize }); old });

let result = js_closure_call2(step, value, false_bits);   // <-- USER CODE: allocates, can collect

INLINE_TRAP.with(|c| c.set(prev));                        // (B) republishes a STALE pointer
forward_swallowed_rejection(result, captured_trap_next);  // (A) STALE pointer, then dereferenced

forward_swallowed_rejection (~line 963) dereferences it directly — (*trap_next).state — and that is the faulting instruction the quarantine reporter names.

Two distinct defects in one function:

  • (A) captured_trap_next is read before the step body runs and used after it.
  • (B) prev (an InlineTrap, which contains a raw *mut Promise) is saved before and written back into the TLS cell after. So the collector faithfully rewrites INLINE_TRAP during the collection, and this line then overwrites it with the pre-collection value. Rooting the cell cannot help; the stale value is reintroduced from outside it.

Note that (B) means scan_inline_trap_step working correctly is not evidence against this bug — which is why the obvious explanation looked wrong.

Reproducer

# gc-handoff/apps/asyncpipe.ts, any build
PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 ./p_asyncpipe   # exit 138

Symbolicated (PERRY_DEBUG_SYMBOLS=1):

[gc-fromspace-protect] FAULT: signal 10 at 0x29f690206c0
  This address is RETIRED FROM-SPACE.
  block=... retired_by_minor=#1657
  last-known object: user_ptr=0x29f690206c8 obj_type=5 size=72
0  ...arena::quarantine::fromspace_fault_handler + 5024
1  libsystem_platform.dylib  _sigtramp + 56
2  ...promise::async_step::async_step_fulfill_thunk + 176
3  ...promise::microtasks::run_microtasks + 20212
4  js_callback_timer_tick + 2996
5  ...promise::microtasks::run_microtasks + 26400
6  main + 536

Interaction with #7793 — please read before triaging as fixed

PR #7793 (the property-miss probe gates) removes ~4 key-string allocations per thenable check on the async path. That drastically reduces the number of collection opportunities inside the vulnerable window, and asyncpipe stops faulting under default GC pacing after it. That is masking, not a fix — the bug is fully intact:

# WITH #7793 applied, same program:
PERRY_GC_ZEAL=1 PERRY_GC_PROTECT_FROMSPACE=1 PERRY_GC_PROTECT_FROMSPACE_DEPTH=800 ./p_asyncpipe
# -> exit 138, identical fault: obj_type=5 size=72, retired_by_minor=#1657

So after #7793 lands, the default-pacing reproducer is gone and PERRY_GC_ZEAL=1 becomes required to see it. Worth fixing before that knowledge goes stale.

Suggested fix

Mirror #7497's pattern from js_async_step_done in the same file: open a RuntimeHandleScope, root the captured promise (and the saved prev.trap_next) before js_closure_call2, and re-read both from their handles afterwards rather than using the pre-call bindings — including when restoring INLINE_TRAP.

Why the static checker did not catch it

scripts/gc_root_dominance_check.py reads emitted LLVM IR from user modules; this is a raw pointer held in a runtime Rust frame, which is structurally invisible to it (see the CLAUDE.md note on runtime-side caches). The runtime instruments are the only detector here.

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