Skip to content

node:crypto: free the async job ctx before invoking the completion callback - #36986

Merged
dylan-conway merged 7 commits into
mainfrom
farm/a58176bf/crypto-job-ctx-freed-before-callback
Aug 6, 2026
Merged

node:crypto: free the async job ctx before invoking the completion callback#36986
dylan-conway merged 7 commits into
mainfrom
farm/a58176bf/crypto-job-ctx-freed-before-callback

Conversation

@robobun

@robobun robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What

test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts (the crypto-generateKeyPair fixture) fails on every Linux x64-asan run since #36598 landed (builds 89023, 89031):

direct leak of 24b in run (src/runtime/node/node_crypto_binding.rs:85:21) +34 more
SUMMARY: AddressSanitizer: 1480 byte(s) leaked in 35 allocation(s).
  #6 EVP_PKEY_keygen vendor/boringssl/crypto/evp/evp_ctx.cc
  #7 Bun::KeyPairJobCtx::runTask src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp:23
  #8 Bun__RsaKeyPairJobCtx__runTask src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp:26

Cause

The 11 extern crypto job ctxs (generateKeyPair x5, sign/verify, diffieHellman, hkdf, generatePrime, checkPrime, generateKey) completed by invoking the JS callback from inside C++ runFromJS while the ctx was still alive; the ctx was freed only after then() returned. A callback that never returns (the fixture calls process.exit(0) inside it) stranded everything the ctx still owned: the generated EVP_PKEY, KeyObjectData refs, BIGNUMs.

The leak is pre-existing; #36598 made it observable by routing OPENSSL_malloc through libc under ASAN. Whether LSan reported the other job types too was codegen luck (their pointers happened to be reachable by the conservative stack scan); generateKeyPair's EVP_PKEY sits behind two FastMalloc indirections and was reported deterministically.

Fix

Make it structurally impossible for a job ctx to hold native resources across user JS: the native side never sees the callback.

  • runFromJS keeps its name (the JS-thread half, paired with the work-pool half runTask) but no longer receives the callback. It returns JSCallbackArgs, a small by-value type whose constructors are the only producers, so bodies read return { err }; or return { jsNull(), publicKey, privateKey };. The extern "C" shims copy it through a typed out-pointer (C linkage cannot return a class type); the Rust side consumes it as a slice.
  • The Rust extern_crypto_job! plumbing does, in order: run runFromJS to produce the arguments, free the ctx (ctx_deinit), invoke the callback. The invariant lives in one place and applies to every job type.
  • Shutdown release: a completion task enqueued but not yet dispatched when process.exit() runs (exit racing the work pool) used to be re-queued at shutdown, stranding the ctx the same way. AnyTaskJob now carries an erased release entry and the shutdown release frees the job without running its completion. A completion posted after the final drain is not recoverable without joining the work pool (which would block exit); test-crypto-op-during-process-exit.js stays in no-validate-leaksan.txt for that sliver, now with an accurate comment.
  • The caught-export-exception paths encoded the JSC::Exception cell itself, so the callback's err argument was not the thrown Error (not instanceof Error, no code). They now use Exception::value(), matching node: JWK export of an unsupported curve surfaces ERR_CRYPTO_JWK_UNSUPPORTED_CURVE.

No behavior change otherwise:

  • Bun__EventLoop__runCallback{1,2,3} were Rust's EventLoop::run_callback exported to C++. The plumbing now calls run_callback directly: same enter/exit bracketing, same pending-exception gate, same unhandled-exception reporting, same synchronous timing. This made runCallback1/runCallback3 dead (the crypto bodies were their last callers), so their exports and declarations are deleted; runCallback2 stays for the webview backends.
  • Callback arity is preserved per path (observable via arguments.length): error paths pass 1 arg, results 2, generateKeyPair success 3.
  • Exception paths are preserved: a throw out of argument production skips the callback and reports unhandled, as before. Each runFromJS checks its ThrowScope after every call that can throw (RETURN_IF_EXCEPTION), since the check that used to happen inside the nested runCallbackN call now happens after the C++ scope destructs; BUN_JSC_validateExceptionChecks verifies this on the asan lane.
  • The produced JSValues live on the then() stack frame between production and invocation, which JSC's conservative scan covers; they are JS-heap values, so freeing the ctx first cannot invalidate them.
  • Perf: same number of FFI crossings, no allocation added.

The Rust-native crypto jobs (pbkdf2, scrypt, random) already had the ordering property: they resolve promises or queue the callback via nextTick, so their ctx drops before user JS runs. The synchronous-callback extern jobs were the gap.

Verification

