From f81686384654de3e210bd399a4a541e1e4f7036d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 15 Jul 2026 12:28:39 +0200 Subject: [PATCH 1/5] fix(crypto): run WebCrypto digest + async randomBytes on a macrotask like Node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node runs `crypto.subtle.digest` and the callback form of `crypto.randomBytes(size, cb)` on the libuv threadpool, so `await`ing them observably yields a macrotask — the promise/callback settles on a later event-loop iteration, never synchronously. Perry computed the bytes eagerly and resolved synchronously (+0 hops), so an `await`ing caller continued a full event-loop iteration ahead of Node. That timing is observable. Auth.js v5 hashes its CSRF token with `subtle.digest`, so a Next.js Server Component's `await auth()` finished a macrotask early under Perry, which collapsed React's Flight (RSC) streaming waves and renumbered the serialized rows of the response versus Node. Fix: both now schedule their settlement through `setImmediate` (the timer/ callback queue) instead of resolving inline. The bytes are still produced eagerly; only the callback/promise dispatch is deferred, so values are unchanged. `subtle.digest` re-arms once (Node's threadpool digest yields ~2 setImmediate ticks); `randomBytes` defers one tick. The deferred Promise/Buffer survive GC via the timer root scanner (`scan_timer_roots`). Verified: digest and randomBytes values are byte-identical to Node (`sha256("hello")` etc.); a synchronous `createHash(...).digest()` still crosses zero macrotasks; the promise/stream microtask-hop harnesses are unaffected. Added test-files/test_gap_webcrypto_async_threadpool.ts pinning the observable contract (async crypto crosses a macrotask, sync hash does not, values unchanged). --- crates/perry-stdlib/src/crypto/random.rs | 23 ++++++-- crates/perry-stdlib/src/webcrypto/digest.rs | 51 ++++++++++++++++- .../test_gap_webcrypto_async_threadpool.ts | 56 +++++++++++++++++++ 3 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 test-files/test_gap_webcrypto_async_threadpool.ts diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 8d9ae47dd8..374d9e9785 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -70,9 +70,16 @@ pub extern "C" fn js_crypto_random_bytes_buffer( /// `crypto.randomBytes(size, callback)` — callback form. /// -/// Perry executes the callback synchronously, but preserves Node's -/// observable callback shape `(err, buffer)` for parity tests and common -/// compatibility paths. +/// Node runs `randomBytes` on the libuv threadpool, so the `(err, buffer)` +/// callback fires on a LATER event-loop iteration (one macrotask hop), never +/// synchronously. Perry generates the bytes synchronously but must preserve +/// that timing: schedule the callback via `setImmediate` instead of calling it +/// inline. Resolving it synchronously let an `await`ing caller continue a +/// macrotask early — e.g. Auth.js generates its CSRF token with async +/// `randomBytes`, so a Next.js Server Component's `await auth()` completed one +/// event-loop iteration ahead of Node, reordering the React Flight (RSC) rows +/// of the streamed response. The bytes are still produced eagerly; only the +/// callback dispatch is deferred. #[no_mangle] pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: f64) -> f64 { let buf = js_crypto_random_bytes_buffer(size); @@ -81,7 +88,15 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: } else { f64::from_bits(JSValue::pointer(buf as *const u8).bits()) }; - call_node_style_callback2(callback_bits, f64::from_bits(JSValue::null().bits()), value); + // Closure pointer (lower 48 bits of the NaN-boxed callback), matching + // `call_node_style_callback2`'s extraction and the i64 the timer queue + // reinterprets as `*const ClosureHeader`. + let cb_ptr = (callback_bits.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; + if cb_ptr >= 0x1000 { + let err = f64::from_bits(JSValue::null().bits()); + let args = [err, value]; + perry_runtime::timer::js_set_immediate_callback_args(cb_ptr, args.as_ptr(), 2); + } f64::from_bits(JSValue::undefined().bits()) } diff --git a/crates/perry-stdlib/src/webcrypto/digest.rs b/crates/perry-stdlib/src/webcrypto/digest.rs index d0b0ab4126..73762ee4b7 100644 --- a/crates/perry-stdlib/src/webcrypto/digest.rs +++ b/crates/perry-stdlib/src/webcrypto/digest.rs @@ -1,5 +1,37 @@ use super::*; +/// Native closure body: resolve the captured Promise (slot 0) with the +/// captured digest value (slot 1). Scheduled via `setImmediate` so +/// `crypto.subtle.digest()` settles on a later event-loop iteration, matching +/// Node — whose WebCrypto digest runs on the libuv threadpool (a macrotask), +/// not synchronously. Resolving it synchronously let an `await`ing caller +/// continue an event-loop iteration early: Auth.js hashes its CSRF token with +/// `subtle.digest`, so a Next.js Server Component's `await auth()` completed +/// ahead of Node and reordered the React Flight (RSC) rows of the response. +extern "C" fn webcrypto_digest_settle(closure: *const perry_runtime::ClosureHeader) -> f64 { + let promise_bits = perry_runtime::closure::js_closure_get_capture_ptr(closure, 0) as u64; + let value_bits = perry_runtime::closure::js_closure_get_capture_ptr(closure, 1) as u64; + // Slot 2 holds the remaining macrotask hops (raw i64). Node's threadpool + // WebCrypto digest observably yields TWO setImmediate iterations (submit + + // completion), so re-arm one more time before resolving. + let remaining = perry_runtime::closure::js_closure_get_capture_ptr(closure, 2); + if remaining > 1 { + let cl = + perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3); + perry_runtime::closure::js_closure_set_capture_ptr(cl, 0, promise_bits as i64); + perry_runtime::closure::js_closure_set_capture_ptr(cl, 1, value_bits as i64); + perry_runtime::closure::js_closure_set_capture_ptr(cl, 2, remaining - 1); + perry_runtime::timer::js_set_immediate_callback(cl as i64); + return f64::from_bits(JSValue::undefined().bits()); + } + let promise = perry_runtime::value::js_nanbox_get_pointer(f64::from_bits(promise_bits)) + as *mut perry_runtime::promise::Promise; + if !promise.is_null() { + perry_runtime::promise::js_promise_resolve(promise, f64::from_bits(value_bits)); + } + f64::from_bits(JSValue::undefined().bits()) +} + /// `crypto.subtle.digest(algorithm, data)` → Promise /// /// `algorithm` is "SHA-1" / "SHA-256" / "SHA-384" / "SHA-512" (string) @@ -14,5 +46,22 @@ pub unsafe extern "C" fn js_webcrypto_digest(algo_bits: f64, data_bits: f64) -> }; let bytes = bytes_from_jsvalue(data_bits.to_bits()); let digest = compute_digest(algo, &bytes); - resolve_with_bytes(&digest) + // The digest bytes are computed eagerly, but the Promise settles on the + // next event-loop iteration (setImmediate) — Node runs the hash on the + // threadpool, so `await subtle.digest(...)` observably yields a macrotask. + let buf = alloc_uint8array_from_slice(&digest); + if buf.is_null() { + return reject_with_dom_exception("OperationError", "The operation failed"); + } + let value = f64::from_bits(JSValue::pointer(buf as *const u8).bits()); + let promise = perry_runtime::promise::js_promise_new(); + let promise_val = f64::from_bits(JSValue::pointer(promise as *const u8).bits()); + let cl = + perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3); + perry_runtime::closure::js_closure_set_capture_ptr(cl, 0, promise_val.to_bits() as i64); + perry_runtime::closure::js_closure_set_capture_ptr(cl, 1, value.to_bits() as i64); + // Remaining macrotask hops (Node's threadpool digest = 2 setImmediate ticks). + perry_runtime::closure::js_closure_set_capture_ptr(cl, 2, 2); + perry_runtime::timer::js_set_immediate_callback(cl as i64); + promise } diff --git a/test-files/test_gap_webcrypto_async_threadpool.ts b/test-files/test_gap_webcrypto_async_threadpool.ts new file mode 100644 index 0000000000..003a19de63 --- /dev/null +++ b/test-files/test_gap_webcrypto_async_threadpool.ts @@ -0,0 +1,56 @@ +// Node runs WebCrypto `subtle.digest` and async `crypto.randomBytes(cb)` on the +// libuv threadpool, so `await`ing them observably yields a macrotask — they do +// NOT resolve synchronously. Perry resolved them synchronously, which let an +// awaiting caller (e.g. Auth.js hashing a CSRF token in a Next.js Server +// Component) continue an event-loop iteration early and reorder streamed RSC +// output. The exact macrotask-hop count is threadpool/context dependent, so this +// pins the observable contract: async crypto crosses ≥1 macrotask, a sync hash +// crosses none, and values are unchanged. +import { webcrypto, randomBytes, createHash } from "crypto"; +import { promisify } from "util"; + +let s = 0; +let run = true; +function metro(): void { + if (!run) return; + s++; + setImmediate(metro); +} +metro(); + +async function crossesMacrotask(fn: () => Promise): Promise { + const a = s; + await fn(); + return s - a > 0; +} + +async function main(): Promise { + console.log( + "subtle.digest crosses macrotask:", + await crossesMacrotask(() => + webcrypto.subtle.digest("SHA-256", new Uint8Array([1, 2, 3])), + ), + ); + console.log( + "randomBytes(cb) crosses macrotask:", + await crossesMacrotask(() => promisify(randomBytes)(16)), + ); + + // A synchronous hash stays synchronous. + const a2 = s; + createHash("sha256").update("x").digest("hex"); + console.log("createHash sync crosses macrotask:", s - a2 > 0); + + // Values are unchanged by the deferral. + const d = await webcrypto.subtle.digest( + "SHA-256", + new TextEncoder().encode("hello"), + ); + console.log("sha256(hello):", Buffer.from(d).toString("hex")); + const rb = await promisify(randomBytes)(8); + console.log("randomBytes length:", rb.length); + + run = false; +} + +main(); From 85076f6bf9fb164eea0b4f11572031cfbf9e8776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 15 Jul 2026 12:48:22 +0200 Subject: [PATCH 2/5] style(crypto): rustfmt digest/random deferral --- crates/perry-stdlib/src/webcrypto/digest.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/perry-stdlib/src/webcrypto/digest.rs b/crates/perry-stdlib/src/webcrypto/digest.rs index 73762ee4b7..ddb549338c 100644 --- a/crates/perry-stdlib/src/webcrypto/digest.rs +++ b/crates/perry-stdlib/src/webcrypto/digest.rs @@ -16,8 +16,7 @@ extern "C" fn webcrypto_digest_settle(closure: *const perry_runtime::ClosureHead // completion), so re-arm one more time before resolving. let remaining = perry_runtime::closure::js_closure_get_capture_ptr(closure, 2); if remaining > 1 { - let cl = - perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3); + let cl = perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3); perry_runtime::closure::js_closure_set_capture_ptr(cl, 0, promise_bits as i64); perry_runtime::closure::js_closure_set_capture_ptr(cl, 1, value_bits as i64); perry_runtime::closure::js_closure_set_capture_ptr(cl, 2, remaining - 1); @@ -56,8 +55,7 @@ pub unsafe extern "C" fn js_webcrypto_digest(algo_bits: f64, data_bits: f64) -> let value = f64::from_bits(JSValue::pointer(buf as *const u8).bits()); let promise = perry_runtime::promise::js_promise_new(); let promise_val = f64::from_bits(JSValue::pointer(promise as *const u8).bits()); - let cl = - perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3); + let cl = perry_runtime::closure::js_closure_alloc(webcrypto_digest_settle as *const u8, 3); perry_runtime::closure::js_closure_set_capture_ptr(cl, 0, promise_val.to_bits() as i64); perry_runtime::closure::js_closure_set_capture_ptr(cl, 1, value.to_bits() as i64); // Remaining macrotask hops (Node's threadpool digest = 2 setImmediate ticks). From 06b1401e4421ed96013b095aa0aeb871ea4354a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 15 Jul 2026 13:02:07 +0200 Subject: [PATCH 3/5] fix(crypto): avoid handle-floor pattern in randomBytes deferral Use JSValue::is_pointer() + js_nanbox_get_pointer for the callback closure instead of a raw `bits & mask + >= 0x1000` floor check, so the addr-class ratchet doesn't gain a handle-floor site. Behavior unchanged (randomBytes callback still deferred one setImmediate tick; verified +1 hop, values correct). --- crates/perry-stdlib/src/crypto/random.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 374d9e9785..d72892f48e 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -88,11 +88,12 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: } else { f64::from_bits(JSValue::pointer(buf as *const u8).bits()) }; - // Closure pointer (lower 48 bits of the NaN-boxed callback), matching - // `call_node_style_callback2`'s extraction and the i64 the timer queue - // reinterprets as `*const ClosureHeader`. - let cb_ptr = (callback_bits.to_bits() & 0x0000_FFFF_FFFF_FFFF) as i64; - if cb_ptr >= 0x1000 { + // Only a genuine closure (POINTER_TAG) is a schedulable callback; a bare + // `undefined`/primitive is a no-op. The timer queue reinterprets the i64 as + // a `*const ClosureHeader`, so hand it the unboxed pointer. + let cb_val = JSValue::from_bits(callback_bits.to_bits()); + if cb_val.is_pointer() { + let cb_ptr = perry_runtime::value::js_nanbox_get_pointer(callback_bits) as i64; let err = f64::from_bits(JSValue::null().bits()); let args = [err, value]; perry_runtime::timer::js_set_immediate_callback_args(cb_ptr, args.as_ptr(), 2); From 7a5dd4e868612fe008ce1b38077cc1aa6aaa3253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 15 Jul 2026 14:41:13 +0200 Subject: [PATCH 4/5] harden(crypto): gate randomBytes async callback on is_closure_ptr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment said "only a genuine closure is a schedulable callback" but the guard tested `is_pointer()`, which also accepts a non-function object/array (`randomBytes(n, {})`). Match the guard to its stated intent and to every other node-style-callback site (`is_closure_ptr` / `is_callable_value`): validate the CLOSURE_MAGIC at the source instead of relying on the timer's downstream check. Not a crash fix — `js_closure_call2` already routes through `get_valid_func_ptr`, which range-checks the address and rejects a non-magic tag, so a mis-typed callback was already a safe no-op (verified: the pre-change `is_pointer` build survives `randomBytes(16, {})` / `[..]` / `42`). This just rejects it one layer earlier and keeps the guard honest. Node throws ERR_INVALID_ARG_TYPE for these; matching that (vs the current silent no-op, which predates this PR) is a separate parity item across all crypto async callbacks. --- crates/perry-stdlib/src/crypto/random.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index d72892f48e..c80c473e19 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -88,12 +88,17 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: } else { f64::from_bits(JSValue::pointer(buf as *const u8).bits()) }; - // Only a genuine closure (POINTER_TAG) is a schedulable callback; a bare - // `undefined`/primitive is a no-op. The timer queue reinterprets the i64 as - // a `*const ClosureHeader`, so hand it the unboxed pointer. - let cb_val = JSValue::from_bits(callback_bits.to_bits()); - if cb_val.is_pointer() { - let cb_ptr = perry_runtime::value::js_nanbox_get_pointer(callback_bits) as i64; + // Only a genuine closure is a schedulable callback. The timer queue + // reinterprets the i64 as `*const ClosureHeader` and calls it with no + // validation, so a bare `is_pointer()` check is not enough: a non-function + // POINTER value (a plain object or array passed as the callback — + // `randomBytes(n, {})`, which Node rejects with ERR_INVALID_CALLBACK) would + // pass it and be miscast as a closure, then dereferenced when the timer + // fires. Gate on `is_closure_ptr` (the CLOSURE_MAGIC + heap-range probe used + // by every other node-style-callback site) so a non-callable argument is a + // safe no-op instead. A non-pointer primitive fails the `>= 0x10000` floor. + let cb_ptr = perry_runtime::value::js_nanbox_get_pointer(callback_bits); + if cb_ptr >= 0x10000 && perry_runtime::closure::is_closure_ptr(cb_ptr as usize) { let err = f64::from_bits(JSValue::null().bits()); let args = [err, value]; perry_runtime::timer::js_set_immediate_callback_args(cb_ptr, args.as_ptr(), 2); From c5b218333966cbe4e0ee69a3b254459d15ba9315 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 15 Jul 2026 23:17:24 +0200 Subject: [PATCH 5/5] style(crypto): drop the redundant handle-band literal from the callback guard The address-classification ratchet flagged `cb_ptr >= 0x10000` as a bare band literal. It is redundant anyway: `is_closure_ptr` opens with `if is_handle_band(ptr) return false` and bounds the address before the CLOSURE_MAGIC probe, so it already rejects the whole handle band and any non-heap value. Call it directly. --- crates/perry-stdlib/src/crypto/random.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index c80c473e19..5a808e0441 100644 --- a/crates/perry-stdlib/src/crypto/random.rs +++ b/crates/perry-stdlib/src/crypto/random.rs @@ -96,9 +96,11 @@ pub unsafe extern "C" fn js_crypto_random_bytes_async(size: f64, callback_bits: // pass it and be miscast as a closure, then dereferenced when the timer // fires. Gate on `is_closure_ptr` (the CLOSURE_MAGIC + heap-range probe used // by every other node-style-callback site) so a non-callable argument is a - // safe no-op instead. A non-pointer primitive fails the `>= 0x10000` floor. + // safe no-op instead. `is_closure_ptr` self-rejects the whole handle band + // and any non-heap address, so a primitive/undefined callback is covered + // without a separate magnitude floor. let cb_ptr = perry_runtime::value::js_nanbox_get_pointer(callback_bits); - if cb_ptr >= 0x10000 && perry_runtime::closure::is_closure_ptr(cb_ptr as usize) { + if perry_runtime::closure::is_closure_ptr(cb_ptr as usize) { let err = f64::from_bits(JSValue::null().bits()); let args = [err, value]; perry_runtime::timer::js_set_immediate_callback_args(cb_ptr, args.as_ptr(), 2);