Skip to content

crypto: drop generateKeyPair ctx's KeyObject ref before invoking the callback - #36657

Closed
robobun wants to merge 4 commits into
mainfrom
farm/9a7d0c03/keypair-exit-leak
Closed

crypto: drop generateKeyPair ctx's KeyObject ref before invoking the callback#36657
robobun wants to merge 4 commits into
mainfrom
farm/9a7d0c03/keypair-exit-leak

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

What

crypto.generateKeyPair's async completion (KeyPairJobCtx::runFromJS) kept m_keyObj (a RefPtr<KeyObjectData>) across the user callback. The Rust ExternCtx Drop that deletes the C++ ctx runs only after then() returns, so a callback that never returns (process.exit()) strands the ctx's ref. lastChanceToFinalize destroys the JSPublicKeyObject/JSPrivateKeyObject wrappers during VM teardown, but the stranded ctx ref keeps KeyObjectData (and the EVP_PKEY it owns) alive past the leak check.

This also routes the BoringSSL memory hooks through bun_alloc::default_alloc instead of hard-coding mimalloc. The ASAN build config already drops BORINGSSL_REQUIRE_MEMORY_HOOKS so BoringSSL falls back to libc ("Off under ASAN so BoringSSL allocs stay on the intercepted libc heap"), but on ELF the weak OPENSSL_memory_alloc/free/get_size symbols still resolved to our definitions, which called mi_malloc directly and kept every OPENSSL_malloc allocation invisible to LeakSanitizer on Linux. Routing through default_alloc makes them libc under cfg(bun_asan) everywhere. The crate-root bun_alloc::usable_size (a mi_usable_size wrapper whose only callers were these hooks) is deleted.

Reproduction

With the hook change alone:

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:419
  #7 Bun::KeyPairJobCtx::runTask src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp:23
  #8 Bun__RsaKeyPairJobCtx__runTask src/jsc/bindings/node/crypto/CryptoGenRsaKeyPair.cpp:26

from test/js/node/async_hooks/async-context/async-context-crypto-generateKeyPair.js, which calls process.exit(0) inside the callback. First seen as the only [new] failure on the x64-asan lane of build 86653 for #36598, which applies the same hook change for its own LSan visibility.

Cause

Ownership chain at the time the callback runs:

stack Box<AnyTaskJob>  (libc under ASAN)
  -> ExternCtx.ctx: *mut RsaKeyPairJobCtx  (TZone/FastMalloc, not LSan-tracked)
       -> m_keyObj.m_data: KeyObjectData*  (TZone/FastMalloc, not LSan-tracked)
            -> EVPKeyPointer.pkey_: EVP_PKEY*  (OPENSSL_malloc, now libc)

LSan scans the libc-backed AnyTaskJob box and the stack but can't follow through the FastMalloc blocks, so the EVP_PKEY is reported as a direct leak. The two JS key wrappers hold their own RefPtr<KeyObjectData> but live in JSC GC cells (also not scanned as a root).

Fix

runFromJS now assigns m_keyObj = {} once the JS values have been produced (and on the two export-failure paths before the error callback). With the ctx's ref gone, either ~VM -> lastChanceToFinalize destroying the JS wrappers brings the refcount to zero (KeyObject output), or the refcount is already zero (string/buffer output), and EVP_PKEY_free runs before LSan's atexit check.

SignJobCtx and DhJobCtx get the same reset for their RefPtr<KeyObjectData> fields (m_keyData, m_privateKey/m_publicKey). runTask has already produced the result and the ctx has no further use for the ref, so the reset is free.

Scope

Tested each of the other async crypto job contexts under BUN_DESTRUCT_VM_ON_EXIT=1 + detect_leaks=1 with process.exit(0) in the callback. With only the OPENSSL_memory_alloc hook applied and main's C++:

path result
generateKeyPair (KeyObject output) leaks 1480b / 35 allocs
generateKeyPair (PEM + passphrase output) leaks 1480b / 35 allocs
sign with PEM-string key no leak
diffieHellman no leak
generatePrime no leak
checkPrime no leak

