diff --git a/crates/perry-stdlib/src/crypto/random.rs b/crates/perry-stdlib/src/crypto/random.rs index 8d9ae47dd8..5a808e0441 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,23 @@ 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); + // 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. `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 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); + } 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..ddb549338c 100644 --- a/crates/perry-stdlib/src/webcrypto/digest.rs +++ b/crates/perry-stdlib/src/webcrypto/digest.rs @@ -1,5 +1,36 @@ 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 +45,21 @@ 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();