Skip to content

fix(crypto): run WebCrypto digest + async randomBytes on a macrotask like Node#6430

Merged
proggeramlug merged 5 commits into
mainfrom
fix/webcrypto-async-threadpool-timing
Jul 15, 2026
Merged

fix(crypto): run WebCrypto digest + async randomBytes on a macrotask like Node#6430
proggeramlug merged 5 commits into
mainfrom
fix/webcrypto-async-threadpool-timing

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Node runs crypto.subtle.digest and the callback form of crypto.randomBytes(size, cb) on the libuv threadpool, so awaiting 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 awaiting caller continued a full event-loop iteration ahead of Node.

Why it's observable

Auth.js v5 hashes its CSRF token with subtle.digest. In a Next.js Server Component, session = await auth() therefore finished a macrotask early under Perry, which collapsed React's Flight (RSC) streaming waves and renumbered the serialized rows of the streamed response versus Node. On a real Next.js 16 app this took the landing route from 5 to 7 __next_f hydration pushes — i.e. it was the difference between a byte-divergent and (all but the last few bytes) byte-identical RSC payload.

Fix

Both now schedule their settlement through setImmediate instead of resolving inline:

  • crypto.subtle.digest returns a pending promise resolved from a native closure scheduled on the timer/immediate queue; it re-arms once, since Node's threadpool digest yields ~2 setImmediate ticks.
  • crypto.randomBytes(size, cb) defers its (err, buf) callback by one setImmediate tick.

The bytes are still produced eagerly — only the callback/promise dispatch is deferred, so values are unchanged. The deferred Promise/Buffer survive GC via the timer root scanner (scan_timer_roots).

Testing

  • Digest and randomBytes values are byte-identical to Node (sha256("hello") = 2cf24dba…, correct lengths).
  • A synchronous createHash(...).digest() still crosses zero macrotasks.
  • The promise/stream microtask-hop harnesses are unaffected (no crypto in them).
  • Added test-files/test_gap_webcrypto_async_threadpool.ts pinning the observable contract: async crypto crosses a macrotask, a sync hash does not, and values are unchanged. The exact hop count is threadpool/context-dependent in Node, so the test asserts the async contract rather than a fixed count.

Summary by CodeRabbit

  • Bug Fixes
    • Updated crypto.randomBytes() callback delivery and crypto.subtle.digest() Promise settling to better match Node-style macrotask timing (completion is now deferred).
    • Ensures returned digest and random bytes remain correct even with deferred completion.
    • If digest output allocation fails, crypto.subtle.digest() now rejects with an OperationError.
  • Tests
    • Added a macrotask-behavior test validating threadpool-based async paths versus synchronous hashing.

…like Node

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).
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4761ca68-b462-47bf-937e-a4ea954aefb3

📥 Commits

Reviewing files that changed from the base of the PR and between 7a5dd4e and c5b2183.

📒 Files selected for processing (1)
  • crates/perry-stdlib/src/crypto/random.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-stdlib/src/crypto/random.rs

📝 Walkthrough

Walkthrough

randomBytes callbacks and crypto.subtle.digest() Promise settlement now occur on later macrotasks. Digest allocation failures reject with OperationError, and a test verifies deferred versus synchronous cryptographic timing.

Changes

Cryptographic async timing

Layer / File(s) Summary
Deferred randomBytes callback dispatch
crates/perry-stdlib/src/crypto/random.rs
Callback timing documentation now describes later macrotask dispatch, and validated closure callbacks receive (err, value) through setImmediate-style scheduling.
Deferred WebCrypto digest settlement and timing coverage
crates/perry-stdlib/src/webcrypto/digest.rs, test-files/test_gap_webcrypto_async_threadpool.ts
Digest results are wrapped in a Promise settled by a re-arming native closure; allocation failures reject with OperationError. Tests verify macrotask crossing for WebCrypto and randomBytes while synchronous hashing remains immediate.

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

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant js_crypto_random_bytes_async
  participant js_webcrypto_digest
  participant EventLoop
  participant PromiseOrCallback
  Caller->>js_crypto_random_bytes_async: request random bytes
  js_crypto_random_bytes_async->>EventLoop: schedule callback with err and value
  EventLoop->>PromiseOrCallback: invoke randomBytes callback
  Caller->>js_webcrypto_digest: request digest
  js_webcrypto_digest->>EventLoop: schedule digest settlement closure
  EventLoop->>PromiseOrCallback: resolve digest Promise
Loading

Possibly related PRs

  • PerryTS/perry#5539: Both changes modify crypto.randomBytes callback handling and its asynchronous dispatch behavior.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: deferring WebCrypto digest and async randomBytes callbacks to a macrotask.
Description check ✅ Passed The description is mostly complete and covers summary, rationale, fix details, and testing, though it doesn't follow the template headings exactly.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/webcrypto-async-threadpool-timing

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
test-files/test_gap_webcrypto_async_threadpool.ts (1)

1-11: 📐 Maintainability & Code Quality | 🔵 Trivial