New tests in crypto.key-objects.test.ts:

  • isASAN-gated leak suite: children run with BUN_DESTRUCT_VM_ON_EXIT=1 and detect_leaks=1 (the asan lane's configuration) and call process.exit(0) from the callback of each job type: generateKeyPair (KeyObject and encrypted PEM outputs), sign, diffieHellman, hkdf, checkPrime, generateKey, plus an exit-before-completion-dispatch case (busy-spin so the queued completion is never dispatched).
  • An export-error test: generateKeyPair('ec', { namedCurve: 'secp224r1', ...jwk encodings }) asserts the callback err is instanceof Error with code ERR_CRYPTO_JWK_UNSUPPORTED_CURVE (matches node; fails on main, which passes the Exception cell).

Results:

  • unfixed build (src stashed): both generateKeyPair leak tests fail with the exact CI signature (Direct leak of 24 byte(s) in EVP_PKEY_keygen via KeyPairJobCtx::runTask)
  • fixed build: all pass, including under BUN_JSC_validateExceptionChecks=1, and ec/ed25519 keypair and verify probes run leak-clean as well
  • AsyncLocalStorage-tracking.test.ts: 74 pass, 0 fail (all async-context crypto fixtures, against both bun and node)
  • crypto.test.ts (369), crypto.key-objects.test.ts (117), and 37 node parallel files (test-crypto-keygen*, test-crypto-sign-verify, test-crypto-hkdf, test-crypto-dh-stateless, test-crypto-*prime*) all pass

The break landed with #36598 (which made the leak visible); #36657 proposed clearing individual ctx fields before the callback, and this PR supersedes that approach with the ordering guarantee in the job plumbing instead of per-field resets.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts test/js/node/crypto/crypto.key-objects.test.ts

…llback

The 11 extern crypto job ctxs (generateKeyPair x5, sign, diffieHellman,
hkdf, generatePrime, checkPrime, generateKey) invoked the JS callback
from inside runFromJS while the C++ ctx was still alive; the ctx was
only freed after then() returned. A callback that never returns
(process.exit()) stranded everything the ctx still owned, e.g. the
generated EVP_PKEY, which LeakSanitizer reports now that OPENSSL_malloc
is on the libc heap under ASAN (6b6fb1a).

Restructure the FFI contract so the C++ side never sees the callback:
Bun__<Name>Ctx__runFromJS(ctx, global, callback) becomes
Bun__<Name>Ctx__takeCallbackArgs(ctx, global, args[3]) -> argc. The
AnyTaskJob plumbing produces the arguments, frees the ctx, then invokes
the callback through the same event loop run_callback the C++ side
called before. The ctx cannot own anything across user JS by
construction, with unchanged invocation timing and semantics.
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:24 PM PT - Aug 5th, 2026

@robobun, your commit 31bff9ba0e23ce336113f8e786a1512d7cb100ec passed in Build #89349! 🎉


🧪   To try this PR locally:

bunx bun-pr 36986

That installs a local version of the PR into your bun-36986 executable, so you can run:

bun-36986 --bun

@github-actions github-actions Bot added the claude label Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1e938bfa-c984-4951-9b1b-84f3efa6202b

📥 Commits

Reviewing files that changed from the base of the PR and between 9f02418 and 31bff9b.

📒 Files selected for processing (1)
  • test/js/node/crypto/crypto.key-objects.test.ts

Walkthrough

Asynchronous Node crypto jobs now return callback arguments through native bridges. The runtime extracts them, cleans up native contexts before JavaScript execution, and releases queued jobs during shutdown. Tests cover error propagation and ASAN process-exit behavior.

Changes

Crypto callback argument extraction

Layer / File(s) Summary
Callback argument contract and bridges
src/jsc/bindings/node/crypto/JSCallbackArgs.h, src/jsc/bindings/node/crypto/Crypto*.h, src/jsc/bindings/node/crypto/CryptoGen*.cpp
Crypto job APIs and exported bridges now use JSCallbackArgs output structures.
Crypto completion results
src/jsc/bindings/node/crypto/Crypto*.cpp
Crypto jobs now return encoded errors or Node-style success arguments instead of invoking callbacks directly.
Runtime extraction and shutdown lifecycle
src/runtime/node/node_crypto_binding.rs, src/jsc/any_task_job.rs, src/runtime/dispatch.rs, src/jsc/event_loop.rs, src/jsc/bindings/headers-handwritten.h
The runtime extracts callback arguments, deinitializes native contexts before JavaScript callbacks, and releases queued jobs during shutdown.
Crypto completion and shutdown validation
test/js/node/crypto/crypto.key-objects.test.ts, test/no-validate-leaksan.txt
Tests cover key-export errors, concurrent asynchronous crypto operations, clean process exits, and the LeakSanitizer exclusion.

Possibly related PRs

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary change: freeing asynchronous crypto job contexts before completion callbacks.
Description check ✅ Passed The description explains the cause, implementation, behavior preservation, shutdown handling, and verification results in sufficient detail.
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.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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 `@src/jsc/bindings/node/crypto/CryptoDhJob.cpp`:
- Around line 49-60: Update the success path in the CryptoDhJob callback after
WebCore::createBuffer to check scope.exception() immediately, and if an
exception is pending, encode and return it through the callback contract as done
in CryptoGenKeyPair.cpp. Only assign args[1] and return the successful
two-argument result when buffer creation completed without an exception.

In `@src/runtime/node/node_crypto_binding.rs`:
- Around line 117-127: The Err branch in the callback dispatch match must still
invoke the removed callback when argument extraction fails. Update the
ctx_take_callback_args handling to convert err via global.take_exception(err),
then call run_callback with callback, global, and the converted exception as the
first argument instead of reporting it as unhandled; preserve the existing
successful argc path.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4ea7ccf8-f817-4ca1-8c62-184e2996b956

📥 Commits

Reviewing files that changed from the base of the PR and between 4c26d64 and 91ed76d.

📒 Files selected for processing (19)
  • src/jsc/bindings/node/crypto/CryptoDhJob.cpp
  • src/jsc/bindings/node/crypto/CryptoDhJob.h
  • src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenKeyPair.h
  • src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoHkdf.cpp
  • src/jsc/bindings/node/crypto/CryptoHkdf.h
  • src/jsc/bindings/node/crypto/CryptoKeygen.cpp
  • src/jsc/bindings/node/crypto/CryptoKeygen.h
  • src/jsc/bindings/node/crypto/CryptoPrimes.cpp
  • src/jsc/bindings/node/crypto/CryptoPrimes.h
  • src/jsc/bindings/node/crypto/CryptoSignJob.cpp
  • src/jsc/bindings/node/crypto/CryptoSignJob.h
  • src/runtime/node/node_crypto_binding.rs
  • test/js/node/crypto/crypto.key-objects.test.ts

Comment thread src/jsc/bindings/node/crypto/CryptoDhJob.cpp Outdated
Comment thread src/runtime/node/node_crypto_binding.rs Outdated
The exception check that used to happen inside the nested runCallbackN
call now happens on the Rust side after the C++ ThrowScope destructs,
so throw-sites in argument production (WebCore::createBuffer via
createUninitialized, error construction) tripped
BUN_JSC_validateExceptionChecks on the asan lane. Check the scope
immediately after each call that can throw; a pending exception still
skips the callback and is reported as unhandled, as before.
Comment thread src/runtime/node/node_crypto_binding.rs Outdated
Comment thread src/runtime/node/node_crypto_binding.rs Outdated
Comment thread src/runtime/node/node_crypto_binding.rs
Comment thread src/runtime/node/node_crypto_binding.rs
The crypto takeCallbackArgs rewiring removed the last C++ callers;
runCallback2 stays for the webview backends.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The dead-code deletion from my earlier note is done in 5c94f8b — thanks. No further findings, but this rewires the FFI contract and free/invoke ordering for all 11 extern crypto job types across the C++/Rust boundary, so it's worth a human pass.

Reviewed: the &mut [JSValue; 3]EncodedJSValue* ABI (thin pointer, matches); GC rooting of the produced args between deinit_ctx and run_callback (on the then stack frame, covered by JSC's conservative scan); the Drop null-guard vs. then's early free (no double-free); and that each takeCallbackArgs body preserves the pre-refactor arity and exception-pending-skips-callback behavior via RETURN_IF_EXCEPTION(scope, 0) + from_js_host_call_generic.

Extended reasoning...

Overview

This PR fixes an ASAN-visible leak in the async node:crypto jobs (generateKeyPair, sign/verify, diffieHellman, hkdf, generatePrime/checkPrime, generateKey) by restructuring the FFI contract: instead of C++ invoking the JS callback directly via Bun__EventLoop__runCallbackN while the job ctx is still alive, C++ now writes the callback arguments into a caller-provided EncodedJSValue[3] and returns argc. The Rust extern_crypto_job! plumbing then frees the ctx before calling event_loop.run_callback, so a callback that never returns (process.exit()) no longer strands the ctx's OpenSSL allocations. 21 files: 11 C++ ctx bodies + headers, the Rust macro in node_crypto_binding.rs, deletion of the now-dead runCallback1/runCallback3 exports in event_loop.rs and headers-handwritten.h, and new ASAN-gated leak tests.

Security risks

The change is in node:crypto but is a resource-lifetime refactor, not a change to key generation, signing, or verification logic. The produced JS values (KeyObjects, Buffers, booleans) are identical; only when the native ctx is freed moves. No new user-controlled input paths, no validation weakened. The one subtle correctness surface is GC: the args array holds unrooted JSValues across the ctx_deinit FFI call. JSC's conservative stack scanner covers the Rust then() frame, and ctx_deinit does not allocate on the JS heap or trigger GC (it's delete this on a TZONE-allocated C++ struct), so this is sound — but it's exactly the kind of invariant a human reviewer familiar with Bun's JSC integration should sign off on.

Level of scrutiny

High. This is an FFI contract change applied uniformly across 11 job types, with a lifecycle reordering (free-before-user-JS) that the whole PR exists to establish. Each C++ body was hand-rewritten from runFromJS to takeCallbackArgs, and each needed its own RETURN_IF_EXCEPTION placement to satisfy validateExceptionChecks now that the scope check no longer happens inside a nested runCallbackN. The PR description is unusually thorough (traces the exact leak, explains why #36598 made it visible, enumerates behavior-preservation claims, lists verification runs), and the follow-up commits (9ba4326, 5c94f8b) show the author responding correctly to review — but the breadth and the memory-safety stakes put this outside auto-approval.

Other factors

My earlier inline note (delete the newly-dead runCallback1/3 exports) was addressed in 5c94f8b and named in the description. CodeRabbit's two findings were discussed and either withdrawn or addressed via the RETURN_IF_EXCEPTION commit; the comment-cop flags on the Rust comments were dismissed with a reasonable justification (they document the FFI contract, not a workaround). The new tests are properly ASAN-gated, spawn per case with test.concurrent, drain both pipes, and assert the combined {stdout, stderr, exitCode} object per REVIEW.md conventions. The SignJobCtx trailing return 0 after an exhaustive switch is dead but harmless (compiler placation). I checked the &mut [JSValue; 3] Rust parameter against the C++ EncodedJSValue* signature — a reference to a fixed-size array is a thin pointer on the C ABI, so they match.

… callbacks

Two issues found reviewing the completion rewiring:

Shutdown release: a completion task enqueued but not yet dispatched when
process.exit() runs (exit racing the work pool) was re-queued by
release_queued_tasks_for_shutdown, stranding the ctx's OpenSSL memory
behind LSan-invisible allocations: the same 1480-byte signature the
callback-path fix closed. AnyTaskJob now carries an erased release
entry and the shutdown release claims the tag, freeing the job without
running its completion. A completion posted after the final drain (the
work pool cannot be joined without blocking exit) remains quarantined
in no-validate-leaksan.txt, now with an accurate comment.

Error value: the caught-export-exception paths encoded the
JSC::Exception cell itself, so the callback's err argument was not the
thrown Error (not instanceof Error, no code property). Use
Exception::value(), matching node, which surfaces
ERR_CRYPTO_JWK_UNSUPPORTED_CURVE for JWK export of an unsupported
curve. Same fix in GeneratePrimeJobCtx.
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/any_task_job.rs Outdated
Comment thread src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp Outdated
Comment thread src/jsc/event_loop.rs Outdated
Comment thread src/runtime/dispatch.rs Outdated
… array

Restore the runTask/runFromJS naming (work-pool half / JS-thread half)
and replace the EncodedJSValue* out-array plus count with a
JSCallbackArgs value type. Constructors are the only producers, so
bodies read as return { err } or return { jsNull(), publicKey,
privateKey }, and the Rust side consumes it as a slice. The extern
shims copy it through a typed out-pointer since C linkage cannot
return a class type.
Comment thread src/jsc/any_task_job.rs
Comment thread src/jsc/bindings/node/crypto/JSCallbackArgs.h
Comment thread src/jsc/event_loop.rs
Comment thread src/runtime/dispatch.rs
Comment thread src/runtime/node/node_crypto_binding.rs
Comment thread src/runtime/node/node_crypto_binding.rs
Comment thread src/runtime/node/node_crypto_binding.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No bugs found, but this deserves human review: it restructures the FFI completion path for all 11 async crypto job types, adds a mirrored C++/Rust ABI struct (JSCallbackArgs), a second type-erased entry on AnyTaskJob with hand-computed offset dispatch, and a new shutdown-release path — memory-safety-critical native code across 25 files.

What was reviewed:

  • JSCallbackArgs C++/Rust layout mirroring (3×EncodedJSValue + u32) and the .min(argv.len()) clamp on argc.
  • release_erased offset math (.add(1) on fn(*mut ())) against the offset_of! assertion; drop path frees ctx + callback via ExternCtx::Drop.
  • GC safety of the stack-held JsCallbackArgs between ctx_run_from_js and run_callback (only ctx_deinit runs in between; no JS allocation).
  • Exception-scope discipline (RETURN_IF_EXCEPTION after each throwing production call) and that the pending-exception path still skips the callback via from_js_host_call_generic returning Err.
Extended reasoning...

Overview

This PR restructures how the 11 C++-backed async node:crypto jobs (generateKeyPair×5, sign/verify, diffieHellman, hkdf, generatePrime, checkPrime, generateKey) deliver their completion. Previously each C++ runFromJS invoked the JS callback directly via Bun__EventLoop__runCallbackN while the native ctx was still alive; a callback that never returned (process.exit()) stranded the ctx's owned resources (EVP_PKEY, KeyObjectData, BIGNUMs), which #36598 made ASAN-visible. The fix moves callback invocation to the Rust extern_crypto_job! plumbing: C++ runFromJS now returns a JSCallbackArgs value (new header, mirrored as a #[repr(C)] Rust struct) through an out-pointer, Rust frees the ctx, then invokes run_callback. A second change adds a type-erased release_erased entry to AnyTaskJob so __bun_release_task_at_shutdown can free queued-but-undispatched completions at VM shutdown. A drive-by fix corrects the caught-exception paths (generateKeyPair, generatePrime) to pass Exception::value() (the Error) rather than the Exception cell, matching Node. Dead Bun__EventLoop__runCallback1/3 exports are deleted (my earlier comment, addressed in 5c94f8b).

Security risks

None identified. This is an internal lifecycle/ordering refactor; no new user-controlled input parsing, no changes to key generation or validation logic, no TLS/auth surface. The ABI mirroring is the risk surface — a layout mismatch would be memory corruption, not a security bypass.

Level of scrutiny

High. This is native memory-safety code at the Rust↔C++ FFI boundary with:

  • A hand-mirrored ABI struct (JSCallbackArgsJsCallbackArgs) whose layout must match exactly across compilers.
  • A second type-erased fn-pointer slot on AnyTaskJob<C> reached by raw pointer arithmetic (.cast::<fn(*mut ())>().add(1)), guarded only by a const _ offset assertion.
  • A new shutdown-release path that frees jobs whose Drop touches JSC handles (StrongOptional::deinit) while the VM is tearing down.
  • GC-lifetime reasoning that stack-held JSValues survive across ctx_deinit (correct — conservative stack scan covers them and ctx_deinit doesn't allocate JS — but subtle).
  • A behavior change (Exception cell → ->value()) piggybacked on the refactor.

REVIEW.md's memory-safety section is directly on point ("pair every acquisition with its release at the acquisition site", "reference counts provably balanced on every terminal path", "never let a pointer or slice outlive the memory it points into"). I checked each of these and found no issues, but the combination of FFI ABI, type erasure, and shutdown lifecycle across 25 files is exactly the kind of change where a maintainer familiar with AnyTaskJob's invariants and the VM shutdown sequence should sign off.

Other factors

  • All prior review threads (CodeRabbit, comment-cop, my dead-code note) are resolved; the author addressed them with follow-up commits (9ba4326, 5c94f8b, a78c019, c6a6990).
  • Test coverage is thorough: an isASAN-gated leak suite covering every job type + the shutdown-queue path, and a Node-parity test for the Exception::value() fix. The PR description documents that the leak tests fail on the unfixed build with the exact CI signature.
  • The SignJobCtx::runFromJS trailing return {}; after the exhaustive switch is unreachable (both cases return) — compiler placation, not a latent zero-arg-callback path.
  • The no-validate-leaksan.txt entry for test-crypto-op-during-process-exit.js is retained with an accurate comment explaining the unfixable work-pool race sliver.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/node/node_crypto_binding.rs (1)

129-148: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Pass callback-argument extraction errors to the completion callback.

try_swap() has already removed callback. The Err branch reports the exception as unhandled and never invokes that callback. Promisified crypto operations can remain pending.

Convert err with global.take_exception(err). Then call run_callback with the converted error as argument zero.

Proposed fix
-                        Err(err) => global.report_active_exception_as_unhandled(err),
+                        Err(err) => {
+                            let error = global.take_exception(err);
+                            global.bun_vm().event_loop_mut().run_callback(
+                                callback,
+                                global,
+                                JSValue::UNDEFINED,
+                                &[error],
+                            );
+                        }

As per coding guidelines, “Never swallow failures or signal success after failure; propagate … errors explicitly.”

🤖 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 `@src/runtime/node/node_crypto_binding.rs` around lines 129 - 148, Update the
Err branch after ctx deinitialization in the callback flow to convert err with
global.take_exception(err), then invoke run_callback using the removed callback
and the converted error as argument zero instead of reporting the exception as
unhandled. Preserve the existing successful callback path and ensure the
completion callback is always signaled on callback-argument extraction failure.

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.

Outside diff comments:
In `@src/runtime/node/node_crypto_binding.rs`:
- Around line 129-148: Update the Err branch after ctx deinitialization in the
callback flow to convert err with global.take_exception(err), then invoke
run_callback using the removed callback and the converted error as argument zero
instead of reporting the exception as unhandled. Preserve the existing
successful callback path and ensure the completion callback is always signaled
on callback-argument extraction failure.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f8115bf0-15ca-4709-b0d0-3b57a4b516c6

📥 Commits

Reviewing files that changed from the base of the PR and between a78c019 and 9f02418.

📒 Files selected for processing (23)
  • src/jsc/any_task_job.rs
  • src/jsc/bindings/node/crypto/CryptoDhJob.cpp
  • src/jsc/bindings/node/crypto/CryptoDhJob.h
  • src/jsc/bindings/node/crypto/CryptoGenDhKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenDsaKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenEcKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenKeyPair.h
  • src/jsc/bindings/node/crypto/CryptoGenNidKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp
  • src/jsc/bindings/node/crypto/CryptoHkdf.cpp
  • src/jsc/bindings/node/crypto/CryptoHkdf.h
  • src/jsc/bindings/node/crypto/CryptoKeygen.cpp
  • src/jsc/bindings/node/crypto/CryptoKeygen.h
  • src/jsc/bindings/node/crypto/CryptoPrimes.cpp
  • src/jsc/bindings/node/crypto/CryptoPrimes.h
  • src/jsc/bindings/node/crypto/CryptoSignJob.cpp
  • src/jsc/bindings/node/crypto/CryptoSignJob.h
  • src/jsc/bindings/node/crypto/JSCallbackArgs.h
  • src/jsc/event_loop.rs
  • src/runtime/dispatch.rs
  • src/runtime/node/node_crypto_binding.rs
  • test/js/node/crypto/crypto.key-objects.test.ts

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Re the repeated suggestion to invoke the callback from the Err branch in node_crypto_binding.rs: declining for the same reason as the earlier resolved thread. That branch only fires for exceptions the C++ body left pending, and those never reached the callback before this PR either (Bun__EventLoop__runCallbackN opened with a has_exception gate, so the invoke was a no-op and the same unhandled report fired). Exceptions the bodies catch are still delivered as the err argument through the normal path. Invoking the callback with a pending-exception error would be new behavior on OOM-class paths, and would diverge from node, which surfaces result-conversion failures as uncaught exceptions rather than callback errors.

Comment thread test/js/node/crypto/crypto.key-objects.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't find any bugs, and both of my earlier nits (dead runCallback1/3 exports; generatePrime in the leak matrix) have been addressed. Deferring to a human because this is a 25-file refactor of memory-safety-critical FFI plumbing — the free-before-user-JS ordering, the new erased shutdown-release path in AnyTaskJob, and the cross-language JSCallbackArgs layout contract all warrant a maintainer's eyes.

What was reviewed:

  • Rust↔C++ JsCallbackArgs layout parity (#[repr(C)] [JSValue;3]+u32 vs EncodedJSValue[3]+uint32_t) and the .min(argv.len()) slice bound.
  • GC safety of the produced JSValues living on the then() stack frame after ctx_deinit and before run_callback — they're stack-rooted for the conservative scan.
  • release_erased's .add(1) fn-pointer read is guarded by the offset_of! const-assert; the new task_tag::AnyTaskJob shutdown arm affects all AnyTaskJob users (pbkdf2/scrypt/random too), whose Drop impls look safe to run at that point.
  • RETURN_IF_EXCEPTION after every throwing production call in the rewritten runFromJS bodies; the Exception::value() fix has a dedicated test.
Extended reasoning...

Overview

This PR restructures the completion path for the 11 extern C++ crypto async jobs (generateKeyPair ×5, sign/verify, diffieHellman, hkdf, generatePrime, checkPrime, generateKey) so the native ctx is freed before the JS completion callback runs. The C++ runFromJS bodies no longer receive or invoke the callback; instead they return a small by-value JSCallbackArgs struct through an out-pointer, and the Rust extern_crypto_job! macro's then() body does runFromJS → deinit ctx → run_callback. It also adds an erased release entry to AnyTaskJob so queued-but-undispatched completions are freed at VM shutdown, deletes the now-dead Bun__EventLoop__runCallback1/3 exports, and fixes a pre-existing bug where the caught-export-exception paths passed the JSC::Exception cell (not the thrown Error) to the callback. 25 files: 4 Rust, 14 C++ (headers + bodies), 1 new C++ header, tests, and no-validate-leaksan.txt.

Security risks

None identified. This is internal resource-lifecycle plumbing; no user-input parsing, auth, or crypto-primitive changes. The Exception::value() change surfaces a more useful error object (with .code) to userland, matching Node.

Level of scrutiny

High. This is exactly the memory-safety category REVIEW.md calls out most heavily: cross-language FFI ownership, ctx lifecycle across work-pool → JS-thread handoff, unsafe Rust with hand-verified layout invariants (the offset_of! const-asserts and .cast::<fn(*mut ())>().add(1) read), GC-rooting reasoning for JSValues held between ctx free and callback invocation, and a new shutdown-release path that applies to every AnyTaskJob user (not only crypto). The change is well-reasoned and thoroughly tested (ASAN-gated leak suite covering all 7 ctx classes + a shutdown-race case + a node-parity behavior test), but the surface area and the class of bug it targets (UAF/leak on non-returning callbacks) mean a maintainer should sign off on the design and the unsafe-block invariants.

Other factors

  • Both of my prior inline nits were addressed (5c94f8b deleted the dead runCallback1/3 exports; 31bff9b added generatePrime to the leak matrix).
  • The SignJobCtx::runFromJS fall-through return {} after the exhaustive switch is unreachable (both enum arms return); it exists to satisfy the compiler now that the function has a non-void return type — harmless.
  • The dispatch.rs shutdown arm now frees AnyTaskJob entries instead of re-queuing them; I checked that the Rust-native CryptoJob ctxs (Scrypt, random::JobCtx, PBKDF2's CallbackCtx) all have Drop/deinit paths that don't require the completion to have run, so this broader change looks safe — but it's a behavior change beyond the crypto extern jobs and worth a second look.
  • CodeRabbit's finding was withdrawn after discussion; the comment-cop bot flags were dismissed as documenting FFI contracts, not workarounds.

@dylan-conway
dylan-conway merged commit 17d6843 into main Aug 6, 2026
54 checks passed
@dylan-conway
dylan-conway deleted the farm/a58176bf/crypto-job-ctx-freed-before-callback branch August 6, 2026 00:27
springmin pushed a commit to springmin/bun that referenced this pull request Aug 6, 2026
…llback (oven-sh#36986)

## What

`test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts` (the
crypto-generateKeyPair fixture) fails on every Linux x64-asan run since
oven-sh#36598 landed (builds
[89023](https://buildkite.com/bun/bun/builds/89023),
[89031](https://buildkite.com/bun/bun/builds/89031)):

```
direct leak of 24b in run (src/runtime/node/node_crypto_binding.rs:85:21) +34 more
SUMMARY: AddressSanitizer: 1480 byte(s) leaked in 35 allocation(s).
  #6 EVP_PKEY_keygen vendor/boringssl/crypto/evp/evp_ctx.cc
  #7 Bun::KeyPairJobCtx::runTask src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp:23
  #8 Bun__RsaKeyPairJobCtx__runTask src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp:26
```

## Cause

The 11 extern crypto job ctxs (generateKeyPair x5, sign/verify,
diffieHellman, hkdf, generatePrime, checkPrime, generateKey) completed
by invoking the JS callback from inside C++ `runFromJS` while the ctx
was still alive; the ctx was freed only after `then()` returned. A
callback that never returns (the fixture calls `process.exit(0)` inside
it) stranded everything the ctx still owned: the generated `EVP_PKEY`,
`KeyObjectData` refs, `BIGNUM`s.

The leak is pre-existing; oven-sh#36598 made it observable by routing
`OPENSSL_malloc` through libc under ASAN. Whether LSan reported the
other job types too was codegen luck (their pointers happened to be
reachable by the conservative stack scan); `generateKeyPair`'s
`EVP_PKEY` sits behind two FastMalloc indirections and was reported
deterministically.

## Fix

Make it structurally impossible for a job ctx to hold native resources
across user JS: the native side never sees the callback.

- `runFromJS` keeps its name (the JS-thread half, paired with the
work-pool half `runTask`) but no longer receives the callback. It
returns `JSCallbackArgs`, a small by-value type whose constructors are
the only producers, so bodies read `return { err };` or `return {
jsNull(), publicKey, privateKey };`. The extern "C" shims copy it
through a typed out-pointer (C linkage cannot return a class type); the
Rust side consumes it as a slice.
- The Rust `extern_crypto_job!` plumbing does, in order: run `runFromJS`
to produce the arguments, free the ctx (`ctx_deinit`), invoke the
callback. The invariant lives in one place and applies to every job
type.
- Shutdown release: a completion task enqueued but not yet dispatched
when `process.exit()` runs (exit racing the work pool) used to be
re-queued at shutdown, stranding the ctx the same way. `AnyTaskJob` now
carries an erased release entry and the shutdown release frees the job
without running its completion. A completion posted after the final
drain is not recoverable without joining the work pool (which would
block exit); `test-crypto-op-during-process-exit.js` stays in
`no-validate-leaksan.txt` for that sliver, now with an accurate comment.
- The caught-export-exception paths encoded the `JSC::Exception` cell
itself, so the callback's err argument was not the thrown Error (not
`instanceof Error`, no `code`). They now use `Exception::value()`,
matching node: JWK export of an unsupported curve surfaces
`ERR_CRYPTO_JWK_UNSUPPORTED_CURVE`.

No behavior change otherwise:

- `Bun__EventLoop__runCallback{1,2,3}` were Rust's
`EventLoop::run_callback` exported to C++. The plumbing now calls
`run_callback` directly: same enter/exit bracketing, same
pending-exception gate, same unhandled-exception reporting, same
synchronous timing. This made `runCallback1`/`runCallback3` dead (the
crypto bodies were their last callers), so their exports and
declarations are deleted; `runCallback2` stays for the webview backends.
- Callback arity is preserved per path (observable via
`arguments.length`): error paths pass 1 arg, results 2, generateKeyPair
success 3.
- Exception paths are preserved: a throw out of argument production
skips the callback and reports unhandled, as before. Each `runFromJS`
checks its `ThrowScope` after every call that can throw
(`RETURN_IF_EXCEPTION`), since the check that used to happen inside the
nested `runCallbackN` call now happens after the C++ scope destructs;
`BUN_JSC_validateExceptionChecks` verifies this on the asan lane.
- The produced `JSValue`s live on the `then()` stack frame between
production and invocation, which JSC's conservative scan covers; they
are JS-heap values, so freeing the ctx first cannot invalidate them.
- Perf: same number of FFI crossings, no allocation added.

The Rust-native crypto jobs (pbkdf2, scrypt, random) already had the
ordering property: they resolve promises or queue the callback via
nextTick, so their ctx drops before user JS runs. The
synchronous-callback extern jobs were the gap.

## Verification

New tests in `crypto.key-objects.test.ts`:

- `isASAN`-gated leak suite: children run with
`BUN_DESTRUCT_VM_ON_EXIT=1` and `detect_leaks=1` (the asan lane's
configuration) and call `process.exit(0)` from the callback of each job
type: generateKeyPair (KeyObject and encrypted PEM outputs), sign,
diffieHellman, hkdf, checkPrime, generateKey, plus an
exit-before-completion-dispatch case (busy-spin so the queued completion
is never dispatched).
- An export-error test: `generateKeyPair('ec', { namedCurve:
'secp224r1', ...jwk encodings })` asserts the callback err is
`instanceof Error` with code `ERR_CRYPTO_JWK_UNSUPPORTED_CURVE` (matches
node; fails on main, which passes the Exception cell).

Results:

- unfixed build (src stashed): both generateKeyPair leak tests fail with
the exact CI signature (`Direct leak of 24 byte(s)` in `EVP_PKEY_keygen`
via `KeyPairJobCtx::runTask`)
- fixed build: all pass, including under
`BUN_JSC_validateExceptionChecks=1`, and ec/ed25519 keypair and verify
probes run leak-clean as well
- `AsyncLocalStorage-tracking.test.ts`: 74 pass, 0 fail (all
async-context crypto fixtures, against both bun and node)
- `crypto.test.ts` (369), `crypto.key-objects.test.ts` (117), and 37
node parallel files (`test-crypto-keygen*`, `test-crypto-sign-verify`,
`test-crypto-hkdf`, `test-crypto-dh-stateless`, `test-crypto-*prime*`)
all pass

The break landed with oven-sh#36598 (which made the leak visible); oven-sh#36657
proposed clearing individual ctx fields before the callback, and this PR
supersedes that approach with the ordering guarantee in the job plumbing
instead of per-field resets.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts
test/js/node/crypto/crypto.key-objects.test.ts

<!-- robobun:evidence:end -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants