From ce94bb8b0c07c3665ba3f48be28aa8e9ab0f8015 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Tue, 25 Aug 2026 13:39:59 -0400 Subject: [PATCH 1/3] src: avoid atexit() for ResetStdio atexit() calls dladdr() to find the image that owns the callback. dladdr() performs an expensive linear scan of the executable's symbol table, so we now avoid it entirely. Signed-off-by: Aviv Keller --- src/node.cc | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/node.cc b/src/node.cc index 7d51cbd5d0c7..0ad0276a90c2 100644 --- a/src/node.cc +++ b/src/node.cc @@ -533,7 +533,17 @@ static void PlatformInit(ProcessInitializationFlags::Flags flags) { init_process_flags.store(flags); if (!(flags & ProcessInitializationFlags::kNoStdioInitialization)) { - atexit(ResetStdio); + // Arrange for ResetStdio() to run at exit. A function-local static with a + // destructor is used instead of atexit() because on macOS atexit() calls + // dladdr() to locate the image that owns the callback, which does a linear + // scan of the (very large) symbol table and costs about a millisecond of + // startup time. Static destructors and atexit() handlers are registered in + // the same list and run in reverse order of registration, so the ordering + // relative to other handlers is unchanged. + static const struct ResetStdioAtExit { + ~ResetStdioAtExit() { ResetStdio(); } + } reset_stdio_at_exit; + (void)reset_stdio_at_exit; } #ifdef __POSIX__ From a1ba04638105489432975f5da6559301aaa73081 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Tue, 25 Aug 2026 13:39:59 -0400 Subject: [PATCH 2/3] crypto: resolve ciphers lazily The Cipher::AES_* and Cipher::CHACHA20_POLY1305 constants were initialized by static initializers while the executable is still being loaded. Lazily-load them now, so they are only init'd on first use. Signed-off-by: Aviv Keller --- deps/ncrypto/ncrypto.cc | 46 +++++++++++++++++--------- deps/ncrypto/ncrypto.h | 35 ++++++++++---------- src/crypto/crypto_aes.h | 30 ++++++++--------- src/crypto/crypto_chacha20_poly1305.cc | 2 +- src/crypto/crypto_context.cc | 15 ++++++--- 5 files changed, 74 insertions(+), 54 deletions(-) diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index fb7446578f57..cda04438fdc4 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -4486,26 +4486,40 @@ const Cipher Cipher::FromCtx(const CipherCtxPointer& ctx) { } const Cipher Cipher::EMPTY = Cipher(); -const Cipher Cipher::AES_128_CBC = Cipher::FromNid(NID_aes_128_cbc); -const Cipher Cipher::AES_192_CBC = Cipher::FromNid(NID_aes_192_cbc); -const Cipher Cipher::AES_256_CBC = Cipher::FromNid(NID_aes_256_cbc); -const Cipher Cipher::AES_128_CTR = Cipher::FromNid(NID_aes_128_ctr); -const Cipher Cipher::AES_192_CTR = Cipher::FromNid(NID_aes_192_ctr); -const Cipher Cipher::AES_256_CTR = Cipher::FromNid(NID_aes_256_ctr); -const Cipher Cipher::AES_128_GCM = Cipher::FromNid(NID_aes_128_gcm); -const Cipher Cipher::AES_192_GCM = Cipher::FromNid(NID_aes_192_gcm); -const Cipher Cipher::AES_256_GCM = Cipher::FromNid(NID_aes_256_gcm); -const Cipher Cipher::AES_128_KW = Cipher::FromNid(NID_id_aes128_wrap); -const Cipher Cipher::AES_192_KW = Cipher::FromNid(NID_id_aes192_wrap); -const Cipher Cipher::AES_256_KW = Cipher::FromNid(NID_id_aes256_wrap); + +// The well-known ciphers are resolved lazily rather than in static +// initializers: EVP_get_cipherbynid() triggers OPENSSL_init_crypto(), and +// doing that while the executable is still being loaded adds the full cost +// of OpenSSL initialization to every process startup, even when crypto is +// never used. +#define NCRYPTO_LAZY_CIPHER(name, nid) \ + const Cipher& Cipher::name() { \ + static const Cipher cipher = Cipher::FromNid(nid); \ + return cipher; \ + } + +NCRYPTO_LAZY_CIPHER(AES_128_CBC, NID_aes_128_cbc) +NCRYPTO_LAZY_CIPHER(AES_192_CBC, NID_aes_192_cbc) +NCRYPTO_LAZY_CIPHER(AES_256_CBC, NID_aes_256_cbc) +NCRYPTO_LAZY_CIPHER(AES_128_CTR, NID_aes_128_ctr) +NCRYPTO_LAZY_CIPHER(AES_192_CTR, NID_aes_192_ctr) +NCRYPTO_LAZY_CIPHER(AES_256_CTR, NID_aes_256_ctr) +NCRYPTO_LAZY_CIPHER(AES_128_GCM, NID_aes_128_gcm) +NCRYPTO_LAZY_CIPHER(AES_192_GCM, NID_aes_192_gcm) +NCRYPTO_LAZY_CIPHER(AES_256_GCM, NID_aes_256_gcm) +NCRYPTO_LAZY_CIPHER(AES_128_KW, NID_id_aes128_wrap) +NCRYPTO_LAZY_CIPHER(AES_192_KW, NID_id_aes192_wrap) +NCRYPTO_LAZY_CIPHER(AES_256_KW, NID_id_aes256_wrap) #ifndef OPENSSL_IS_BORINGSSL -const Cipher Cipher::AES_128_OCB = Cipher::FromNid(NID_aes_128_ocb); -const Cipher Cipher::AES_192_OCB = Cipher::FromNid(NID_aes_192_ocb); -const Cipher Cipher::AES_256_OCB = Cipher::FromNid(NID_aes_256_ocb); +NCRYPTO_LAZY_CIPHER(AES_128_OCB, NID_aes_128_ocb) +NCRYPTO_LAZY_CIPHER(AES_192_OCB, NID_aes_192_ocb) +NCRYPTO_LAZY_CIPHER(AES_256_OCB, NID_aes_256_ocb) #endif -const Cipher Cipher::CHACHA20_POLY1305 = Cipher::FromNid(NID_chacha20_poly1305); +NCRYPTO_LAZY_CIPHER(CHACHA20_POLY1305, NID_chacha20_poly1305) + +#undef NCRYPTO_LAZY_CIPHER bool Cipher::isGcmMode() const { if (!cipher_) return false; diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index 58e32cc18fc7..29a70ec9d110 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -502,25 +502,26 @@ class Cipher final { // Utilities to get various ciphers by type. If the underlying // implementation does not support the requested cipher, then // the result will be an empty Cipher object whose bool operator - // will return false. + // will return false. The ciphers are looked up lazily on first use so + // that merely loading the library does not initialize OpenSSL. static const Cipher EMPTY; - static const Cipher AES_128_CBC; - static const Cipher AES_192_CBC; - static const Cipher AES_256_CBC; - static const Cipher AES_128_CTR; - static const Cipher AES_192_CTR; - static const Cipher AES_256_CTR; - static const Cipher AES_128_GCM; - static const Cipher AES_192_GCM; - static const Cipher AES_256_GCM; - static const Cipher AES_128_KW; - static const Cipher AES_192_KW; - static const Cipher AES_256_KW; - static const Cipher AES_128_OCB; - static const Cipher AES_192_OCB; - static const Cipher AES_256_OCB; - static const Cipher CHACHA20_POLY1305; + static const Cipher& AES_128_CBC(); + static const Cipher& AES_192_CBC(); + static const Cipher& AES_256_CBC(); + static const Cipher& AES_128_CTR(); + static const Cipher& AES_192_CTR(); + static const Cipher& AES_256_CTR(); + static const Cipher& AES_128_GCM(); + static const Cipher& AES_192_GCM(); + static const Cipher& AES_256_GCM(); + static const Cipher& AES_128_KW(); + static const Cipher& AES_192_KW(); + static const Cipher& AES_256_KW(); + static const Cipher& AES_128_OCB(); + static const Cipher& AES_192_OCB(); + static const Cipher& AES_256_OCB(); + static const Cipher& CHACHA20_POLY1305(); struct CipherParams { int padding; diff --git a/src/crypto/crypto_aes.h b/src/crypto/crypto_aes.h index 76359f14e7df..6b18b1e29e24 100644 --- a/src/crypto/crypto_aes.h +++ b/src/crypto/crypto_aes.h @@ -13,15 +13,15 @@ namespace node::crypto { constexpr unsigned kNoAuthTagLength = static_cast(-1); #define VARIANTS_COMMON(V) \ - V(CTR_128, AES_CTR_Cipher, ncrypto::Cipher::AES_128_CTR) \ - V(CTR_192, AES_CTR_Cipher, ncrypto::Cipher::AES_192_CTR) \ - V(CTR_256, AES_CTR_Cipher, ncrypto::Cipher::AES_256_CTR) \ - V(CBC_128, AES_Cipher, ncrypto::Cipher::AES_128_CBC) \ - V(CBC_192, AES_Cipher, ncrypto::Cipher::AES_192_CBC) \ - V(CBC_256, AES_Cipher, ncrypto::Cipher::AES_256_CBC) \ - V(GCM_128, AES_Cipher, ncrypto::Cipher::AES_128_GCM) \ - V(GCM_192, AES_Cipher, ncrypto::Cipher::AES_192_GCM) \ - V(GCM_256, AES_Cipher, ncrypto::Cipher::AES_256_GCM) \ + V(CTR_128, AES_CTR_Cipher, ncrypto::Cipher::AES_128_CTR()) \ + V(CTR_192, AES_CTR_Cipher, ncrypto::Cipher::AES_192_CTR()) \ + V(CTR_256, AES_CTR_Cipher, ncrypto::Cipher::AES_256_CTR()) \ + V(CBC_128, AES_Cipher, ncrypto::Cipher::AES_128_CBC()) \ + V(CBC_192, AES_Cipher, ncrypto::Cipher::AES_192_CBC()) \ + V(CBC_256, AES_Cipher, ncrypto::Cipher::AES_256_CBC()) \ + V(GCM_128, AES_Cipher, ncrypto::Cipher::AES_128_GCM()) \ + V(GCM_192, AES_Cipher, ncrypto::Cipher::AES_192_GCM()) \ + V(GCM_256, AES_Cipher, ncrypto::Cipher::AES_256_GCM()) \ VARIANTS_KW(V) #ifdef OPENSSL_IS_BORINGSSL @@ -33,16 +33,16 @@ constexpr unsigned kNoAuthTagLength = static_cast(-1); V(KW_256, AES_KW_Cipher, static_cast(nullptr)) #else #define VARIANTS_KW(V) \ - V(KW_128, AES_Cipher, ncrypto::Cipher::AES_128_KW) \ - V(KW_192, AES_Cipher, ncrypto::Cipher::AES_192_KW) \ - V(KW_256, AES_Cipher, ncrypto::Cipher::AES_256_KW) + V(KW_128, AES_Cipher, ncrypto::Cipher::AES_128_KW()) \ + V(KW_192, AES_Cipher, ncrypto::Cipher::AES_192_KW()) \ + V(KW_256, AES_Cipher, ncrypto::Cipher::AES_256_KW()) #endif #if OPENSSL_WITH_AES_OCB #define VARIANTS_OCB(V) \ - V(OCB_128, AES_Cipher, ncrypto::Cipher::AES_128_OCB) \ - V(OCB_192, AES_Cipher, ncrypto::Cipher::AES_192_OCB) \ - V(OCB_256, AES_Cipher, ncrypto::Cipher::AES_256_OCB) + V(OCB_128, AES_Cipher, ncrypto::Cipher::AES_128_OCB()) \ + V(OCB_192, AES_Cipher, ncrypto::Cipher::AES_192_OCB()) \ + V(OCB_256, AES_Cipher, ncrypto::Cipher::AES_256_OCB()) #else #define VARIANTS_OCB(V) #endif diff --git a/src/crypto/crypto_chacha20_poly1305.cc b/src/crypto/crypto_chacha20_poly1305.cc index 1cdb933c65d4..56ba78be7ccd 100644 --- a/src/crypto/crypto_chacha20_poly1305.cc +++ b/src/crypto/crypto_chacha20_poly1305.cc @@ -105,7 +105,7 @@ Maybe ChaCha20Poly1305CipherTraits::AdditionalConfig( ChaCha20Poly1305CipherConfig* params) { Environment* env = Environment::GetCurrent(args); - params->cipher = ncrypto::Cipher::CHACHA20_POLY1305; + params->cipher = ncrypto::Cipher::CHACHA20_POLY1305(); #ifndef OPENSSL_IS_BORINGSSL // On BoringSSL, ChaCha20-Poly1305 is not exposed via the EVP_CIPHER registry diff --git a/src/crypto/crypto_context.cc b/src/crypto/crypto_context.cc index 95564f02b343..c9c73037fe97 100644 --- a/src/crypto/crypto_context.cc +++ b/src/crypto/crypto_context.cc @@ -2507,9 +2507,11 @@ int SecureContext::TicketKeyCallback(SSL* ssl, ArrayBufferViewContents aes_key(aes.As()); if (enc) { - EVP_EncryptInit_ex(ectx, Cipher::AES_128_CBC, nullptr, aes_key.data(), iv); + EVP_EncryptInit_ex( + ectx, Cipher::AES_128_CBC(), nullptr, aes_key.data(), iv); } else { - EVP_DecryptInit_ex(ectx, Cipher::AES_128_CBC, nullptr, aes_key.data(), iv); + EVP_DecryptInit_ex( + ectx, Cipher::AES_128_CBC(), nullptr, aes_key.data(), iv); } return r; @@ -2531,8 +2533,11 @@ int SecureContext::TicketCompatibilityCallback(SSL* ssl, if (enc) { memcpy(name, sc->ticket_key_name_, sizeof(sc->ticket_key_name_)); if (!ncrypto::CSPRNG(iv, 16) || - EVP_EncryptInit_ex( - ectx, Cipher::AES_128_CBC, nullptr, sc->ticket_key_aes_, iv) <= 0 || + EVP_EncryptInit_ex(ectx, + Cipher::AES_128_CBC(), + nullptr, + sc->ticket_key_aes_, + iv) <= 0 || !InitTicketHmac( hctx, sc->ticket_key_hmac_, sizeof(sc->ticket_key_hmac_))) { return -1; @@ -2546,7 +2551,7 @@ int SecureContext::TicketCompatibilityCallback(SSL* ssl, } if (EVP_DecryptInit_ex( - ectx, Cipher::AES_128_CBC, nullptr, sc->ticket_key_aes_, iv) <= 0 || + ectx, Cipher::AES_128_CBC(), nullptr, sc->ticket_key_aes_, iv) <= 0 || !InitTicketHmac( hctx, sc->ticket_key_hmac_, sizeof(sc->ticket_key_hmac_))) { return -1; From 2dffdbf997356a69830b77c3991676cf105251b1 Mon Sep 17 00:00:00 2001 From: avivkeller Date: Tue, 25 Aug 2026 13:40:13 -0400 Subject: [PATCH 3/3] src: do not register OpenSSL's atexit() handler Now that crypto no longer initializes OpenSSL from a static initalizer (see previous commit), nothing requires OpenSSL to be torn down at process exit. As mentioned in an earlier commit, `atexit` is expensive, so we now once again avoid it. Signed-off-by: Aviv Keller --- src/node.cc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/node.cc b/src/node.cc index 0ad0276a90c2..1a65ad89e667 100644 --- a/src/node.cc +++ b/src/node.cc @@ -1224,7 +1224,14 @@ InitializeOncePerProcessInternal(const std::vector& args, OPENSSL_INIT_set_config_file_flags(settings, CONF_MFLAGS_IGNORE_MISSING_FILE); - OPENSSL_init_crypto(OPENSSL_INIT_LOAD_CONFIG, settings); + // OPENSSL_INIT_NO_ATEXIT: do not let OpenSSL register OPENSSL_cleanup() + // with atexit(). Nothing needs OpenSSL to be torn down when the process + // exits, and on macOS atexit() itself is expensive: it calls dladdr(), + // which linearly scans the executable's (very large) symbol table and + // costs about a millisecond on every process start. This must be part of + // the first OPENSSL_init_crypto() call in the process to take effect. + OPENSSL_init_crypto(OPENSSL_INIT_LOAD_CONFIG | OPENSSL_INIT_NO_ATEXIT, + settings); OPENSSL_INIT_free(settings); if (ERR_peek_error() != 0) {