Use the pinned Node runtime for this gap test. The run here used v24.15.0, so it doesn’t match the .node-version oracle (26.5.0).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test-files/test_gap_webcrypto_async_threadpool.ts` around lines 1 - 11,
Update the gap test’s Node runtime configuration to use the pinned version from
the project’s .node-version oracle, 26.5.0, instead of the currently used
v24.15.0. Keep the existing crypto timing assertions and test logic unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/perry-stdlib/src/crypto/random.rs`:
- Around line 91-99: Update js_crypto_random_bytes_async to validate that
callback is callable before extracting or queueing it through
js_set_immediate_callback_args. Preserve the existing error/value argument
construction for valid callbacks, and avoid forwarding non-function callback
bits that could be miscast as a ClosureHeader.

In `@crates/perry-stdlib/src/webcrypto/digest.rs`:
- Around line 49-66: Root the allocated Uint8Array and Promise in the digest
flow before performing subsequent heap allocations. In the code around
webcrypto_digest_settle, alloc_uint8array_from_slice, js_promise_new, and
js_closure_alloc, create the runtime’s equivalent RuntimeHandleScope immediately
after allocation and register both buf and promise with it so they remain live
while the closure and captures are created.

---

Nitpick comments:
In `@test-files/test_gap_webcrypto_async_threadpool.ts`:
- Around line 1-11: Update the gap test’s Node runtime configuration to use the
pinned version from the project’s .node-version oracle, 26.5.0, instead of the
currently used v24.15.0. Keep the existing crypto timing assertions and test
logic unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 49de3376-b556-4836-948d-3d0a41c84f4d

📥 Commits

Reviewing files that changed from the base of the PR and between 987dc22 and f816863.

📒 Files selected for processing (3)
  • crates/perry-stdlib/src/crypto/random.rs
  • crates/perry-stdlib/src/webcrypto/digest.rs
  • test-files/test_gap_webcrypto_async_threadpool.ts

Comment thread crates/perry-stdlib/src/crypto/random.rs Outdated
Comment on lines +49 to +66
// 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Root heap allocations to prevent use-after-free.

The raw pointers buf and promise are left unrooted during the JS heap allocations for js_promise_new and js_closure_alloc. If a garbage collection occurs during these allocations, the unrooted objects may be swept, resulting in the closure capturing dangling pointers.

Please secure these pointers using a RuntimeHandleScope or the runtime's equivalent rooting mechanism immediately after allocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-stdlib/src/webcrypto/digest.rs` around lines 49 - 66, Root the
allocated Uint8Array and Promise in the digest flow before performing subsequent
heap allocations. In the code around webcrypto_digest_settle,
alloc_uint8array_from_slice, js_promise_new, and js_closure_alloc, create the
runtime’s equivalent RuntimeHandleScope immediately after allocation and
register both buf and promise with it so they remain live while the closure and
captures are created.

Ralph Küpper and others added 3 commits July 15, 2026 12:48
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).
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.
@proggeramlug

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit's callback-validation finding in 7a5dd4e86, but with an accurate characterization: it's hardening, not a crash fix.

The finding is real in the narrow sense — the guard was is_pointer(), which accepts a non-function object/array passed as the callback (randomBytes(n, {}), which Node rejects with ERR_INVALID_ARG_TYPE). The code's own comment even claimed "only a genuine closure is a schedulable callback," which is_pointer() doesn't enforce — a guard stricter in its comment than in its condition.

But it does not actually crash, because the timer's dispatch already self-defends. js_set_immediate_callback_args → the timer fires → js_closure_call2get_valid_func_ptr, which range-checks the address and rejects any pointer whose CLOSURE_TYPE_TAG isn't CLOSURE_MAGIC, returning null (no-op). I verified empirically on the pre-change is_pointer build:

randomBytes(16, {})     -> exit 0, "survived"
randomBytes(16, [1,2,3]) -> exit 0, "survived"
randomBytes(16, 42)      -> exit 0, "survived"

So no miscast-and-crash — the object pointer reads a valid offset, finds a non-magic tag, and no-ops. (This differs from an uninitialized-read bug like #6429; here the read target is always a mapped, valid allocation.)

The change switches the guard to is_closure_ptr — the same CLOSURE_MAGIC probe every other node-style-callback site uses (is_callable_value in streams, the sqlite/async-local-storage sites). It rejects a non-callable one layer earlier and makes the guard match its comment. Verified:

  • randomBytes(8, fn) still fires the callback asynchronously (the timing fix this PR is about — confirmed the sync path returns first).
  • randomBytes(16, {}) / [..] / 42 are safe no-ops.
  • The PR's own test_gap_webcrypto_async_threadpool is byte-identical to Node; fmt/file-size/gc-store gates pass.

Node throws ERR_INVALID_ARG_TYPE for a non-function callback where Perry silently no-ops (pre-dating this PR). Matching that throw across all the crypto async callbacks is a separate parity item, not this timing PR's job.

…ck 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.
@proggeramlug proggeramlug merged commit 1524454 into main Jul 15, 2026
26 checks passed
@proggeramlug proggeramlug deleted the fix/webcrypto-async-threadpool-timing branch July 15, 2026 22:55
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.

1 participant