Only generateKeyPair trips LSan: its EVP_PKEY* sits behind two FastMalloc indirections (ctx -> KeyObjectData* -> EVP_PKEY*) and runFromJS never loads the raw pointer onto the stack. The others hold their OPENSSL_malloc pointer either directly in the ctx or load it onto the runFromJS frame (for memcpy into the result buffer), so LSan's conservative stack scan finds it. The SignJobCtx/DhJobCtx resets are kept for consistency; HkdfJobCtx::m_key holds a symmetric Vector<uint8_t> (FastMalloc, not OPENSSL_malloc) and is left alone, as are GeneratePrimeJobCtx/CheckPrimeJobCtx and the KeyPairJobCtx passphrase, none of which are observable.

Verification

New isASAN-gated tests in crypto.key-objects.test.ts spawn a child with BUN_DESTRUCT_VM_ON_EXIT=1 and detect_leaks=1, generate an RSA key pair (once with KeyObject output, once with encrypted PEM output), and call process.exit(0) from the callback.

  • hook change without the m_keyObj reset: both cases fail with the 35-allocation LSan report above
  • both changes: both pass, child exits 0
  • crypto.test.ts (369 tests), crypto-rsa.test.js, crypto.key-objects.test.ts, the node test-crypto-keygen-* parallel suite, test-crypto-sign-verify.js, test-crypto-dh-stateless.js, and all async-context-crypto-* fixtures pass leak-clean

Fail-before note

The automated fail-before check stashes all of src/ and rebuilds. With the hook reverted to mimalloc, LSan cannot see the EVP_PKEY allocation at all, so the new tests pass trivially on main. The leak only becomes observable once OPENSSL_memory_alloc lands on libc, which is itself one of the src/ changes in this diff; the fail-before/pass-after demonstration above (stashing only CryptoGenKeyPair.cpp) is the closest mechanical proof available.


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

…callback

KeyPairJobCtx::runFromJS held m_keyObj (a RefPtr<KeyObjectData>) across the
user callback. The Rust ExternCtx Drop that deletes the ctx runs only after
the callback returns, so when the callback calls process.exit() the ctx's
ref is never released. lastChanceToFinalize destroys the JSPublicKeyObject/
JSPrivateKeyObject wrappers, but the stranded ctx ref keeps KeyObjectData
(and its EVP_PKEY) alive past the leak check.

With OPENSSL_memory_alloc hard-coded to mimalloc this was invisible to LSan;
route the BoringSSL memory hooks through default_alloc so under cfg(bun_asan)
they land on libc (matching the intent of dropping
BORINGSSL_REQUIRE_MEMORY_HOOKS in the ASAN build config, which previously
only took effect on Mach-O/COFF because ELF still resolved the weak hooks to
these definitions), and drop the ctx's ref once the JS wrappers own the key
so VM teardown frees it.
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

BoringSSL hooks now use default allocator APIs. Node crypto jobs release key references before JavaScript callback re-entry, including error paths. ASAN tests cover callback-triggered process exits for generated key objects and encrypted PEM outputs.

Changes

Memory cleanup

Layer / File(s) Summary
Default allocator integration
src/bun_alloc/lib.rs, src/boringssl/lib.rs
The BoringSSL hooks use default_alloc::malloc, usable_size, and free. The root-level mimalloc size helper was removed.
Crypto callback reference release
src/jsc/bindings/node/crypto/CryptoDhJob.cpp, src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp, src/jsc/bindings/node/crypto/CryptoSignJob.cpp
Crypto jobs clear stored key references before success and exception callbacks.
ASAN callback regression tests
test/js/node/crypto/crypto.key-objects.test.ts
ASAN subprocess tests cover process.exit() during key-generation callbacks and check outputs, exit status, and LeakSanitizer diagnostics.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the primary fix: releasing the generateKeyPair context's KeyObject reference before the callback.
Description check ✅ Passed The description explains the problem, fix, scope, and verification results, although its headings differ from the repository template.

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

@github-actions github-actions Bot added the claude label Aug 1, 2026
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced locally (1480b / 35 allocs via EVP_PKEY_keygen), fix verified leak-clean under BUN_DESTRUCT_VM_ON_EXIT=1 + detect_leaks=1. Swept the sibling async crypto job contexts; only generateKeyPair observably leaks (scope table in the PR body). Deleted the now-dead crate-root bun_alloc::usable_size. All review threads resolved.

