diff --git a/src/jsc/bindings/node/crypto/JSCipher.cpp b/src/jsc/bindings/node/crypto/JSCipher.cpp index 02cb1e071a4..be0386ac01b 100644 --- a/src/jsc/bindings/node/crypto/JSCipher.cpp +++ b/src/jsc/bindings/node/crypto/JSCipher.cpp @@ -21,6 +21,11 @@ const JSC::ClassInfo JSCipher::s_info = { "Cipher"_s, &Base::s_info, nullptr, nu void JSCipher::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) { Base::finishCreation(vm); + + if (m_ctx) { + m_sizeForGC = sizeof(EVP_CIPHER_CTX); + vm.heap.reportExtraMemoryAllocated(this, m_sizeForGC); + } } template @@ -29,6 +34,8 @@ void JSCipher::visitChildrenImpl(JSCell* cell, Visitor& visitor) JSCipher* thisObject = uncheckedDowncast(cell); ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); + + visitor.reportExtraMemoryVisited(thisObject->m_sizeForGC); } DEFINE_VISIT_CHILDREN(JSCipher); diff --git a/src/jsc/bindings/node/crypto/JSCipher.h b/src/jsc/bindings/node/crypto/JSCipher.h index 75d105ae28d..aeda8eebfff 100644 --- a/src/jsc/bindings/node/crypto/JSCipher.h +++ b/src/jsc/bindings/node/crypto/JSCipher.h @@ -100,6 +100,7 @@ class JSCipher final : public JSC::JSDestructibleObject { char m_authTag[EVP_GCM_TLS_TAG_LEN]; bool m_pendingAuthFailed; int32_t m_maxMessageSize; + size_t m_sizeForGC { 0 }; private: JSCipher(JSC::VM& vm, JSC::Structure* structure, CipherKind kind, ncrypto::CipherCtxPointer&& ctx, std::optional authTagLen, int32_t maxMessageSize) diff --git a/src/jsc/bindings/node/crypto/JSCipherPrototype.cpp b/src/jsc/bindings/node/crypto/JSCipherPrototype.cpp index a5a2ee5f54a..76d84ddfee6 100644 --- a/src/jsc/bindings/node/crypto/JSCipherPrototype.cpp +++ b/src/jsc/bindings/node/crypto/JSCipherPrototype.cpp @@ -186,6 +186,7 @@ JSC_DEFINE_HOST_FUNCTION(jsCipherFinal, (JSC::JSGlobalObject * lexicalGlobalObje } cipher->m_ctx.reset(); + cipher->m_sizeForGC = 0; if (!ok) { throwCryptoErrorWithAuth(lexicalGlobalObject, scope); diff --git a/src/jsc/bindings/node/crypto/JSECDH.cpp b/src/jsc/bindings/node/crypto/JSECDH.cpp index 28d642c4e4e..5a2f1624007 100644 --- a/src/jsc/bindings/node/crypto/JSECDH.cpp +++ b/src/jsc/bindings/node/crypto/JSECDH.cpp @@ -19,6 +19,9 @@ const JSC::ClassInfo JSECDH::s_info = { "ECDH"_s, &Base::s_info, nullptr, nullpt void JSECDH::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) { Base::finishCreation(vm); + + m_sizeForGC = (EC_GROUP_get_degree(m_group) + 7) / 8; + vm.heap.reportExtraMemoryAllocated(this, m_sizeForGC); } template @@ -27,6 +30,8 @@ void JSECDH::visitChildrenImpl(JSCell* cell, Visitor& visitor) JSECDH* thisObject = uncheckedDowncast(cell); ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); + + visitor.reportExtraMemoryVisited(thisObject->m_sizeForGC); } point_conversion_form_t JSECDH::getFormat(JSC::JSGlobalObject* globalObject, JSC::ThrowScope& scope, JSC::JSValue formatValue) diff --git a/src/jsc/bindings/node/crypto/JSECDH.h b/src/jsc/bindings/node/crypto/JSECDH.h index 9b1594117be..2ca00ef0b90 100644 --- a/src/jsc/bindings/node/crypto/JSECDH.h +++ b/src/jsc/bindings/node/crypto/JSECDH.h @@ -45,6 +45,7 @@ class JSECDH final : public JSC::JSDestructibleObject { ncrypto::ECKeyPointer m_key; const EC_GROUP* m_group; + size_t m_sizeForGC { 0 }; JSC::EncodedJSValue getPublicKey(JSC::JSGlobalObject*, JSC::ThrowScope&, JSC::JSValue encodingValue, JSC::JSValue formatValue); diff --git a/src/jsc/bindings/node/crypto/JSHash.cpp b/src/jsc/bindings/node/crypto/JSHash.cpp index 6904da49c1d..ddcd131a043 100644 --- a/src/jsc/bindings/node/crypto/JSHash.cpp +++ b/src/jsc/bindings/node/crypto/JSHash.cpp @@ -46,6 +46,18 @@ void JSHash::finishCreation(JSC::VM& vm) Base::finishCreation(vm); } +template +void JSHash::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSHash* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + + visitor.reportExtraMemoryVisited(thisObject->m_sizeForGC); +} + +DEFINE_VISIT_CHILDREN(JSHash); + template JSC::GCClient::IsoSubspace* JSHash::subspaceFor(JSC::VM& vm) { @@ -94,6 +106,9 @@ bool JSHash::init(JSC::JSGlobalObject* globalObject, ThrowScope& scope, const EV m_mdLen = xofLen.value(); } + m_sizeForGC = sizeof(EVP_MD_CTX) + m_mdLen; + globalObject->vm().heap.reportExtraMemoryAllocated(this, m_sizeForGC); + return true; } @@ -114,6 +129,9 @@ bool JSHash::initZig(JSGlobalObject* globalObject, ThrowScope& scope, ExternZigH m_mdLen = xofLen.value(); } + m_sizeForGC = m_mdLen; + globalObject->vm().heap.reportExtraMemoryAllocated(this, m_sizeForGC); + return true; } diff --git a/src/jsc/bindings/node/crypto/JSHash.h b/src/jsc/bindings/node/crypto/JSHash.h index d6ee1b8fb78..a00075fbc23 100644 --- a/src/jsc/bindings/node/crypto/JSHash.h +++ b/src/jsc/bindings/node/crypto/JSHash.h @@ -25,6 +25,7 @@ class JSHash final : public JSC::JSDestructibleObject { static JSHash* create(JSC::VM& vm, JSC::Structure* structure); DECLARE_INFO; + DECLARE_VISIT_CHILDREN; template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm); @@ -52,6 +53,7 @@ class JSHash final : public JSC::JSDestructibleObject { Vector m_digestBuffer; ExternZigHash::Hasher* m_zigHasher { nullptr }; + size_t m_sizeForGC { 0 }; }; class JSHashPrototype final : public JSC::JSNonFinalObject { diff --git a/src/jsc/bindings/node/crypto/JSHmac.cpp b/src/jsc/bindings/node/crypto/JSHmac.cpp index 0bd9fed3367..c7e6b470787 100644 --- a/src/jsc/bindings/node/crypto/JSHmac.cpp +++ b/src/jsc/bindings/node/crypto/JSHmac.cpp @@ -44,6 +44,18 @@ void JSHmac::finishCreation(JSC::VM& vm) Base::finishCreation(vm); } +template +void JSHmac::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSHmac* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + + visitor.reportExtraMemoryVisited(thisObject->m_sizeForGC); +} + +DEFINE_VISIT_CHILDREN(JSHmac); + template JSC::GCClient::IsoSubspace* JSHmac::subspaceFor(JSC::VM& vm) { @@ -88,6 +100,9 @@ void JSHmac::init(JSC::JSGlobalObject* globalObject, ThrowScope& scope, const St throwCryptoError(globalObject, scope, ERR_get_error(), "Failed to initialize HMAC context"_s); return; } + + m_sizeForGC = sizeof(HMAC_CTX); + globalObject->vm().heap.reportExtraMemoryAllocated(this, m_sizeForGC); } bool JSHmac::update(std::span input) @@ -219,10 +234,12 @@ JSC_DEFINE_HOST_FUNCTION(jsHmacProtoFuncDigest, (JSC::JSGlobalObject * lexicalGl if (hmac->m_ctx) { if (!hmac->m_ctx.digestInto(&mdBuffer)) { hmac->m_ctx.reset(); + hmac->m_sizeForGC = 0; throwCryptoError(lexicalGlobalObject, scope, ERR_get_error(), "Failed to digest HMAC"_s); return {}; } hmac->m_ctx.reset(); + hmac->m_sizeForGC = 0; } // We shouldn't set finalized if coming from _flush, but this diff --git a/src/jsc/bindings/node/crypto/JSHmac.h b/src/jsc/bindings/node/crypto/JSHmac.h index 583fe2c9266..7c02b8bbb49 100644 --- a/src/jsc/bindings/node/crypto/JSHmac.h +++ b/src/jsc/bindings/node/crypto/JSHmac.h @@ -26,6 +26,7 @@ class JSHmac final : public JSC::JSDestructibleObject { static void destroy(JSC::JSCell* cell); DECLARE_INFO; + DECLARE_VISIT_CHILDREN; template static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm); @@ -44,6 +45,7 @@ class JSHmac final : public JSC::JSDestructibleObject { ncrypto::HMACCtxPointer m_ctx; bool m_finalized { false }; + size_t m_sizeForGC { 0 }; }; class JSHmacPrototype final : public JSC::JSNonFinalObject { diff --git a/src/jsc/bindings/node/crypto/JSKeyObject.cpp b/src/jsc/bindings/node/crypto/JSKeyObject.cpp index fecf27d7e52..e74f45052a8 100644 --- a/src/jsc/bindings/node/crypto/JSKeyObject.cpp +++ b/src/jsc/bindings/node/crypto/JSKeyObject.cpp @@ -17,6 +17,11 @@ const JSC::ClassInfo JSKeyObject::s_info = { "KeyObject"_s, &Base::s_info, nullp void JSKeyObject::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) { Base::finishCreation(vm); + + if (auto data = m_handle.data()) { + m_sizeForGC = sizeof(KeyObjectData) + data->symmetricKey.sizeInBytes() + data->asymmetricKey.size(); + vm.heap.reportExtraMemoryAllocated(this, m_sizeForGC); + } } template @@ -25,6 +30,8 @@ void JSKeyObject::visitChildrenImpl(JSCell* cell, Visitor& visitor) JSKeyObject* thisObject = uncheckedDowncast(cell); ASSERT_GC_OBJECT_INHERITS(thisObject, info()); Base::visitChildren(thisObject, visitor); + + visitor.reportExtraMemoryVisited(thisObject->m_sizeForGC); } DEFINE_VISIT_CHILDREN(JSKeyObject); diff --git a/src/jsc/bindings/node/crypto/JSKeyObject.h b/src/jsc/bindings/node/crypto/JSKeyObject.h index 5d7af7ae3bc..b7bcc99a6ca 100644 --- a/src/jsc/bindings/node/crypto/JSKeyObject.h +++ b/src/jsc/bindings/node/crypto/JSKeyObject.h @@ -57,6 +57,7 @@ class JSKeyObject : public JSC::JSDestructibleObject { static void destroy(JSC::JSCell* cell) { static_cast(cell)->~JSKeyObject(); } KeyObject m_handle; + size_t m_sizeForGC { 0 }; DECLARE_INFO; DECLARE_VISIT_CHILDREN; diff --git a/src/jsc/bindings/node/crypto/JSSign.cpp b/src/jsc/bindings/node/crypto/JSSign.cpp index 91d52748e04..3f796fafde2 100644 --- a/src/jsc/bindings/node/crypto/JSSign.cpp +++ b/src/jsc/bindings/node/crypto/JSSign.cpp @@ -66,6 +66,18 @@ void JSSign::finishCreation(JSC::VM& vm) Base::finishCreation(vm); } +template +void JSSign::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSSign* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + + visitor.reportExtraMemoryVisited(thisObject->m_sizeForGC); +} + +DEFINE_VISIT_CHILDREN(JSSign); + JSSign* JSSign::create(JSC::VM& vm, JSC::Structure* structure) { JSSign* sign = new (NotNull, JSC::allocateCell(vm)) JSSign(vm, structure); @@ -196,6 +208,11 @@ JSC_DEFINE_HOST_FUNCTION(jsSignProtoFuncInit, (JSC::JSGlobalObject * globalObjec // Store the initialized context in the JSSign object thisObject->m_mdCtx = WTF::move(mdCtx); + if (!thisObject->m_sizeForGC) { + thisObject->m_sizeForGC = sizeof(EVP_MD_CTX); + vm.heap.reportExtraMemoryAllocated(thisObject, thisObject->m_sizeForGC); + } + return JSC::JSValue::encode(JSC::jsUndefined()); } @@ -304,6 +321,7 @@ JSUint8Array* signWithKey(JSC::JSGlobalObject* lexicalGlobalObject, JSSign* this // Move mdCtx out of JSSign object ncrypto::EVPMDCtxPointer mdCtx = WTF::move(thisObject->m_mdCtx); + thisObject->m_sizeForGC = 0; // Validate DSA parameters if (!pkey.validateDsaParameters()) { diff --git a/src/jsc/bindings/node/crypto/JSSign.h b/src/jsc/bindings/node/crypto/JSSign.h index e067f3404bc..69414b6bef5 100644 --- a/src/jsc/bindings/node/crypto/JSSign.h +++ b/src/jsc/bindings/node/crypto/JSSign.h @@ -31,8 +31,10 @@ class JSSign final : public JSC::JSDestructibleObject { static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm); DECLARE_INFO; + DECLARE_VISIT_CHILDREN; ncrypto::EVPMDCtxPointer m_mdCtx; + size_t m_sizeForGC { 0 }; private: JSSign(JSC::VM& vm, JSC::Structure* structure); diff --git a/src/jsc/bindings/node/crypto/JSVerify.cpp b/src/jsc/bindings/node/crypto/JSVerify.cpp index 89ce5170a83..d5d3fd798ff 100644 --- a/src/jsc/bindings/node/crypto/JSVerify.cpp +++ b/src/jsc/bindings/node/crypto/JSVerify.cpp @@ -74,6 +74,18 @@ void JSVerify::finishCreation(JSC::VM& vm, JSC::JSGlobalObject* globalObject) Base::finishCreation(vm); } +template +void JSVerify::visitChildrenImpl(JSCell* cell, Visitor& visitor) +{ + JSVerify* thisObject = uncheckedDowncast(cell); + ASSERT_GC_OBJECT_INHERITS(thisObject, info()); + Base::visitChildren(thisObject, visitor); + + visitor.reportExtraMemoryVisited(thisObject->m_sizeForGC); +} + +DEFINE_VISIT_CHILDREN(JSVerify); + JSVerify* JSVerify::create(JSC::VM& vm, JSC::Structure* structure, JSC::JSGlobalObject* globalObject) { JSVerify* verify = new (NotNull, JSC::allocateCell(vm)) JSVerify(vm, structure); @@ -202,6 +214,11 @@ JSC_DEFINE_HOST_FUNCTION(jsVerifyProtoFuncInit, (JSGlobalObject * globalObject, // Store the initialized context in the JSVerify object thisObject->m_mdCtx = WTF::move(mdCtx); + if (!thisObject->m_sizeForGC) { + thisObject->m_sizeForGC = sizeof(EVP_MD_CTX); + vm.heap.reportExtraMemoryAllocated(thisObject, thisObject->m_sizeForGC); + } + return JSC::JSValue::encode(JSC::jsUndefined()); } @@ -368,6 +385,7 @@ JSC_DEFINE_HOST_FUNCTION(jsVerifyProtoFuncVerify, (JSGlobalObject * globalObject // Move mdCtx out of JSVerify object to finalize it ncrypto::EVPMDCtxPointer mdCtx = WTF::move(thisObject->m_mdCtx); + thisObject->m_sizeForGC = 0; // Validate DSA parameters if (!keyPtr.validateDsaParameters()) { diff --git a/src/jsc/bindings/node/crypto/JSVerify.h b/src/jsc/bindings/node/crypto/JSVerify.h index a7b85267cd8..1492b202396 100644 --- a/src/jsc/bindings/node/crypto/JSVerify.h +++ b/src/jsc/bindings/node/crypto/JSVerify.h @@ -30,8 +30,10 @@ class JSVerify final : public JSC::JSDestructibleObject { static JSC::GCClient::IsoSubspace* subspaceFor(JSC::VM& vm); DECLARE_INFO; + DECLARE_VISIT_CHILDREN; ncrypto::EVPMDCtxPointer m_mdCtx; + size_t m_sizeForGC { 0 }; private: JSVerify(JSC::VM& vm, JSC::Structure* structure); diff --git a/test/js/node/crypto/crypto-extra-memory.test.ts b/test/js/node/crypto/crypto-extra-memory.test.ts new file mode 100644 index 00000000000..cd6e3573c67 --- /dev/null +++ b/test/js/node/crypto/crypto-extra-memory.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe } from "harness"; + +// These tests verify that native node:crypto wrapper objects report their +// external memory usage to the GC via reportExtraMemoryAllocated and +// reportExtraMemoryVisited. Without this, the GC has no idea that e.g. a +// SecretKeyObject holding a 64KB key is keeping 64KB of native memory alive. + +async function extraMemoryDelta(setup: string, create: string, count: number): Promise { + const script = ` + const crypto = require("crypto"); + const { heapStats } = require("bun:jsc"); + ${setup} + + Bun.gc(true); + const before = heapStats().extraMemorySize; + + const live = []; + for (let i = 0; i < ${count}; i++) { + live.push(${create}); + } + + Bun.gc(true); + const after = heapStats().extraMemorySize; + process.stdout.write(String(after - before)); + // keep live referenced until after the measurement + if (live.length !== ${count}) throw new Error("unreachable"); + `; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", script], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const delta = Number(stdout); + expect({ stderr, exitCode, delta }).toEqual({ stderr: expect.any(String), exitCode: 0, delta: expect.any(Number) }); + expect(Number.isFinite(delta)).toBe(true); + return delta; +} + +describe.concurrent("node:crypto external memory is reported to the GC", () => { + test("SecretKeyObject reports symmetric key bytes", async () => { + const keySize = 64 * 1024; + const count = 100; + const delta = await extraMemoryDelta( + `const keyData = Buffer.alloc(${keySize}, 1);`, + `crypto.createSecretKey(Buffer.from(keyData))`, + count, + ); + // Each SecretKeyObject owns a copy of the key bytes. Allow generous + // headroom for baseline noise but require at least half the total. + expect(delta).toBeGreaterThan((keySize * count) / 2); + }); + + test("PublicKeyObject / PrivateKeyObject report asymmetric key size", async () => { + const count = 200; + const delta = await extraMemoryDelta( + `const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 }); + const pubDer = publicKey.export({ type: "spki", format: "der" }); + const privDer = privateKey.export({ type: "pkcs8", format: "der" });`, + `[crypto.createPublicKey({ key: pubDer, format: "der", type: "spki" }), crypto.createPrivateKey({ key: privDer, format: "der", type: "pkcs8" })]`, + count, + ); + // RSA-2048: EVP_PKEY_size is 256 bytes per key, 2 keys per iteration. + expect(delta).toBeGreaterThan(256 * 2 * count * 0.5); + }); + + test("Hash reports XOF outputLength", async () => { + const outputLength = 64 * 1024; + const count = 100; + const delta = await extraMemoryDelta(``, `crypto.createHash("shake256", { outputLength: ${outputLength} })`, count); + expect(delta).toBeGreaterThan((outputLength * count) / 2); + }); + + test("Hmac reports HMAC_CTX size", async () => { + const count = 1000; + const delta = await extraMemoryDelta(`const key = Buffer.alloc(32, 1);`, `crypto.createHmac("sha256", key)`, count); + // HMAC_CTX is 3 EVP_MD_CTX structs (each ~400B) plus overhead. + expect(delta).toBeGreaterThan(200 * count); + }); + + test("Cipher reports EVP_CIPHER_CTX size", async () => { + const count = 1000; + const delta = await extraMemoryDelta( + `const key = Buffer.alloc(32, 1); const iv = Buffer.alloc(16, 2);`, + `crypto.createCipheriv("aes-256-cbc", key, iv)`, + count, + ); + expect(delta).toBeGreaterThan(100 * count); + }); + + test("ECDH reports field size", async () => { + const count = 1000; + const delta = await extraMemoryDelta(``, `crypto.createECDH("prime256v1")`, count); + // prime256v1 field is 256 bits = 32 bytes. + expect(delta).toBeGreaterThan(16 * count); + }); + + test("Sign reports EVP_MD_CTX size", async () => { + const count = 1000; + const delta = await extraMemoryDelta(``, `crypto.createSign("sha256")`, count); + expect(delta).toBeGreaterThan(100 * count); + }); + + test("Verify reports EVP_MD_CTX size", async () => { + const count = 1000; + const delta = await extraMemoryDelta(``, `crypto.createVerify("sha256")`, count); + expect(delta).toBeGreaterThan(100 * count); + }); +});