Skip to content

fix(runtime): pin cross-thread promises in their constructor until they settle (#9552) - #9565

Closed
proggeramlug wants to merge 4 commits into
mainfrom
fix/9552-cross-thread-promise-pin
Closed

fix(runtime): pin cross-thread promises in their constructor until they settle (#9552)#9565
proggeramlug wants to merge 4 commits into
mainfrom
fix/9552-cross-thread-promise-pin

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Fixes #9552.

What was wrong

A promise minted for a cross-thread settlement — every stdlib fetch/db/ws request, perry/thread's spawn, Atomics.waitAsync — leaves the runtime as a bare usize inside a worker future. No root scanner sees it until its completion is queued back, and nothing on the JS side points at it either: the awaiting continuation hangs off it (P.on_fulfilled) and P.next is an edge out. The constructor's contract said the pin was the caller's job. spawn and waitAsync took it; ~110 stdlib sites (fetch among them) never did.

In the report's case (cc -p <120,000-char argument>, not logged in) an old-generation reclaim at an allocation point ran its malloc sweep while js_fetch_with_options's promise was in flight. The sweep freed it (flags=0x0, never pinned, no token, still Pending); mimalloc handed the 80-byte slot to a RegExp; the stdlib pump then resolved the stale address and queued it, and the microtask pump read REGEXP_MAGIC as the promise's next (mov 0x30(%rax) at pump_protected, SIGSEGV with si_addr=0). The nondeterminism is where the reclaim lands relative to the request. The from-space quarantine correctly reports it as an unrelated fault: the object was never in the arena.

Diagnosis was by symbolized build + an env-gated trace of every promise allocation, malloc-sweep free, pin/unpin, and token event; the trace shows new promise (malloc) → FREED by malloc sweep (state=0, not queued, token=false) → unpin → STALE js_promise_resolve for the fetch promise, with zero pin events in its lifetime.

The fix (one owner, no hot-path cost)

  • js_promise_new_cross_thread takes the pin itself: one flag bit on a malloc-resident object it is already writing, via pin_object_non_young so the copying minor's young-pin latch is never armed.
  • Settlement releases it: js_promise_resolve / js_promise_reject test one byte that lives in the padding after state (same cache line, no field moves); an arena promise pays that predictable-branch load and nothing else. remove_token_from_registry releases it too, so a token dropped without settling cannot leak its promise.
  • The caller-side pins in spawn and waitAsync and the thread::pin_promise helper are removed; the stdlib's js_promise_new_for_native_resolution is now exactly the constructor. The pump's explicit unpin stays: it still serves spawn_for_promise*, which pins whatever the caller passed.

Trust boundary

Everywhere a raw promise address re-enters the runtime from native code — the stdlib pump (both queues), the native-async token pump, the perry/thread result drain — now goes through native_promise_from_raw, which classifies the address with try_read_gc_header and aborts naming the site and the slot's current obj_type. That runs once per I/O completion, never per await. A future rooting hole of this shape fails at the boundary with an attributable message instead of as heap corruption cycles later.

Scanning for siblings

scripts/check_cross_thread_promise_provenance.py (new lint step) parses every function in perry-runtime, perry-stdlib and the perry-ext-* crates, tracks promise bindings by constructor (with shadowing and let p = promise as usize aliases), and fails on an arena promise (js_promise_new / js_promise_new_with_parent) reaching a native settlement sink (queue_*_resolution, queue_thread_result, spawn_for_promise*, spawn, spawn_blocking, std::thread::spawn). --self-test plants three bad shapes (direct, alias+spawn, with_parent+thread::spawn) and three clean ones (cross-thread ctor, unrelated usize, shadowed early-return arena promise). The tree is clean today; an early-return arena promise in spawn_impl was the one false positive the shadowing rule exists for.

Tests

  • crates/perry-runtime/src/promise/cross_thread_pin_tests.rs: pinned at creation / released by fulfilment and by rejection; survives js_gc_collect() while only an XOR-hidden integer holds it (so a conservative scan cannot make the assertion vacuous); arena promises carry no pin; dropping a token without settling releases the pin; classify_native_promise_addr names null / live / reused-slot / non-heap. All six pass; the full perry-runtime suite is 3005 passed, 0 failed, 4 ignored (RUST_TEST_THREADS=1), perry-stdlib's async_bridge tests 3/3.
  • test-files/test_gap_9552_cross_thread_promise_survives_gc.ts: three consumer shapes (.then, async arrow, async class method) each start a fetch against a local server that answers late, from a frame that has returned; the churn trips the malloc-count sweep (Symbol()) and reuses freed 80-byte slots (RegExp headers). Unfixed it hangs 4/4 (the env-gated trace shows two of the three promises FREED by malloc sweeps at state=0, then STALE js_promise_resolve on a slot now holding an object / nothing); fixed it prints node's ok,ok,ok 6 5/5. The first draft of this test (await fetch() in a plain async function + gc()) passed 6/6 unfixed and was replaced — a gate that cannot fail is not a gate.

Validation (perrymaster, main 3b9c9dee2 toolchain, cli_2.1.112.js compiled with --enable-wasm-runtime, same compiler, only the runtime archives swapped via PERRY_BUILD_COMMIT stamping)

arm C08_long_arg (-p x*120000) gap test
main tip SIGSEGV 4/5, then 1/4 (nondeterministic) hang 4/4 (timeout)
this PR's runtime exit 1 like node, 12/12 ok,ok,ok 6 5/5 = node
  • cargo check --release -p perry-runtime -p perry-stdlib --tests on current main + this patch: clean.
  • scripts/run_lint_gates.sh on main + this patch: 61 of 62 pass. The one failure, warnings (cargo check --workspace --all-targets with -D warnings), is pre-existing on pristine main (ed99c35cd): pthread_getattr_np / pthread_attr_getstack / pthread_attr_destroy are declared with *mut [u64; 8] in gc/roots.rs and *mut u8 in error_stack_frames.rs (feat(runtime): real function names in Error stacks — frame-pointer walk + the existing name registry (#9486) #9521), which clashing_extern_declarations rejects on Linux. Not touched here.

https://claude.ai/code/session_01Bok4V8wzgNGmBeE4GPf7Up

…ey settle (#9552)

A promise minted for a cross-thread settlement — every stdlib fetch/db/ws
request, `spawn`, `Atomics.waitAsync` — leaves the runtime as a bare usize
inside a worker future and is invisible to every root scanner until its
completion is queued back. Nothing on the JS side points AT it either: the
awaiting continuation hangs OFF it (`P.on_fulfilled`) and `P.next` is an
edge out. The pin was the caller's job; `spawn` and `waitAsync` took it,
~110 stdlib sites (fetch among them) never did. A full collection landing
in the in-flight window freed the promise, and the completion then
resolved whatever the allocator had reused the slot for — in the report,
a RegExp header read as the promise's `next` inside the microtask pump.

`js_promise_new_cross_thread` now takes the pin itself (one flag bit on a
malloc-resident object it is already writing; the young-pin latch is not
consulted) and the settlement paths release it (one byte test on the
promise's own cache line). A token dropped without a settlement releases
it too. The caller-side pins in `spawn` and `waitAsync` are gone; the
stdlib bridge helper is now exactly the constructor.

Every place a raw promise address re-enters the runtime from native code
(the stdlib pump, the native-async token pump, the thread-result drain)
classifies the address first and aborts naming the site and the slot's
occupant, so a future rooting hole fails at the boundary instead of as
heap corruption cycles later.

`scripts/check_cross_thread_promise_provenance.py` (lint) finds arena
promises handed to a native settlement sink or captured by a spawn,
self-tested with three planted shapes and three clean ones.

Claude-Session: https://claude.ai/code/session_01Bok4V8wzgNGmBeE4GPf7Up
@coderabbitai

coderabbitai Bot commented Sep 2, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed via merge train #9569 (rebase-merge, authorship preserved), with the three gc test-fixture Promise literals updated for native_pinned as a train fix.

proggeramlug pushed a commit that referenced this pull request Sep 2, 2026
…9565

The #9552 fix landed via merge train #9569 with the first draft of its gap
test. That draft — `await fetch()` in a plain async function plus `gc()` —
passed 6/6 on the UNFIXED runtime: the collection it forces does not free
the in-flight promise, so the gate could not fail. This is the fixture
validated on both arms: three consumer shapes (`.then`, async arrow, async
class method) start a request from a frame that has returned, `Symbol()`
churn trips the malloc-count sweep, `RegExp` headers reuse freed 80-byte
slots. Unfixed it hangs 4/4 (two of the three promises freed by malloc
sweeps, then a stale resolve on a reused slot); fixed it prints node's
`ok,ok,ok 6` 5/5.

Also lands the changelog fragment for #9565, which the train did not carry.

Claude-Session: https://claude.ai/code/session_01Bok4V8wzgNGmBeE4GPf7Up
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.

SIGSEGV: cc with a ~290-char argument crashes perry-compiled cc (rc=-11, nondeterministic 2/3 reps) — regression window includes today's merge trains

1 participant