CI build 87518: crypto.key-objects.test.ts and AsyncLocalStorage-tracking.test.ts pass on every lane including x64-asan. The only [new] failure is bun-upgrade.test.ts on windows-aarch64 ("Canary builds are not available for this platform yet"), unrelated to this diff and reported separately. Everything else is [flaky] or agent-creation infra. Ready for review.

Note on the automated fail-before check: it stashes all of src/, which reverts the OPENSSL_memory_alloc hook to mimalloc, and with BoringSSL allocations invisible to LSan the new tests pass trivially on main. Stashing only CryptoGenKeyPair.cpp (keeping the hook) reproduces the failure and the fix resolves it; details in the PR body.

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

Beyond the inline nit, I checked that default_alloc::{malloc,free,usable_size} are byte-identical to the previous direct mi_* calls under !cfg(bun_asan), so the BoringSSL hook change is a release no-op; and that lifting the #[cfg(any(debug_assertions, bun_asan))] gate on default_alloc::usable_size still compiles on Windows (the catch-all arm falls through to mi_usable_size).

Extended reasoning...

The inline nit (sibling SignJobCtx/DhJobCtx sharing the same hold-ref-across-callback pattern) is the only finding; it's an at-exit LSan report under a debug-only config with no CI coverage today, so it doesn't block. The two things I independently verified beyond it: (1) default_alloc::malloc/free/usable_size reduce to mi_malloc/mi_free/mi_usable_size when bun_asan is off, so release behavior of the OPENSSL memory hooks is unchanged; (2) removing the debug_assertions|bun_asan cfg gate on default_alloc::usable_size is safe on all check-all targets because the non-ASAN catch-all arm is mi_usable_size. Deferring to a human because the BoringSSL hook is process-global crypto/TLS allocator infrastructure and there's an open nit to address or acknowledge.

Comment thread src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp Outdated
…llback

Apply the same reset to the two sibling async crypto job contexts that hold
RefPtr<KeyObjectData> across the user callback. Unlike generateKeyPair these
take the key as an input that the caller already owns, and empirically do not
trip LSan under BUN_DESTRUCT_VM_ON_EXIT=1 without the reset, but runTask has
already produced the result and the ctx has no further use for the key ref.
Comment thread src/boringssl/lib.rs Outdated
Comment thread src/boringssl/lib.rs
Comment thread src/jsc/bindings/node/crypto/CryptoDhJob.cpp Outdated
Comment thread src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp Outdated
Comment thread src/jsc/bindings/node/crypto/CryptoSignJob.cpp Outdated
Comment thread src/boringssl/lib.rs
Comment thread src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Aug 1st, 2026

@robobun, your commit fffaa6b has 2 failures in Build #87518 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36657

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

bun-36657 --bun

Comment thread src/bun_alloc/lib.rs
Comment thread src/jsc/bindings/node/crypto/CryptoGenKeyPair.cpp Outdated
…EM output in the leak test

The only callers of the crate-root bun_alloc::usable_size were the two
BoringSSL hooks this branch rewired to default_alloc::usable_size; delete the
now-dead mimalloc-only wrapper so it cannot be picked up by accident.

The encrypted-PEM output path is the other generateKeyPair variant that
observably leaks without m_keyObj = {} before the callback (exportPrivate
returns a copied string, so the ctx ref going to zero is what frees the
EVP_PKEY). Cover it alongside the KeyObject-output case.

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

Both prior findings are addressed in fffaa6b — the orphaned crate-root bun_alloc::usable_size is deleted (and named in the description), and the Scope section now states the intentional exclusions (GeneratePrimeJobCtx/CheckPrimeJobCtx/the KeyPairJobCtx passphrase) with the empirical no-leak table. No new issues found. Deferring only because the OPENSSL_memory_* hook rewire touches the allocator every TLS/crypto path shares — worth one human glance even though non-ASAN builds reduce to the same mi_* calls after inlining.

Checked: default_alloc::{malloc,free,usable_size} are all pub and the non-ASAN cfg arm falls through to mi_usable_size, so release behavior is unchanged; the RefPtr resets in KeyPairJobCtx/SignJobCtx/DhJobCtx all sit after the last read of the cleared field; the new tests follow harness conventions (bunEnv spread, test.concurrent, pipes drained via Promise.all, isASAN-gated).

