Skip to content

fix(worker): root the awaited handler Promise across perry_poll - #15

Merged
proggeramlug merged 2 commits into
mainfrom
fix/8546-root-awaited-promise
Aug 27, 2026
Merged

fix(worker): root the awaited handler Promise across perry_poll#15
proggeramlug merged 2 commits into
mainfrom
fix/8546-root-awaited-promise

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

The bug

await_promise extracted a raw Promise pointer from the NaN-boxed handler
result and then called perry_poll in a loop:

let promise_ptr = (bits & POINTER_MASK) as usize as *mut u8;
loop {
    (api.perry_poll)();                              // runs JS -> GC safepoints
    let state = (api.js_promise_state)(promise_ptr); // pre-collection address

perry_poll runs JS, which reaches GC safepoints, and an evacuating minor
moves the Promise. From then on promise_ptr is a stale from-space address
and js_promise_state reads recycled memory.

How it was found

PERRY_GC_PROTECT_FROMSPACE=1 faults at the stale use, not cycles later:

[gc-fromspace-protect] FAULT: signal 10 at 0xc76124770
  This address is RETIRED FROM-SPACE. The evacuating minor moved or freed
  the object here and the holder kept the pre-collection address.
  block=0xc76124000 +1904 retired_bytes=45544 retired_by_minor=#114
  last-known object: user_ptr=0xc76124770 obj_type=5 size=72
  The faulting instruction IS the stale use.
REPRODUCER: PERRY_GC_SCHEDULE_SEED=12345

obj_type=5 is GC_TYPE_PROMISE, and the backtrace lands in
DeploymentHost::load_with_options on the app's Perry thread.

The fix

Re-deriving the pointer from promise_value each turn does not help — the
NaN-box holds the same pre-collection address. Only a root the collector
rewrites is stable.

Perry already exports the FFI root scope for exactly this
(js_ffi_root_scope_enter/exit, js_ffi_root_push_nanbox,
js_ffi_root_get_nanbox). Root the Promise for the duration of the await and
re-read the slot every turn. An RAII guard pops the scope on every exit path,
including the ? and timeout returns.

Verification

Same fixture, same seed (PERRY_GC_SCHEDULE_SEED=12345 PERRY_GC_SCHEDULE_RATE=0.5 PERRY_GC_PROTECT_FROMSPACE=1), instrument armed in both arms — a run with zero
copying minors would protect nothing, so the minor count is the anti-vacuity
check:

copying minors from-space faults
before 116 FAULT (obj_type=5)
after 107 0, no TypeError

cargo check -p coop-worker clean; cargo fmt clean.

Scope — this does NOT close PerryTS/perry#8546

Hosting two Next.js applications in_process still fails. What changed is that
the failures are now named rather than opaque:

bench-000: TypeError: _onUserlandLoaded is not a function
bench-001: TypeError: value is not a function

_onUserlandLoaded is a Node bootstrap internal, so there is at least one more
holder of this same class (a JS value cached across something that can run JS).
This PR removes one verified instance; #8546 stays open for the rest.

Summary by CodeRabbit

  • Bug Fixes
    • Improved reliability when awaiting promises during garbage collection.
    • Prevented invalid promise values from causing unsafe waits.
    • Ensured temporary resources are cleaned up after successful, timed-out, or failed operations.

await_promise cached a raw Promise pointer and then called perry_poll in a
loop. perry_poll runs JS, which reaches GC safepoints, and an evacuating
minor MOVES the Promise -- after which the cached address is stale
from-space memory and js_promise_state reads recycled bytes.

Caught precisely with PERRY_GC_PROTECT_FROMSPACE:

  [gc-fromspace-protect] FAULT: signal 10
  This address is RETIRED FROM-SPACE. The evacuating minor moved or freed
  the object here and the holder kept the pre-collection address.
  last-known object: obj_type=5 size=72          (5 = GC_TYPE_PROMISE)
  The faulting instruction IS the stale use.

Re-deriving the pointer from the NaN-boxed value each turn does not help --
the box holds the same pre-collection address. Root it in Perry's FFI root
scope and re-read the slot each turn, so the collector rewrites it. An RAII
guard pops the scope on every exit path, including the timeout return.

A/B on the same fixture and seed (PERRY_GC_SCHEDULE_SEED=12345
PERRY_GC_SCHEDULE_RATE=0.5), instrument armed both times:

  before: 116 copying minors -> FAULT (obj_type=5)
  after:  107 copying minors -> 0 faults, no TypeError

Refs PerryTS/perry#8546. That issue is NOT fully closed by this: with two
Next.js apps the remaining failures are now NAMED
("_onUserlandLoaded is not a function"), indicating further holders of the
same class.
@coderabbitai

coderabbitai Bot commented Aug 22, 2026

Copy link
Copy Markdown

Review Change Stack

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Free

Run ID: 6c3710b0-fc0b-4093-9420-1d518c9522ae

📥 Commits

Reviewing files that changed from the base of the PR and between ad9e03e and a431a76.

📒 Files selected for processing (2)
  • crates/coop-worker/src/plugin_host.rs
  • crates/coop-worker/src/runtime_libraries.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

await_promise now roots Promise values through Perry’s FFI root scope and refreshes GC-updated values during polling. RuntimeApi exposes the required root-management functions, which runtime initialization resolves from the runtime provider.

Changes

Promise GC Rooting

Layer / File(s) Summary
Runtime root API
crates/coop-worker/src/runtime_libraries.rs
The runtime defines FFI signatures for root scopes and nanboxed values. RuntimeApi stores the function pointers. Runtime initialization resolves all four symbols and fails when a symbol is unavailable.
Rooted Promise polling
crates/coop-worker/src/plugin_host.rs
await_promise validates and roots the Promise value, then reads the refreshed value on each poll. FfiRootScope closes the root scope when the operation exits.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk: ⚪ Minimal · up to a431a

This change roots the awaited Promise across garbage-collection safepoints and re-reads it safely during polling; no actionable merge-blocking risk remains beyond normal checks and review.


Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/settings/billing.

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

…-promise

# Conflicts:
#	crates/coop-worker/src/plugin_host.rs
#	crates/coop-worker/src/runtime_libraries.rs
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.

coop/in_process: second Next app dies with TypeError: value is not a function (GC rooting, multi-heap)

1 participant