Extended reasoning...

Overview

Six files: (1) src/boringssl/lib.rs rewires OPENSSL_memory_alloc/free/get_size from hard-coded mi_* to bun_alloc::default_alloc::*; (2) src/bun_alloc/lib.rs promotes default_alloc::usable_size from #[cfg(any(debug_assertions, bun_asan))] pub(crate) to unconditional pub, and deletes the now-orphaned crate-root usable_size wrapper; (3–5) three async crypto job contexts (KeyPairJobCtx, SignJobCtx, DhJobCtx) clear their RefPtr<KeyObjectData> fields before invoking the user callback; (6) two ASAN-gated leak regression tests spawning a child that calls process.exit(0) inside the generateKeyPair callback under BUN_DESTRUCT_VM_ON_EXIT=1 + detect_leaks=1.

What changed since the last review

Commit fffaa6b addressed both open findings: the dead crate-root usable_size is removed and named in the description, and the PR body's Scope section now enumerates the sibling contexts I flagged with an empirical leak/no-leak table and explicitly states why GeneratePrimeJobCtx/CheckPrimeJobCtx/the passphrase are left alone (not LSan-observable — the raw pointer lands on the runFromJS frame or in the ctx directly, so the conservative stack scan finds it). The second new test exercises the encrypted-PEM path, covering the passphrase-carrying m_privateKeyEncoding.

Security risks

None identified. No cryptographic logic, key-material handling, or validation changes — only earlier release of refcounts the ctx destructor would drop anyway, plus allocator routing that is a no-op in release builds (default_alloc on the non-ASAN arm is mi_malloc/mi_free/mi_usable_size). The rewire matches the convention documented in src/CLAUDE.md ("route through bun_alloc::default_alloc rather than mi_* directly").

Level of scrutiny

Medium-high. The ref resets are trivially correct (each field is last read before the reset). The allocator hook is the piece that warrants a human look: it sits under every OPENSSL_malloc in the process (all TLS handshakes, every node:crypto op). I verified the non-ASAN path is behavior-identical, but a maintainer should confirm they're comfortable with default_alloc::usable_size losing its #[cfg] guard and becoming a stable public entry point.

Other factors

All prior review threads (mine, the comment-cop bot's) are resolved. The fail-before caveat is honestly documented in the PR body. Test coverage is appropriate for an LSan-visibility fix — gated on isASAN, concurrent, 30s timeout justified by the symbolizer note.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Main now hits this exact leak: #36598 landed the same memory hook change (6b6fb1a), which made the stranded EVP_PKEY visible to LeakSanitizer, and test/js/node/async_hooks/AsyncLocalStorage-tracking.test.ts now fails on every Linux x64-asan run. The async-context-crypto-generateKeyPair.js fixture calls process.exit(0) inside the generateKeyPair callback, producing the signature documented above: direct leak of 24b in run (src/runtime/node/node_crypto_binding.rs:85:21) +34 more, 1480 bytes in 35 allocations. Examples: build 89023 and build 89031.

Notes for landing this:

  • The branch now conflicts on src/boringssl/lib.rs and src/bun_alloc/lib.rs because main already contains that half of the diff via fetch: cache client TLS sessions for resumption #36598. Resolving those two files to main's side and keeping the CryptoGenKeyPair.cpp / CryptoDhJob.cpp / CryptoSignJob.cpp resets plus the tests is the whole rebase.
  • After the rebase the fail-before caveat in the PR body no longer applies: the new tests fail on current main as-is, since the hook change is already there.
  • The debian x64-asan lane on this PR ran green with both halves applied, and current main (hook half only) is red, so the two halves of the proof are already on record.

@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36986, which makes the job plumbing free the ctx before invoking the callback for all 11 job types instead of resetting individual ctx fields. The failing main test (AsyncLocalStorage-tracking on x64-asan) is covered there with the same fail-before signature.

dylan-conway pushed a commit that referenced this pull request Aug 6, 2026
…llback (#36986)

## 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](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; #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 #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.

<!-- 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 -->
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