diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 91e7d3f78727..59e09628c3c6 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -162,6 +162,8 @@ static int us_ctx_cache_ex_idx = -1; * ca/caFile options or a later addCACert): the per-socket client attach must * not replace such a store with the process-shared default roots. */ static int us_ctx_user_ca_ex_idx = -1; +/* The resolved system-CA decision the context was built with (stored as value+1 so 0 = unset). */ +static int us_ctx_use_system_ca_ex_idx = -1; static int us_ssl_reneg_state_idx = -1; /* Per-connection async-SNI suspension state (select_certificate_cb retry). */ static int us_ssl_sni_pending_idx = -1; @@ -443,6 +445,7 @@ static void us_ex_idx_init(void) { us_sni_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_cache_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, bun_ssl_ctx_cache_on_free); us_ctx_user_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); + us_ctx_use_system_ca_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ctx_sni_policy_ex_idx = SSL_CTX_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_reneg_state_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_reneg_state_free); us_ssl_sni_pending_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_sni_pending_free); @@ -903,13 +906,15 @@ static int us_ssl_ctx_use_privatekey_content(SSL_CTX *ctx, const char *content, * the still-empty SSL_CTX_new() store are first replaced by a private full * default-root copy, and the context is marked so the per-socket attach keeps * it. https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_context.cc#L1831 */ +int us_ssl_ctx_use_system_ca(SSL_CTX *ctx) { + us_ex_idx_ensure(); + intptr_t stored = (intptr_t)SSL_CTX_get_ex_data(ctx, us_ctx_use_system_ca_ex_idx); + return stored ? (int)(stored - 1) : us_default_use_system_ca(); +} + static X509_STORE *us_ssl_ctx_get_own_cert_store(SSL_CTX *ctx) { X509_STORE *store = SSL_CTX_get_cert_store(ctx); - /* us_get_shared_default_ca_store() up-refs before returning, so release - * the reference taken just for this comparison. */ - X509_STORE *shared = us_get_shared_default_ca_store(); - int store_is_shared = store != NULL && store == shared; - X509_STORE_free(shared); + int store_is_shared = us_is_shared_default_ca_store(store); us_ex_idx_ensure(); int store_is_empty = 0; if (store != NULL && !store_is_shared) { @@ -921,7 +926,7 @@ static X509_STORE *us_ssl_ctx_get_own_cert_store(SSL_CTX *ctx) { * no `ca` configured at all may be seeded with the default roots here. */ int user_ca = SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx) != NULL; if (store == NULL || store_is_shared || (store_is_empty && !user_ca)) { - X509_STORE *own = us_get_default_ca_store(); + X509_STORE *own = us_get_default_ca_store(us_ssl_ctx_use_system_ca(ctx)); if (own == NULL) { return NULL; } @@ -1106,6 +1111,9 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, /* Register the live-count free_func first thing so every exit (including * build_fail) balances. The packed reneg policy reuses the same slot. */ SSL_CTX_set_ex_data(ssl_context, us_ssl_ctx_ex_idx(), NULL); + const int use_system_ca = us_resolve_use_system_ca(options.use_system_ca); + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ssl_context, us_ctx_use_system_ca_ex_idx, (void *)(intptr_t)(use_system_ca + 1)); /* Default options we rely on — changing these breaks the BIO logic. */ SSL_CTX_set_read_ahead(ssl_context, 1); @@ -1233,7 +1241,7 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, * addRootCerts() when `ca` is absent - the handshake-time auto-chain and * (for requestCert) client verification both read it. The getter up-refs, * so set_cert_store owns exactly one reference per context. */ - SSL_CTX_set_cert_store(ssl_context, us_get_shared_default_ca_store()); + SSL_CTX_set_cert_store(ssl_context, us_get_shared_default_ca_store(use_system_ca)); if (options.request_cert) { SSL_CTX_set_verify(ssl_context, options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) @@ -1574,7 +1582,7 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx, * A context whose store holds user-provided CAs (ca/caFile options or * addCACert) keeps using its own store - overriding it here would * hide those CAs from chain verification. */ - X509_STORE *roots = us_get_shared_default_ca_store(); + X509_STORE *roots = us_get_shared_default_ca_store(us_ssl_ctx_use_system_ca(ctx)); if (roots) SSL_set0_verify_cert_store(ssl, roots); } } diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 4257ddeee0dc..622bb0697e29 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -2,6 +2,7 @@ #include "./root_certs_header.h" #include "./internal/internal.h" #include +#include #include #include "./default_ciphers.h" @@ -23,22 +24,34 @@ extern "C" void BUN__warn__extra_ca_load_failed(const char* filename, const char // Forward declarations for platform-specific functions // (Actual implementations are in platform-specific files) -// External variable from Zig CLI arguments +// External variables from the CLI arguments extern "C" bool Bun__Node__UseSystemCA; - -// Helper function to check if system CA should be used -// Checks both CLI flag (--use-system-ca) and environment variable (NODE_USE_SYSTEM_CA=1) -static bool us_should_use_system_ca() { - // Check CLI flag first +extern "C" bool Bun__Node__NoUseSystemCA; +// BunCAStore discriminant (Arguments.rs): 1 == --use-openssl-ca. +extern "C" uint8_t Bun__Node__CAStore; +static const uint8_t BUN_CA_STORE_OPENSSL = 1; + +// The process-wide default: --no-use-system-ca beats everything, then --use-system-ca, then +// NODE_USE_SYSTEM_CA=1. A thread (node: Environment) started with its own flag overrides this for +// the contexts it creates — see us_bun_socket_context_options_t.use_system_ca. +extern "C" int us_default_use_system_ca() { + if (Bun__Node__NoUseSystemCA) { + return 0; + } if (Bun__Node__UseSystemCA) { - return true; + return 1; } - - // Check environment variable const char *use_system_ca = getenv("NODE_USE_SYSTEM_CA"); return use_system_ca && strcmp(use_system_ca, "1") == 0; } +// Resolve an options-struct tri-state (0: process default, >0: include system roots, <0: exclude). +extern "C" int us_resolve_use_system_ca(int requested) { + if (requested > 0) return 1; + if (requested < 0) return 0; + return us_default_use_system_ca(); +} + // Platform-specific system certificate loading implementations are separated: // - macOS: root_certs_darwin.cpp (Security framework with dynamic loading) // - Windows: root_certs_windows.cpp (Windows CryptoAPI) @@ -207,30 +220,48 @@ STACK_OF(X509) *us_get_root_system_cert_instances() { return system_certs; } -extern "C" X509_STORE *us_get_default_ca_store() { +extern "C" X509_STORE *us_get_default_ca_store(int use_system_ca) { X509_STORE *store = X509_STORE_new(); if (store == NULL) { return NULL; } - if (!X509_STORE_set_default_paths(store)) { + // Node's NewRootCertStore: --use-openssl-ca means OpenSSL's default lookups *instead of* the + // bundled roots (system roots ignored); otherwise the bundled roots, plus the system store when + // asked (on Linux that store is what honours SSL_CERT_FILE / SSL_CERT_DIR, see root_certs_linux.cpp; + // on macOS / Windows node's is the OS store alone). NODE_EXTRA_CA_CERTS is added in every mode. + // https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_context.cc#L1099-L1109 + const int openssl_ca = Bun__Node__CAStore == BUN_CA_STORE_OPENSSL; + if (openssl_ca && !X509_STORE_set_default_paths(store)) { X509_STORE_free(store); return NULL; } us_default_ca_certificates *default_ca_certificates = us_get_default_ca_certificates(); - X509** root_cert_instances = default_ca_certificates->root_cert_instances; - STACK_OF(X509) *root_extra_cert_instances = default_ca_certificates->root_extra_cert_instances; - // load all root_cert_instances on the default ca store - for (size_t i = 0; i < root_certs_size; i++) { - X509 *cert = root_cert_instances[i]; - if (cert == NULL) - continue; - X509_up_ref(cert); - X509_STORE_add_cert(store, cert); + if (!openssl_ca) { + X509** root_cert_instances = default_ca_certificates->root_cert_instances; + for (size_t i = 0; i < root_certs_size; i++) { + X509 *cert = root_cert_instances[i]; + if (cert == NULL) + continue; + X509_up_ref(cert); + X509_STORE_add_cert(store, cert); + } + + if (use_system_ca) { + STACK_OF(X509) *root_system_cert_instances = us_get_root_system_cert_instances(); + if (root_system_cert_instances) { + for (int i = 0; i < sk_X509_num(root_system_cert_instances); i++) { + X509 *cert = sk_X509_value(root_system_cert_instances, i); + X509_up_ref(cert); + X509_STORE_add_cert(store, cert); + } + } + } } + STACK_OF(X509) *root_extra_cert_instances = default_ca_certificates->root_extra_cert_instances; if (root_extra_cert_instances) { for (int i = 0; i < sk_X509_num(root_extra_cert_instances); i++) { X509 *cert = sk_X509_value(root_extra_cert_instances, i); @@ -239,34 +270,32 @@ extern "C" X509_STORE *us_get_default_ca_store() { } } - if (us_should_use_system_ca()) { - STACK_OF(X509) *root_system_cert_instances = us_get_root_system_cert_instances(); - if (root_system_cert_instances) { - for (int i = 0; i < sk_X509_num(root_system_cert_instances); i++) { - X509 *cert = sk_X509_value(root_system_cert_instances, i); - X509_up_ref(cert); - X509_STORE_add_cert(store, cert); - } - } - } - return store; } -// Process-wide immutable default store. Safe to share across SSL_CTXs that -// don't add per-config CAs (the user-`ca` path in build_raw populates the -// SSL_CTX's own private, initially-empty store instead). This makes the -// ~150-root build a once-per-process cost instead of once-per-SSL_CTX, which -// is what kept Bun.connect({tls:true}) under the node-tls-server.test.ts -// 100ms cold-path budget in debug+ASAN. -extern "C" X509_STORE *us_get_shared_default_ca_store() { - static X509_STORE *shared = nullptr; - static std::once_flag once; - std::call_once(once, []() { shared = us_get_default_ca_store(); }); +// Process-wide immutable default stores, one per system-CA decision. Safe to share across SSL_CTXs +// that don't add per-config CAs (the user-`ca` path in build_raw populates the SSL_CTX's own +// private, initially-empty store instead). This makes the ~150-root build a once-per-process cost +// (per variant actually used) instead of once-per-SSL_CTX, which is what kept +// Bun.connect({tls:true}) under the node-tls-server.test.ts 100ms cold-path budget in debug+ASAN. +static std::atomic shared_default_ca_store[2] = { nullptr, nullptr }; + +extern "C" X509_STORE *us_get_shared_default_ca_store(int use_system_ca) { + static std::once_flag once[2]; + int i = use_system_ca ? 1 : 0; + std::call_once(once[i], [i]() { shared_default_ca_store[i].store(us_get_default_ca_store(i)); }); + X509_STORE *shared = shared_default_ca_store[i].load(); if (shared) X509_STORE_up_ref(shared); return shared; } +// Whether `store` is one of the process-shared default stores (as opposed to a context's own). +// Compares against whatever has been built so far; builds nothing. +extern "C" int us_is_shared_default_ca_store(X509_STORE *store) { + return store != nullptr + && (store == shared_default_ca_store[0].load() || store == shared_default_ca_store[1].load()); +} + extern "C" const char *us_get_default_ciphers() { return DEFAULT_CIPHER_LIST; } diff --git a/packages/bun-usockets/src/crypto/root_certs_header.h b/packages/bun-usockets/src/crypto/root_certs_header.h index 31b59acc9848..cf6636dc3c33 100644 --- a/packages/bun-usockets/src/crypto/root_certs_header.h +++ b/packages/bun-usockets/src/crypto/root_certs_header.h @@ -11,5 +11,10 @@ STACK_OF(X509) *us_get_root_system_cert_instances(); #define CPPDECL extern #endif -CPPDECL X509_STORE *us_get_default_ca_store(); -CPPDECL X509_STORE *us_get_shared_default_ca_store(); +CPPDECL int us_default_use_system_ca(); +CPPDECL int us_resolve_use_system_ca(int requested); +CPPDECL X509_STORE *us_get_default_ca_store(int use_system_ca); +CPPDECL X509_STORE *us_get_shared_default_ca_store(int use_system_ca); +CPPDECL int us_is_shared_default_ca_store(X509_STORE *store); +/* The resolved system-CA decision an SSL_CTX built by us_ssl_ctx_build_raw was created with. */ +CPPDECL int us_ssl_ctx_use_system_ca(SSL_CTX *ctx); diff --git a/packages/bun-usockets/src/eventing/epoll_kqueue.c b/packages/bun-usockets/src/eventing/epoll_kqueue.c index 90c655d88185..5a903303adb0 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -515,6 +515,10 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout } } + const uint64_t idle_start_ns = will_idle_inside_event_loop ? us_internal_monotonic_ns() : 0; + if (will_idle_inside_event_loop) + __atomic_store_n(&loop->data.idle_entry_ns, idle_start_ns, __ATOMIC_SEQ_CST); + /* Fetch ready polls */ #ifdef LIBUS_USE_EPOLL /* A zero timespec already has a fast path in ep_poll (fs/eventpoll.c): @@ -533,6 +537,18 @@ void us_loop_run_bun_tick(struct us_loop_t *loop, const struct timespec* timeout timeout); #endif + if (will_idle_inside_event_loop) { + /* us_loop_idle_ns (another thread) retries while idle_seq is odd or changed underneath it, so + * it never observes the entry cleared without the park added (a non-monotonic sample). */ + __atomic_add_fetch(&loop->data.idle_seq, 1, __ATOMIC_SEQ_CST); + /* Clock read inside the odd window: a reader's own clock read (taken before it validated an + * even seq) is then never later than the park length we record, so samples stay monotonic. */ + uint64_t now = us_internal_monotonic_ns(); + __atomic_store_n(&loop->data.idle_entry_ns, 0, __ATOMIC_SEQ_CST); + __atomic_add_fetch(&loop->data.idle_ns, now - idle_start_ns, __ATOMIC_SEQ_CST); + __atomic_add_fetch(&loop->data.idle_seq, 1, __ATOMIC_SEQ_CST); + } + /* Before anything can allocate again. */ if (handed_off) mi_on_thread_idle_end(); diff --git a/packages/bun-usockets/src/eventing/libuv.c b/packages/bun-usockets/src/eventing/libuv.c index e1eb0551b3ef..f0e796b4cf05 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -349,6 +349,7 @@ struct us_loop_t *us_create_loop(void *hint, loop->uv_loop = hint ? hint : uv_loop_new(); loop->is_default = hint != 0; + uv_loop_configure(loop->uv_loop, UV_METRICS_IDLE_TIME); loop->uv_pre = us_malloc(sizeof(uv_prepare_t)); uv_prepare_init(loop->uv_loop, loop->uv_pre); diff --git a/packages/bun-usockets/src/internal/internal.h b/packages/bun-usockets/src/internal/internal.h index 8444fa14b641..40ba52a8d122 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -158,6 +158,9 @@ uint64_t us_internal_monotonic_ns(void); long long us_internal_sweep_timeout_ns(struct us_loop_t *loop); void us_internal_sweep_if_due(struct us_loop_t *loop); #endif +/* Nanoseconds this loop has spent parked, including a park in progress. Safe + * from another thread. Both platforms: Rust calls it ungated. */ +uint64_t us_loop_idle_ns(struct us_loop_t *loop); void us_internal_free_closed_sockets(us_loop_r loop); void us_internal_loop_link_group(struct us_loop_t *loop, struct us_socket_group_t *group); void us_internal_loop_unlink_group(struct us_loop_t *loop, struct us_socket_group_t *group); diff --git a/packages/bun-usockets/src/internal/loop_data.h b/packages/bun-usockets/src/internal/loop_data.h index 3937ea5d70d5..b79a6e0f8daa 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -76,6 +76,15 @@ struct us_internal_loop_data_t { * for lsquic's time-driven state. POSIX folds the deadline into the * epoll_pwait2 timeout via getTimeout() instead. */ struct us_timer_t *quic_timer; +#endif +#ifndef LIBUS_USE_LIBUV + /* Nanoseconds parked, for eventLoopUtilization(). Read cross-thread — + * __atomic_* only. MIRRORED in src/uws_sys/InternalLoopData.rs: this struct + * is us_loop_t's first member, so a field here shifts num_polls. */ + unsigned long long idle_ns; + unsigned long long idle_entry_ns; + /* Seqlock over the park-exit update of the two fields above (odd while in progress). */ + unsigned long long idle_seq; #endif struct us_socket_group_t *iterator; char *recv_buf; diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index cf62e75eee44..fbd3f922df6a 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -517,6 +517,10 @@ struct us_bun_socket_context_options_t { const char *sigalgs; /* Colon-separated named-group list applied via SSL_CTX_set1_groups_list. */ const char *ecdh_curve; + /* Whether the default root store of this context includes the system's trusted CAs (node's + * per-Environment --use-system-ca): 0 = the process default (CLI flags / NODE_USE_SYSTEM_CA), + * 1 = include, -1 = exclude. Only matters when no `ca`/`ca_file_name` is given. */ + int use_system_ca; }; enum create_bun_socket_error_t { diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index 7df70af56ea2..a81c59cdc5ae 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -119,6 +119,36 @@ void us_internal_sweep_if_due(struct us_loop_t *loop) { #endif +/* The clock us_loop_idle_ns accumulates in, so eventLoopUtilization's elapsed and idle share one + * time base (they diverge across system sleep otherwise: CLOCK_MONOTONIC keeps counting on macOS, + * the uptime clock std::time::Instant uses does not). */ +uint64_t us_loop_idle_clock_ns(void) { +#ifdef LIBUS_USE_LIBUV + return uv_hrtime(); +#else + return us_internal_monotonic_ns(); +#endif +} + +uint64_t us_loop_idle_ns(struct us_loop_t *loop) { +#ifdef LIBUS_USE_LIBUV + return uv_metrics_idle_time(loop->uv_loop); +#else + uint64_t idle, entry, now; + for (;;) { + uint64_t seq = __atomic_load_n(&loop->data.idle_seq, __ATOMIC_SEQ_CST); + if (seq & 1) continue; + idle = __atomic_load_n(&loop->data.idle_ns, __ATOMIC_SEQ_CST); + entry = __atomic_load_n(&loop->data.idle_entry_ns, __ATOMIC_SEQ_CST); + now = entry > 0 ? us_internal_monotonic_ns() : 0; + if (__atomic_load_n(&loop->data.idle_seq, __ATOMIC_SEQ_CST) == seq) break; + } + if (entry > 0 && now > entry) + idle += now - entry; + return idle; +#endif +} + void us_internal_loop_data_init(struct us_loop_t *loop, void (*wakeup_cb)(struct us_loop_t *loop), void (*pre_cb)(struct us_loop_t *loop), void (*post_cb)(struct us_loop_t *loop)) { // We allocate with calloc, so we only need to initialize the specific fields in use. diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 8926c7cfbd8a..0d6ec5687d61 100644 --- a/packages/bun-usockets/src/quic.c +++ b/packages/bun-usockets/src/quic.c @@ -24,10 +24,11 @@ #include #endif +#include "crypto/root_certs_header.h" + extern SSL_CTX *us_ssl_ctx_build_raw( struct us_bun_socket_context_options_t options, enum create_bun_socket_error_t *err); -extern X509_STORE *us_get_default_ca_store(void); #define US_QUIC_READ_BUF (16 * 1024) @@ -1156,8 +1157,9 @@ us_quic_socket_context_t *us_create_quic_client_context( SSL_CTX_set_max_proto_version(ssl, TLS1_3_VERSION); /* Same root store the H1/H2 client uses (bundled Mozilla roots + platform * CAs + NODE_EXTRA_CA_CERTS); set_default_verify_paths alone doesn't find - * the system store on macOS/Windows. */ - SSL_CTX_set_cert_store(ssl, us_get_default_ca_store()); + * the system store on macOS/Windows. The shared store is never mutated on + * this path, and set_cert_store takes the getter's up-ref. */ + SSL_CTX_set_cert_store(ssl, us_get_shared_default_ca_store(us_default_use_system_ca())); SSL_CTX_set_custom_verify(ssl, SSL_VERIFY_PEER, us_quic_client_verify); us_quic_socket_context_t *ctx = (us_quic_socket_context_t *) diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index eb5ee2ba132d..8830f8d014e5 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -86,6 +86,8 @@ namespace uWS { int allow_partial_trust_chain = 0; const char *sigalgs = nullptr; const char *ecdh_curve = nullptr; + /* 0 = process default, 1 = include system CAs, -1 = exclude (see libusockets.h) */ + int use_system_ca = 0; /* Conversion operator used internally */ operator struct us_bun_socket_context_options_t() const { diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index c6bea951364a..ab7d2b69eb78 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -51,6 +51,10 @@ pub struct SSLConfig { pub requires_custom_request_ctx: bool, pub is_using_default_ciphers: bool, pub low_memory_mode: bool, + /// Whether contexts built from this config trust the system CAs by default (node's + /// per-Environment --use-system-ca): 0 = process default, 1 = include, -1 = exclude. Stamped + /// from the creating VM; only matters when no `ca`/`ca_file_name` is given. + pub use_system_ca: i32, /// Memoized `content_hash()`. Interior-mutable because it's lazily filled /// through `Arc` (shared ref) by the intern registry's hash /// context. @@ -120,6 +124,7 @@ impl SSLConfig { requires_custom_request_ctx: false, is_using_default_ciphers: true, low_memory_mode: false, + use_system_ca: 0, cached_hash: AtomicU64::new(0), }; @@ -225,6 +230,7 @@ impl SSLConfig { ctx_opts.crl = crl.as_ptr(); ctx_opts.crl_count = crl.len() as u32; } + ctx_opts.use_system_ca = self.use_system_ca; ctx_opts } @@ -324,6 +330,9 @@ impl SSLConfig { if self.is_using_default_ciphers != other.is_using_default_ciphers { return false; } + if self.use_system_ca != other.use_system_ca { + return false; + } if self.low_memory_mode != other.low_memory_mode { return false; } @@ -384,6 +393,7 @@ impl SSLConfig { hasher.update(&[u8::from(self.requires_custom_request_ctx)]); hasher.update(&[u8::from(self.is_using_default_ciphers)]); hasher.update(&[u8::from(self.low_memory_mode)]); + hasher.update(&self.use_system_ca.to_ne_bytes()); let hash = hasher.final_(); // Avoid 0 since it's the sentinel for "not computed" let hash = if hash == 0 { 1 } else { hash }; @@ -483,6 +493,7 @@ impl Clone for SSLConfig { requires_custom_request_ctx: self.requires_custom_request_ctx, is_using_default_ciphers: self.is_using_default_ciphers, low_memory_mode: self.low_memory_mode, + use_system_ca: self.use_system_ca, cached_hash: AtomicU64::new(0), } } diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 5373569f51cb..ea4d3c1d43ba 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -798,6 +798,7 @@ export function buildAllowedNodeEnvironmentFlags() { "--max-http-header-size", "--no-addons", "--no-deprecation", + "--no-use-system-ca", "--no-warnings", "--pending-deprecation", "--perf-basic-prof", diff --git a/src/js/internal/async_hooks_tick.ts b/src/js/internal/async_hooks_tick.ts index 8b000fceac8a..07046006811b 100644 --- a/src/js/internal/async_hooks_tick.ts +++ b/src/js/internal/async_hooks_tick.ts @@ -1,14 +1,6 @@ -// Bridge between node:async_hooks createHook() and the process.nextTick -// queue (builtins/ProcessObjectInternals.ts). Enabled `init` hooks are pushed -// into `tickInitHooks` so the nextTick hot path pays only an array-length -// check when no hook is enabled. -// -// The array identity must stay stable (push/splice only, never reassign): -// the nextTick closure captures it once at setup. -// -// Currently only TickObject `init` events are delivered (enough for -// console.log/stream.write tick-coalescing tests); promise, timer and native -// resource events are still unimplemented. +// Bridge between createHook() and process.nextTick: enabled `init` hooks live in `tickInitHooks` so the +// hot path pays only an array-length check. Array identity is stable (push/splice only) — nextTick captures it once. +// Only TickObject and WORKER `init` events are delivered; other resource types are unimplemented. const tickInitHooks = []; let nextAsyncId = 1; diff --git a/src/js/internal/perf/event_loop_utilization.ts b/src/js/internal/perf/event_loop_utilization.ts new file mode 100644 index 000000000000..5c5fdc3965d2 --- /dev/null +++ b/src/js/internal/perf/event_loop_utilization.ts @@ -0,0 +1,25 @@ +// Shared by perf_hooks and worker_threads; see https://github.com/nodejs/node/blob/main/lib/internal/perf/event_loop_utilization.js +function internalEventLoopUtilization(elu, util1, util2) { + if (elu === null) { + return { idle: 0, active: 0, utilization: 0 }; + } + + if (util2) { + const idle = util1.idle - util2.idle; + const active = util1.active - util2.active; + return { idle, active, utilization: active / (idle + active) }; + } + + const idle = elu[1]; + const active = elu[0] - idle; + + if (!util1) { + return { idle, active, utilization: active / (idle + active) }; + } + + const idleDelta = idle - util1.idle; + const activeDelta = active - util1.active; + return { idle: idleDelta, active: activeDelta, utilization: activeDelta / (idleDelta + activeDelta) }; +} + +export default { internalEventLoopUtilization }; diff --git a/src/js/node/async_hooks.ts b/src/js/node/async_hooks.ts index ade40926f8da..b385692f76a0 100644 --- a/src/js/node/async_hooks.ts +++ b/src/js/node/async_hooks.ts @@ -495,16 +495,14 @@ function createHook(hook) { throw $ERR_ASYNC_CALLBACK("hook.promiseResolve"); let enabledInit; - return { + const asyncHook = { enable() { if (init !== undefined && enabledInit === undefined) { - // init is delivered for TickObject resources (process.nextTick); - // other resource types are still unimplemented. - // Per-instance wrapper: two hooks registered with the same init - // function must stay independently removable (removal is by - // identity, and removing the other instance's entry would reorder - // its callback relative to unrelated hooks). - enabledInit = (asyncId, type, triggerAsyncId, resource) => init(asyncId, type, triggerAsyncId, resource); + // Only TickObject and WORKER init are delivered. Per-instance wrapper so two hooks with the + // same init stay independently removable. node calls init as a method on the AsyncHook + // (https://github.com/nodejs/node/blob/main/lib/internal/async_hooks.js), so `this.disable()` works. + enabledInit = (asyncId, type, triggerAsyncId, resource) => + init.$call(asyncHook, asyncId, type, triggerAsyncId, resource); require("internal/async_hooks_tick").tickInitHooks.push(enabledInit); } if (before !== undefined || after !== undefined || destroy !== undefined || promiseResolve !== undefined) { @@ -531,6 +529,7 @@ function createHook(hook) { return this; }, }; + return asyncHook; } const executionAsyncIdNotImpl = createWarning( diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 858b4df289f2..c530eb8ecf46 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -116,12 +116,11 @@ function createPerformanceNodeTiming() { return object; } -function eventLoopUtilization(_utilization1, _utilization2) { - return { - idle: 0, - active: 0, - utilization: 0, - }; +const getLoopELU = $newRustFunction("bun.rs", "getLoopELU", 0); +const { internalEventLoopUtilization } = require("internal/perf/event_loop_utilization"); + +function eventLoopUtilization(utilization1, utilization2) { + return internalEventLoopUtilization(getLoopELU(), utilization1, utilization2); } const { PerformanceResourceTiming } = globalThis; diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index fcfbd5824864..0d1c1e76c444 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1672,22 +1672,27 @@ function cacheBundledRootCertificates(): string[] { return bundledRootCertificates; } const getUseSystemCA = $newRustFunction("bun.rs", "getUseSystemCA", 0); +const getUseOpensslCA = $newRustFunction("bun.rs", "getUseOpensslCA", 0); let defaultCACertificates: string[] | undefined; +// Mirrors the store root_certs.cpp builds, in node's shape: +// https://github.com/nodejs/node/blob/v26.3.0/lib/tls.js#L146-L178 function cacheDefaultCACertificates() { if (defaultCACertificates) return defaultCACertificates; defaultCACertificates = []; - const bundled = cacheBundledRootCertificates(); - for (let i = 0; i < bundled.length; ++i) { - ArrayPrototypePush.$call(defaultCACertificates, bundled[i]); - } + if (!getUseOpensslCA()) { + const bundled = cacheBundledRootCertificates(); + for (let i = 0; i < bundled.length; ++i) { + ArrayPrototypePush.$call(defaultCACertificates, bundled[i]); + } - // Include system certificates when --use-system-ca is set or NODE_USE_SYSTEM_CA=1 - if (getUseSystemCA() || process.env.NODE_USE_SYSTEM_CA === "1") { - const system = cacheSystemCACertificates(); - for (let i = 0; i < system.length; ++i) { - ArrayPrototypePush.$call(defaultCACertificates, system[i]); + const useSystemCA = getUseSystemCA(); + if (useSystemCA === true || (useSystemCA === undefined && process.env.NODE_USE_SYSTEM_CA === "1")) { + const system = cacheSystemCACertificates(); + for (let i = 0; i < system.length; ++i) { + ArrayPrototypePush.$call(defaultCACertificates, system[i]); + } } } diff --git a/src/js/node/worker_threads.ts b/src/js/node/worker_threads.ts index 5fa6aa8707f8..e0f76cc3b3d2 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -4,8 +4,9 @@ type WebWorker = InstanceType; const EventEmitter = require("node:events"); const { SafeMap } = require("internal/primordials"); const Readable = require("internal/streams/readable"); +const { internalEventLoopUtilization } = require("internal/perf/event_loop_utilization"); const Writable = require("internal/streams/writable"); -const { throwNotImplemented, warnNotImplementedOnce } = require("internal/shared"); +const { throwNotImplemented } = require("internal/shared"); const { validateString, validateObject, @@ -80,6 +81,8 @@ const { 9: _setEntryEvaluatedHook, 10: _isNodeWorker, 11: _setParentPort, + 12: _workerHasRef, + 13: _workerEventLoopUtilization, } = $cpp("Worker.cpp", "createNodeWorkerThreadsBinding") as [ unknown, number, @@ -93,6 +96,8 @@ const { (hook: () => void) => void, boolean, (port: MessagePort) => void, + (worker: WebWorker) => boolean | undefined, + (worker: WebWorker) => [number, number] | null, ]; type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; @@ -100,6 +105,8 @@ type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; // Used to ensure that Blobs created to hold the source code for `eval: true` Workers get cleaned up // after their Worker exits let urlRevokeRegistry: FinalizationRegistry | undefined = undefined; +const workerThreadsChannel = require("node:diagnostics_channel").channel("worker_threads"); +const { tickInitHooks, newAsyncId } = require("internal/async_hooks_tick"); function injectFakeEmitter(Class) { // Per-instance registry mapping each event to (user listener -> wrapper), so @@ -130,7 +137,7 @@ function injectFakeEmitter(Class) { function wrapped(run, listener) { return function (event) { - return listener(run(event)); + return listener.$call(this, run(event)); }; } @@ -197,7 +204,7 @@ function injectFakeEmitter(Class) { // a listener that already fired. function onceWrapper(ev) { registryFor(target, false)?.get(event)?.delete(listener); - return wrapper(ev); + return wrapper.$call(target, ev); } register(this, event, listener, onceWrapper, { once: true }); return this; @@ -1142,6 +1149,38 @@ class Worker extends EventEmitter { } urlRevokeRegistry.register(this.#worker, this.#urlToRevoke); } + this.#emitAsyncHooksInit(); + if (workerThreadsChannel.hasSubscribers) { + workerThreadsChannel.publish({ worker: this }); + } + } + + #emitAsyncHooksInit() { + const count = tickInitHooks.length; + if (count === 0) return; + const worker = this; + // node's WORKER handle: answers while the parent still holds the thread + // (through 'exit'), undefined once it has been released. + const resource = { + hasRef() { + return _workerHasRef(worker.#worker); + }, + }; + const asyncId = newAsyncId(); + // Snapshot: enable()/disable() from inside a hook must not affect the + // in-flight dispatch (node stages such mutations in tmp_array). + const snapshot = $newArrayWithSize(count); + for (let i = 0; i < count; i++) snapshot[i] = tickInitHooks[i]; + for (let i = 0; i < count; i++) { + try { + snapshot[i](asyncId, "WORKER", 0, resource); + } catch (err) { + try { + console.error(typeof err?.stack === "string" ? err.stack : err); + } catch {} + process.exit(1); + } + } } get threadId() { @@ -1185,17 +1224,14 @@ class Worker extends EventEmitter { get performance() { return (this.#performance ??= { - eventLoopUtilization() { - warnNotImplementedOnce("worker_threads.Worker.performance"); - return { - idle: 0, - active: 0, - utilization: 0, - }; - }, + eventLoopUtilization: this.#eventLoopUtilization.bind(this), }); } + #eventLoopUtilization(utilization1, utilization2) { + return internalEventLoopUtilization(_workerEventLoopUtilization(this.#worker), utilization1, utilization2); + } + terminate(callback: unknown) { if (typeof callback === "function") { process.emitWarning( @@ -1361,7 +1397,9 @@ class Worker extends EventEmitter { // if not the message is the actual error const message = event.message; if (message !== "") { + const code = error?.code; error = new Error(message, { cause: event }); + if (typeof code === "string") error.code = code; const stack = event?.stack; if (stack) { error.stack = stack; diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index f854f1aecca7..0d47e1d2b000 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -16,24 +16,27 @@ pub(crate) enum ProfilerError { FilenameTooLong, } +#[derive(Clone)] pub struct CPUProfilerConfig { - // CLI-arg-backed and - // process-lifetime, so `&'static` is sound (no struct lifetime params). - pub name: &'static [u8], - pub dir: &'static [u8], + /// Empty: the default `CPU....` name / the cwd. + pub name: Box<[u8]>, + pub dir: Box<[u8]>, pub md_format: bool, pub json_format: bool, pub interval: u32, + /// `worker.threadId` (0 on the main thread): the tid segment of node's default profile names. + pub thread_id: u32, } impl Default for CPUProfilerConfig { fn default() -> Self { Self { - name: b"", - dir: b"", + name: Box::default(), + dir: Box::default(), md_format: false, json_format: false, interval: 1000, + thread_id: 0, } } } @@ -128,7 +131,7 @@ fn write_profile_to_file( let errno = err.get_errno(); if errno == Errno::ENOENT || errno == Errno::EPERM || errno == Errno::EACCES { if !config.dir.is_empty() { - let _ = Fd::cwd().make_path(config.dir); + let _ = Fd::cwd().make_path(&config.dir); // Retry write let retry_result = bun_sys::File::write_file_os_path( Fd::cwd(), @@ -167,21 +170,21 @@ fn build_output_path( let ext: &[u8] = if is_md_format { b".md" } else { b".cpuprofile" }; let mut cursor = std::io::Cursor::new(&mut filename_buf[..]); cursor - .write_all(config.name) + .write_all(&config.name) .and_then(|_| cursor.write_all(ext)) .map_err(|_| ProfilerError::FilenameTooLong)?; let len = usize::try_from(cursor.position()).expect("int cast"); break 'blk &filename_buf[..len]; } else { - break 'blk config.name; + break 'blk &config.name; } } } else { - generate_default_filename(&mut filename_buf, is_md_format)? + generate_default_filename(&mut filename_buf, is_md_format, config.thread_id)? }; if !config.dir.is_empty() { - path.join(&[config.dir]) + path.join(&[&config.dir]) .map_err(|_| ProfilerError::FilenameTooLong)?; } @@ -195,10 +198,11 @@ fn build_output_path( fn generate_default_filename( buf: &mut PathBuffer, md_format: bool, + thread_id: u32, ) -> Result<&[u8], ProfilerError> { let extension: &str = if md_format { ".md" } else { ".cpuprofile" }; let mut cursor = std::io::Cursor::new(&mut buf[..]); - write_diagnostic_filename(&mut cursor, "CPU", extension) + write_diagnostic_filename(&mut cursor, "CPU", extension, thread_id) .map_err(|_| ProfilerError::FilenameTooLong)?; let len = usize::try_from(cursor.position()).expect("int cast"); Ok(&buf[..len]) @@ -210,6 +214,7 @@ pub(crate) fn write_diagnostic_filename( cursor: &mut dyn std::io::Write, prefix: &str, extension: &str, + tid: u32, ) -> std::io::Result<()> { #[cfg(windows)] let pid = bun_sys::windows::GetCurrentProcessId(); @@ -224,7 +229,7 @@ pub(crate) fn write_diagnostic_filename( write!( cursor, - "{prefix}.{year:04}{month:02}{day:02}.{hour:02}{minute:02}{second:02}.{pid}.0.{seq:03}{extension}" + "{prefix}.{year:04}{month:02}{day:02}.{hour:02}{minute:02}{second:02}.{pid}.{tid}.{seq:03}{extension}" ) } diff --git a/src/jsc/BunHeapProfiler.rs b/src/jsc/BunHeapProfiler.rs index 3169f067f2ea..9dfc2d7e1b94 100644 --- a/src/jsc/BunHeapProfiler.rs +++ b/src/jsc/BunHeapProfiler.rs @@ -126,7 +126,7 @@ fn build_output_path( fn generate_default_filename(buf: &mut PathBuffer, text_format: bool) -> Result<&[u8], Error> { let extension: &str = if text_format { ".md" } else { ".heapprofile" }; let mut cursor = std::io::Cursor::new(&mut buf[..]); - crate::bun_cpu_profiler::write_diagnostic_filename(&mut cursor, "Heap", extension) + crate::bun_cpu_profiler::write_diagnostic_filename(&mut cursor, "Heap", extension, 0) .map_err(|_| crate::CrateError::Sys(bun_errno::SystemErrno::ENOSPC))?; let written = usize::try_from(cursor.position()).expect("int cast"); Ok(&buf.as_slice()[..written]) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 0ee8a9c4075e..4de4dfe35897 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -83,6 +83,12 @@ pub struct InitOptions { /// The CLI's `api.TransformOptions`. Consumed by `RuntimeHooks::init_runtime_state` /// → `Transpiler::init(.., configureTransformOptionsForBunVM(args), ..)`. pub transform_options: bun_options_types::schema::api::TransformOptions, + /// Explicit CA intent for this VM; `None` lets NODE_USE_SYSTEM_CA decide. + pub use_system_ca: Option, + /// The part of `use_system_ca` that came from a flag (this thread's execArgv, or the parent's + /// when inheriting): what a child Worker inherits. Ignored on the main thread, whose + /// `use_system_ca` is flag-only already. + pub use_system_ca_flag: Option, /// Consumed by `RuntimeHooks::init_runtime_state` → `configureDebugger`. pub debugger: bun_options_types::context::Debugger, /// When `Some`, [`init`] adopts @@ -125,6 +131,8 @@ impl Default for InitOptions { store_fd: false, smol: false, eval_mode: false, + use_system_ca: None, + use_system_ca_flag: None, is_main_thread: false, worker_ptr: core::ptr::null_mut(), context_id: None, @@ -224,6 +232,12 @@ pub struct VirtualMachine { /// only leak the hook's `ctx` allocation. pub(crate) has_run_cleanup_hooks: bool, pub plugin_runner: Option, + /// Explicit `--use-system-ca` / `--no-use-system-ca` for THIS thread, or + /// `None` when neither was given and NODE_USE_SYSTEM_CA decides. Node makes + /// this an Environment option, so a Worker's execArgv can differ. + pub use_system_ca: Option, + /// See [`InitOptions::use_system_ca_flag`]; equals `use_system_ca` on the main thread. + pub use_system_ca_flag: Option, pub is_main_thread: bool, pub exit_handler: ExitHandler, @@ -286,6 +300,12 @@ pub struct VirtualMachine { pub argv: Vec>, pub origin_timer: std::time::Instant, + /// `us_loop_idle_clock_ns()` when THIS thread's loop began; 0 until then, which + /// eventLoopUtilization() reports as node's "loop has not begun" zeros. Read cross-thread. + pub loop_start_ns: core::sync::atomic::AtomicU64, + /// The loop's idle counter when `loop_start_ns` was stamped: parking before the loop "began" + /// (a watcher waiting for the first file, a debugger pause) is not loop idle time. + pub loop_idle_base_ns: core::sync::atomic::AtomicU64, pub(crate) origin_timestamp: u64, /// For fake timers: override performance.now() with a specific value (in nanoseconds). pub overridden_performance_now: Option, @@ -1984,6 +2004,49 @@ extern crate alloc; /// casts back on the other side of each hook. pub type RuntimeState = *mut c_void; +unsafe extern "C" { + safe fn us_default_use_system_ca() -> i32; +} + +impl VirtualMachine { + /// Whether TLS contexts created by this thread trust the system CAs by default: this thread's + /// explicit `--use-system-ca` / `--no-use-system-ca`, else the process default. + pub fn tls_use_system_ca(&self) -> bool { + self.use_system_ca + .unwrap_or_else(|| us_default_use_system_ca() != 0) + } + + /// The same decision as the `use_system_ca` tri-state TLS options carry + /// (0 = process default, 1 = include, -1 = exclude). + pub fn tls_use_system_ca_option(&self) -> i32 { + match self.use_system_ca { + None => 0, + Some(true) => 1, + Some(false) => -1, + } + } + + /// This thread's decision differs from the process default, so anything keyed on "the default + /// TLS context" (fetch's shared client context) must use a variant of its own. + pub fn tls_use_system_ca_differs_from_process(&self) -> bool { + self.use_system_ca + .is_some_and(|v| v != (us_default_use_system_ca() != 0)) + } +} + +/// The subset of a Worker's `execArgv` that bun acts on (node's per-Environment options). +#[derive(Default)] +pub struct WorkerExecArgv { + pub allow_addons: Option, + pub use_system_ca: Option, + /// `--cpu-prof` (JSON) / `--cpu-prof-md`; either enables profiling of the worker thread. + pub cpu_prof: bool, + pub cpu_prof_md: bool, + pub cpu_prof_interval: Option, + pub cpu_prof_name: Option>, + pub cpu_prof_dir: Option>, +} + pub struct RuntimeHooks { /// `bun.api.Timer.All.init()` + `Body.Value.HiveAllocator.init()` + /// `configureDebugger()` — everything `init()` does that names a @@ -2105,16 +2168,9 @@ pub struct RuntimeHooks { transpiler: *mut Transpiler<'static>, graph: &'static dyn bun_resolver::StandaloneModuleGraph, ), - /// Parse `execArgv` against the `RunCommand` - /// param table and return the resulting `allow_addons` value - /// (`!args.flag("--no-addons")`), or `None` if parsing failed. - /// The param table lives in - /// `bun_runtime::cli` (forward-dep). Only `--no-addons` is honoured; - /// the caller writes the returned bool back into - /// `transform_options.allow_addons` so the override semantics - /// ("override the existing even if it was set") match. - pub parse_worker_exec_argv_allow_addons: - unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> Option, + /// Parse `execArgv` against the `RunCommand` param table (lives in `bun_runtime::cli`, forward-dep). + /// Caller writes `allow_addons` back into `transform_options` and applies `cpu_prof` to the worker VM. + pub parse_worker_exec_argv: unsafe fn(exec_argv: &[bun_core::WTFStringImpl]) -> WorkerExecArgv, /// `CronJob.clearAllForVM(vm, .teardown)`. `CronJob` lives in /// `bun_runtime::api::cron`. pub stop_cron_for_vm_teardown: fn(vm: &mut VirtualMachine), @@ -2335,6 +2391,11 @@ fn get_origin_timestamp() -> u64 { (now - ORIGIN_RELATIVE_EPOCH).max(0) as u64 } +fn process_origin() -> (std::time::Instant, u64) { + static ORIGIN: std::sync::OnceLock<(std::time::Instant, u64)> = std::sync::OnceLock::new(); + *ORIGIN.get_or_init(|| (std::time::Instant::now(), get_origin_timestamp())) +} + impl VirtualMachine { /// `VirtualMachine.init(opts)` — allocate + wire the per-thread VM. /// @@ -2417,6 +2478,12 @@ impl VirtualMachine { addr_of_mut!((*vm).main_resolved_path).write(bun_core::String::empty()); addr_of_mut!((*vm).hide_bun_stackframes).write(true); addr_of_mut!((*vm).is_main_thread).write(opts.is_main_thread); + addr_of_mut!((*vm).use_system_ca).write(opts.use_system_ca); + addr_of_mut!((*vm).use_system_ca_flag).write(if opts.is_main_thread { + opts.use_system_ca + } else { + opts.use_system_ca_flag + }); // Left at the // zeroed default this aliases `hot_reload_counter`'s initial 0, so a // watcher event that races the very first entry-point load makes @@ -2426,8 +2493,11 @@ impl VirtualMachine { addr_of_mut!((*vm).pending_internal_promise_reported_at).write(u32::MAX); addr_of_mut!((*vm).on_unhandled_rejection) .write(VirtualMachine::default_on_unhandled_rejection); - addr_of_mut!((*vm).origin_timer).write(std::time::Instant::now()); - addr_of_mut!((*vm).origin_timestamp).write(get_origin_timestamp()); + addr_of_mut!((*vm).loop_start_ns).write(core::sync::atomic::AtomicU64::new(0)); + addr_of_mut!((*vm).loop_idle_base_ns).write(core::sync::atomic::AtomicU64::new(0)); + let (origin_timer, origin_timestamp) = process_origin(); + addr_of_mut!((*vm).origin_timer).write(origin_timer); + addr_of_mut!((*vm).origin_timestamp).write(origin_timestamp); addr_of_mut!((*vm).smol).write(opts.smol); // `Option<{CPU,Heap}ProfilerConfig>` are NOT zero-valid: each // payload contains a `bool`, and rustc picks that field's invalid @@ -2597,10 +2667,56 @@ impl VirtualMachine { self.event_loop_mut().wait_for_promise(promise) } + /// This thread's loop has begun: the main thread stamps it on its first poll (node's uv_run, after + /// the entry point's synchronous evaluation); a worker stamps it before its script, whose + /// bootstrap already runs inside the loop. + #[inline] + pub fn mark_loop_started(&self) { + use core::sync::atomic::Ordering; + if self.loop_start_ns.load(Ordering::Relaxed) != 0 { + return; + } + // SAFETY: `event_loop` is this VM's own loop; the idle counter is read atomically. + let idle = unsafe { (*self.event_loop).try_usockets_loop() } + .map_or(0, |l| unsafe { uws::us_loop_idle_ns(l) }); + // Base first: a reader that sees the start stamped also sees the base it belongs to. + self.loop_idle_base_ns.store(idle, Ordering::Release); + let ns = uws::us_loop_idle_clock_ns().max(1); + let _ = self + .loop_start_ns + .compare_exchange(0, ns, Ordering::Release, Ordering::Relaxed); + } + + /// Milliseconds since this thread's loop began polling; `None` before that. + pub fn loop_elapsed_ms(&self) -> Option { + Self::loop_elapsed_ms_from(&self.loop_start_ns) + } + + /// `raw_idle_ns` (this thread's `us_loop_idle_ns`) minus the idle accumulated before the loop began. + pub fn loop_idle_ms_from( + loop_idle_base_ns: &core::sync::atomic::AtomicU64, + raw_idle_ns: u64, + ) -> f64 { + let base = loop_idle_base_ns.load(core::sync::atomic::Ordering::Acquire); + raw_idle_ns.saturating_sub(base) as f64 / 1_000_000.0 + } + + /// From the field alone, for a reader on another thread that must not form a `&VirtualMachine` + /// (see `WebWorker__getELU`). + pub fn loop_elapsed_ms_from(loop_start_ns: &core::sync::atomic::AtomicU64) -> Option { + let start = loop_start_ns.load(core::sync::atomic::Ordering::Acquire); + if start == 0 { + return None; + } + let now = uws::us_loop_idle_clock_ns(); + Some(now.saturating_sub(start) as f64 / 1_000_000.0) + } + /// `eventLoop().autoTick()` — dispatched through the runtime hook /// (needs `Timer::All` for the poll timeout). #[inline] pub fn auto_tick(&mut self) { + self.mark_loop_started(); if let Some(hooks) = runtime_hooks() { // SAFETY: hook contract — `self` is the live per-thread VM. unsafe { (hooks.auto_tick)(self) }; @@ -2618,6 +2734,7 @@ impl VirtualMachine { /// `on_before_exit` / `bun_main` still make forward progress. #[inline] pub fn auto_tick_active(&mut self) { + self.mark_loop_started(); if let Some(hooks) = runtime_hooks() { // SAFETY: `self` is the live per-thread VM (hook contract). unsafe { (hooks.auto_tick_active)(self) }; @@ -3136,6 +3253,10 @@ pub struct Options { // configuration is plumbed through `RuntimeHooks::ensure_debugger` (the // CLI option struct lives in `bun_cli`, a forward dep). See // `runtime/jsc_hooks.rs` for the `configureDebugger` call site. + /// Explicit CA intent; `None` lets NODE_USE_SYSTEM_CA decide. + pub use_system_ca: Option, + /// See [`InitOptions::use_system_ca_flag`]. + pub use_system_ca_flag: Option, pub is_main_thread: bool, } @@ -3911,6 +4032,8 @@ impl VirtualMachine { mini_mode: opts.smol, eval_mode: false, is_main_thread: opts.is_main_thread, + use_system_ca: opts.use_system_ca, + use_system_ca_flag: opts.use_system_ca_flag, ..Default::default() }; let vm = Self::init(init_opts)?; @@ -3941,6 +4064,8 @@ impl VirtualMachine { smol: opts.smol, eval_mode: opts.eval, is_main_thread: false, + use_system_ca: opts.use_system_ca, + use_system_ca_flag: opts.use_system_ca_flag, // The global is created with the worker's messaging proxy, context id // and `mini` so the C++ ZigGlobalObject is born with its options wired. worker_ptr: worker.messaging_proxy(), @@ -3991,6 +4116,8 @@ impl VirtualMachine { mini_mode: opts.smol, eval_mode: false, is_main_thread: opts.is_main_thread, + use_system_ca: opts.use_system_ca, + use_system_ca_flag: opts.use_system_ca_flag, ..Default::default() }; // Note: shares the console / log / event-loop wiring with `init`; diff --git a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp index ed823a27192e..7bb248804795 100644 --- a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp +++ b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp @@ -388,7 +388,8 @@ static inline JSC::EncodedJSValue jsBroadcastChannelPrototypeFunction_refBody(JS UNUSED_PARAM(throwScope); UNUSED_PARAM(callFrame); auto& impl = castedThis->wrapped(); - RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.jsRef(lexicalGlobalObject); }))); + impl.jsRef(lexicalGlobalObject); + RELEASE_AND_RETURN(throwScope, JSValue::encode(castedThis)); } JSC_DEFINE_HOST_FUNCTION(jsBroadcastChannelPrototypeFunction_ref, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index a4b39d1b9df8..2b041687a2c0 100644 --- a/src/jsc/bindings/webcore/Worker.cpp +++ b/src/jsc/bindings/webcore/Worker.cpp @@ -26,6 +26,7 @@ #include "config.h" #include "Worker.h" +#include "JSWorker.h" #include "BunClientData.h" #include "ErrorCode.h" @@ -127,6 +128,16 @@ void Worker::setKeepAlive(bool keepAlive) m_contextProxy->setKeepAlive(keepAlive); } +std::optional Worker::hasRef() const +{ + return m_contextProxy->hasRef(); +} + +bool Worker::eventLoopUtilization(double& elapsedMs, double& idleMs) +{ + return m_contextProxy->eventLoopUtilization(elapsedMs, idleMs); +} + void Worker::dispatchEvent(Event& event) { if (m_wasTerminated || !m_contextProxy->hasPendingActivity()) @@ -170,6 +181,11 @@ extern "C" void WebWorker__entrySettled(Zig::GlobalObject* globalObject) CLEAR_IF_EXCEPTION(scope); } +extern "C" void WebWorker__workerThreadStarted(WorkerMessagingProxy* proxy) +{ + proxy->workerThreadStarted(); +} + extern "C" void WebWorker__workerGlobalScopeStarted(WorkerMessagingProxy* proxy, Zig::GlobalObject* globalObject) { WebWorker__entrySettled(globalObject); @@ -203,6 +219,34 @@ extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker JSC_DECLARE_HOST_FUNCTION(jsFunctionSetParentPort); +// node:worker_threads internals that read a Worker's native state; private (handed to the module +// through createNodeWorkerThreadsBinding), not properties of the web Worker. +JSC_DEFINE_HOST_FUNCTION(jsFunctionWorkerHasRef, (JSGlobalObject*, CallFrame* callFrame)) +{ + auto* worker = dynamicDowncast(callFrame->argument(0)); + if (!worker) + return JSValue::encode(jsUndefined()); + auto hasRef = worker->wrapped().hasRef(); + return JSValue::encode(hasRef ? jsBoolean(*hasRef) : jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsFunctionWorkerEventLoopUtilization, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(lexicalGlobalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* worker = dynamicDowncast(callFrame->argument(0)); + double elapsedMs = 0; + double idleMs = 0; + if (!worker || !worker->wrapped().eventLoopUtilization(elapsedMs, idleMs)) + return JSValue::encode(jsNull()); + auto* result = constructEmptyArray(lexicalGlobalObject, nullptr, 2); + RETURN_IF_EXCEPTION(scope, {}); + result->putDirectIndex(lexicalGlobalObject, 0, jsNumber(elapsedMs)); + RETURN_IF_EXCEPTION(scope, {}); + result->putDirectIndex(lexicalGlobalObject, 1, jsNumber(idleMs)); + RELEASE_AND_RETURN(scope, JSValue::encode(result)); +} + JSC_DEFINE_HOST_FUNCTION(jsReceiveMessageOnPort, (JSGlobalObject * lexicalGlobalObject, CallFrame* callFrame)) { auto& vm = JSC::getVM(lexicalGlobalObject); @@ -344,7 +388,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) bool isNodeWorker = proxy && proxy->options().kind == WorkerOptions::Kind::Node; - JSObject* array = constructEmptyArray(globalObject, nullptr, 12); + JSObject* array = constructEmptyArray(globalObject, nullptr, 14); RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, 0, workerData); array->putDirectIndex(globalObject, 1, threadId); @@ -358,6 +402,8 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) array->putDirectIndex(globalObject, 9, JSFunction::create(vm, globalObject, 1, "setEntryEvaluatedHook"_s, jsFunctionSetEntryEvaluatedHook, ImplementationVisibility::Public, NoIntrinsic)); array->putDirectIndex(globalObject, 10, jsBoolean(isNodeWorker)); array->putDirectIndex(globalObject, 11, JSFunction::create(vm, globalObject, 1, "setParentPort"_s, jsFunctionSetParentPort, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 12, JSFunction::create(vm, globalObject, 1, "workerHasRef"_s, jsFunctionWorkerHasRef, ImplementationVisibility::Public, NoIntrinsic)); + array->putDirectIndex(globalObject, 13, JSFunction::create(vm, globalObject, 1, "workerEventLoopUtilization"_s, jsFunctionWorkerEventLoopUtilization, ImplementationVisibility::Public, NoIntrinsic)); return array; } diff --git a/src/jsc/bindings/webcore/Worker.h b/src/jsc/bindings/webcore/Worker.h index eaf0f3fcd957..09ae54717827 100644 --- a/src/jsc/bindings/webcore/Worker.h +++ b/src/jsc/bindings/webcore/Worker.h @@ -64,6 +64,11 @@ class Worker final : public RefCounted, public EventTargetWithInlineData bool hasExited() const { return m_contextProxy->isClosingOrClosed(); } bool isOnline() const { return m_contextProxy->isOnline(); } void setKeepAlive(bool); + // Whether this Worker keeps the parent's event loop alive; nullopt once the thread is released. + std::optional hasRef() const; + // `[elapsedSinceLoopStartMs, idleMs]` of the worker's loop, read live from this (the parent) + // thread. False once the thread has gone (node reports all-zero then). + bool eventLoopUtilization(double& elapsedMs, double& idleMs); // Node worker_threads: 'message'/'error'/'messageerror' are not delivered once terminate() was // called; 'close' (which carries the exit code) always is. diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp index 926b113ba152..a431075100c9 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.cpp @@ -26,6 +26,7 @@ #include "config.h" #include "WorkerMessagingProxy.h" +#include #include "BunClientData.h" #include "GlobalEventScope.h" @@ -65,12 +66,16 @@ void* WebWorker__create( bool defaultExecArgv, StringImpl** execArgvPtr, size_t execArgvLen, + // NODE_USE_SYSTEM_CA as seen by the worker's own `env` option: 1 / 0, or -1 when it inherits the env. + int8_t envUseSystemCa, BunString* preloadModulesPtr, size_t preloadModulesLen); // Raise a TerminationException in the worker VM at its next safepoint and wake its loop. Any thread. void WebWorker__requestTermination(void*); // Toggle the keep-alive this worker holds on the parent event loop. Parent thread. void WebWorker__setRef(void*, bool); +bool WebWorker__hasRef(void* worker); +bool WebWorker__getELU(void* worker, double* elapsedMs, double* idleMs); // Release that keep-alive. Parent thread. void WebWorker__releaseParentPollRef(void*); // Block until the OS thread has exited. Parent thread, after the worker reported destroyed or was @@ -137,6 +142,12 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr }) .value_or(std::span {}); + int8_t envUseSystemCa = -1; + if (m_options.env) { + auto it = m_options.env->find("NODE_USE_SYSTEM_CA"_s); + envUseSystemCa = it != m_options.env->end() && it->value == "1"_s ? 1 : 0; + } + // The thread holds a ref on the proxy until releaseWorkerThread(). ref(); BunString errorMessage = BunStringEmpty; @@ -156,6 +167,7 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr !m_options.execArgv.has_value(), execArgv.data(), execArgv.size(), + envUseSystemCa, preloadModules.begin(), preloadModules.size()); m_options.preloadModules.clear(); @@ -184,6 +196,24 @@ void WorkerMessagingProxy::setKeepAlive(bool keepAlive) WebWorker__setRef(m_workerThread, keepAlive); } +std::optional WorkerMessagingProxy::hasRef() const +{ + ASSERT(!m_scriptExecutionContext || m_scriptExecutionContext->isContextThread()); + if (!m_workerThread) + return std::nullopt; + return WebWorker__hasRef(m_workerThread); +} + +bool WorkerMessagingProxy::eventLoopUtilization(double& elapsedMs, double& idleMs) +{ + ASSERT(!m_scriptExecutionContext || m_scriptExecutionContext->isContextThread()); + // The proxy holds a ref on the thread object until releaseWorkerThread(), so it is readable + // here; whether its VM is still there is answered under the thread's own lock. + if (!m_workerThread) + return false; + return WebWorker__getELU(m_workerThread, &elapsedMs, &idleMs); +} + void WorkerMessagingProxy::workerObjectDestroyed() { ASSERT(!m_scriptExecutionContext || m_scriptExecutionContext->isContextThread()); @@ -221,6 +251,7 @@ bool WorkerMessagingProxy::postTaskToWorkerGlobalScope(Functionm_workerObject; + if (!workerObject || !workerObject->hasEventListeners(eventNames().openEvent)) + return; + workerObject->dispatchEvent(Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No)); + }); +} + void WorkerMessagingProxy::workerGlobalScopeStarted(Zig::GlobalObject& workerGlobalObject) { auto& context = *workerGlobalObject.scriptExecutionContext(); ASSERT(context.identifier() == m_workerContextIdentifier); - // Pending -> Running under the lock postTaskToWorkerGlobalScope() takes, and before 'online' is - // posted: a parent-side 'online' handler may immediately post a task and must find Running. + // Started -> Running under the lock postTaskToWorkerGlobalScope() takes, so a task is either + // queued here (and run below) or posted directly, never lost. Deque> pendingTasks; { Locker lock { m_pendingTasksLock }; @@ -368,13 +416,6 @@ void WorkerMessagingProxy::workerGlobalScopeStarted(Zig::GlobalObject& workerGlo pendingTasks = std::exchange(m_pendingTasks, {}); } - ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }](ScriptExecutionContext&) { - RefPtr workerObject = protectedThis->m_workerObject; - if (!workerObject || !workerObject->hasEventListeners(eventNames().openEvent)) - return; - workerObject->dispatchEvent(Event::create(eventNames().openEvent, Event::CanBubble::No, Event::IsCancelable::No)); - }); - // Tasks and messages that arrived while Pending. If the entry module installed a 'message' // listener they run now; otherwise on the next tick, so a listener added right after startup // (the common `parentPort.on('message')` in an async callback) still sees them. @@ -413,18 +454,41 @@ void WorkerMessagingProxy::postMessageToWorkerObject(MessageWithMessagePorts&& m } } -void WorkerMessagingProxy::postMessageErrorToWorkerObject(String&& message) +void WorkerMessagingProxy::postMessageErrorToWorkerObject(String&& message, String&& code) { - ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }, message = WTF::move(message).isolatedCopy()](ScriptExecutionContext&) { + ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }, message = WTF::move(message).isolatedCopy(), code = WTF::move(code).isolatedCopy()](ScriptExecutionContext& context) { RefPtr workerObject = protectedThis->m_workerObject; if (!workerObject) return; ErrorEvent::Init init; init.message = message; + // The thrown value could not be cloned; `code` is all the parent can otherwise recover of it. + if (!code.isNull()) { + auto* globalObject = context.globalObject(); + auto& vm = JSC::getVM(globalObject); + auto* carrier = JSC::createError(globalObject, message); + carrier->putDirect(vm, WebCore::builtinNames(vm).codePublicName(), JSC::jsString(vm, code)); + init.error = carrier; + } workerObject->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); }); } +// A string `error.code` on the thrown value, read without leaving an exception behind. +static String errorCodeOf(JSC::JSGlobalObject& globalObject, JSC::JSValue value) +{ + auto& vm = JSC::getVM(&globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + if (!value.isObject() || scope.exception()) + return {}; + JSC::JSValue codeValue = value.getObject()->getIfPropertyExists(&globalObject, WebCore::builtinNames(vm).codePublicName()); + String code; + if (!scope.exception() && codeValue && codeValue.isString()) + code = codeValue.toWTFString(&globalObject); + CLEAR_IF_EXCEPTION(scope); + return code; +} + bool WorkerMessagingProxy::postSerializedErrorToWorkerObject(Zig::GlobalObject& workerGlobalObject, JSC::JSValue value) { // Top of the worker's error-dispatch stack: neither the structured clone (which can run script @@ -434,18 +498,23 @@ bool WorkerMessagingProxy::postSerializedErrorToWorkerObject(Zig::GlobalObject& auto serialized = SerializedScriptValue::create(workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); CLEAR_IF_EXCEPTION(scope); + // Cloning an Error reads `stack`; when that getter throws (a throwing Error.prepareStackTrace), + // Node drops only `stack` (lib/internal/error_serdes.js TryGetAllProperties) rather than the + // whole error, so retry once with an own undefined `stack` that cannot run the getter again. + if (!serialized && !vm.hasPendingTerminationException()) { + if (auto* errorInstance = dynamicDowncast(value)) { + errorInstance->putDirect(vm, vm.propertyNames->stack, JSC::jsUndefined(), static_cast(JSC::PropertyAttribute::DontEnum)); + errorInstance->setStackPropertyAlreadyMaterialized(); + serialized = SerializedScriptValue::create(workerGlobalObject, value, SerializationForStorage::No, SerializationErrorMode::NonThrowing); + CLEAR_IF_EXCEPTION(scope); + } + } if (!serialized) return false; // Structured clone keeps only the standard Error fields; Node's worker 'error' event also // preserves a string `error.code` (lib/internal/error_serdes.js). - String errorCode; - if (value.isObject()) { - JSC::JSValue codeValue = value.getObject()->getIfPropertyExists(&workerGlobalObject, WebCore::builtinNames(vm).codePublicName()); - if (!scope.exception() && codeValue && codeValue.isString()) - errorCode = codeValue.toWTFString(&workerGlobalObject); - CLEAR_IF_EXCEPTION(scope); - } + String errorCode = errorCodeOf(workerGlobalObject, value); return ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, [protectedThis = Ref { *this }, serialized = serialized.releaseNonNull(), errorCode = WTF::move(errorCode).isolatedCopy()](ScriptExecutionContext& context) { RefPtr workerObject = protectedThis->m_workerObject; @@ -470,11 +539,11 @@ void WorkerMessagingProxy::postErrorToWorkerObject(Zig::GlobalObject& workerGlob { switch (m_options.kind) { case WorkerOptions::Kind::Web: - postMessageErrorToWorkerObject(String { message }); + postMessageErrorToWorkerObject(String { message }, {}); return; case WorkerOptions::Kind::Node: if (!postSerializedErrorToWorkerObject(workerGlobalObject, error)) - postMessageErrorToWorkerObject(String { message }); + postMessageErrorToWorkerObject(String { message }, errorCodeOf(workerGlobalObject, error)); return; } } diff --git a/src/jsc/bindings/webcore/WorkerMessagingProxy.h b/src/jsc/bindings/webcore/WorkerMessagingProxy.h index 938b5d0cbfcf..066d0a8206f4 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.h +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.h @@ -70,7 +70,8 @@ class WorkerMessagingProxy final : public ThreadSafeRefCounted&&); void setKeepAlive(bool); + // Whether the thread keeps the parent's loop alive; nullopt once the thread is released + // (node: the handle is gone and hasRef() reads back undefined). + std::optional hasRef() const; + bool eventLoopUtilization(double& elapsedMs, double& idleMs); void workerObjectDestroyed(); // The parent context is exiting: the thread has been asked to stop; wait for it and release what // workerGlobalScopeDestroyedInternal() would have released. Parent thread. @@ -92,13 +97,22 @@ class WorkerMessagingProxy final : public ThreadSafeRefCounted= State::Closing; } uint64_t registerCrossVMRequest(JSC::VM&, JSC::JSPromise*); JSC::Strong takeCrossVMRequest(uint64_t id); // -- WorkerObjectProxy / WorkerReportingProxy (worker thread) --------------------------------- + // The VM is up; the entry point loads next. Posts 'online' (node reports it before user code). + void workerThreadStarted(); + // The entry point has evaluated (up to its first top-level await): tasks and messages that + // arrived meanwhile are delivered and later ones routed directly. void workerGlobalScopeStarted(Zig::GlobalObject&); void postMessageToWorkerObject(MessageWithMessagePorts&&); void postErrorToWorkerObject(Zig::GlobalObject&, const String& message, JSC::JSValue error); @@ -126,7 +140,7 @@ class WorkerMessagingProxy final : public ThreadSafeRefCounted Option<*mut uws::Loop> { + #[cfg(windows)] + { + self.uws_loop.map(|l| l.as_ptr()) + } + #[cfg(not(windows))] + { + self.vm_ref().event_loop_handle + } + } + pub fn usockets_loop(&self) -> *mut uws::Loop { // Panic on null rather than returning it — callers immediately // materialize `&mut *`, so a null return would be instant UB instead diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 3a1ae1cce08f..27a4a1f1b09a 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -70,9 +70,14 @@ pub struct WebWorker { exec_argv_ptr: *const WTFStringImpl, exec_argv_len: usize, inherit_exec_argv: bool, + /// `NODE_USE_SYSTEM_CA` from the worker's own `env` option (1 / 0), or -1 when it inherits the env. + env_use_system_ca: i8, unresolved_specifier: Box<[u8]>, preloads: Vec>, name: bun_core::ZBox, + /// `--cpu-prof` on the parent applies to workers that inherit its execArgv (as in node, where + /// the flag is per-process); a worker with its own execArgv profiles only if that says so. + parent_cpu_profiler_config: Option, // ---- Cross-thread ---------------------------------------------------------- ref_count: bun_ptr::ThreadSafeRefCount, @@ -84,6 +89,10 @@ pub struct WebWorker { /// after `shutdown()` unpublishes it. Cross-thread readers hold `vm_lock`. vm: Cell<*mut VirtualMachine>, vm_lock: Mutex, + /// The worker's uSockets loop, published/unpublished under `vm_lock` together with `vm`, for the + /// parent's `eventLoopUtilization()` read. Cached rather than reached through the VM because + /// `spawnSync` swaps the VM's loop handle on the worker thread without `vm_lock`. + elu_loop: Cell<*mut bun_uws::Loop>, // ---- Parent-thread only --------------------------------------------------- /// Keep-alive on the parent's event loop: taken in `create()`, toggled by @@ -132,6 +141,7 @@ pub enum Status { // even when C++ mutates through it. `proxy` is the opaque C++ `WorkerMessagingProxy*` // round-tripped from `create()`; it is only ever handed back to C++. unsafe extern "C" { + safe fn WebWorker__workerThreadStarted(proxy: *mut c_void); safe fn WebWorker__workerGlobalScopeStarted(proxy: *mut c_void, global: &JSGlobalObject); safe fn WebWorker__workerGlobalScopeDestroyed( proxy: *mut c_void, @@ -274,6 +284,7 @@ impl WebWorker { inherit_exec_argv: bool, exec_argv_ptr: *const WTFStringImpl, exec_argv_len: usize, + env_use_system_ca: i8, preload_modules_ptr: *const BunString, preload_modules_len: usize, ) -> *mut WebWorker { @@ -341,6 +352,7 @@ impl WebWorker { exec_argv_ptr, exec_argv_len, inherit_exec_argv, + env_use_system_ca, unresolved_specifier: spec_slice.slice().to_vec().into_boxed_slice(), preloads, name: if name_str.is_empty() { @@ -348,10 +360,13 @@ impl WebWorker { } else { name_str.to_owned_slice_z() }, + // SAFETY: `parent` is live (see above); read on the parent's own thread. + parent_cpu_profiler_config: unsafe { (*parent).cpu_profiler_config.clone() }, ref_count: bun_ptr::ThreadSafeRefCount::init(), requested_terminate: AtomicBool::new(false), vm: Cell::new(core::ptr::null_mut()), vm_lock: Mutex::new(), + elu_loop: Cell::new(core::ptr::null_mut()), parent_poll_ref: JsCell::new(KeepAlive::init()), join_handle: JsCell::new(None), status: Cell::new(Status::Start), @@ -457,6 +472,12 @@ impl WebWorker { }); } + /// Whether this worker currently keeps the parent's loop alive (node's `Worker::HasRef`). + #[unsafe(export_name = "WebWorker__hasRef")] + pub(crate) extern "C" fn has_ref(this: &WebWorker) -> bool { + this.with_parent_poll_ref(|poll| poll.is_active()) + } + /// Ask the thread to stop: set `requested_terminate`, raise a /// TerminationException in its VM at the next safepoint, wake its loop. /// Any thread that holds a ref (the proxy) may call this. @@ -497,6 +518,43 @@ impl WebWorker { this.vm_lock.unlock(); } + /// The parent reading this worker's loop counters for `eventLoopUtilization()`: false once the + /// VM is unpublished (node reports all-zero then). Idle is read before elapsed, in node's + /// order, so `active = elapsed - idle` cannot come out negative. + #[unsafe(export_name = "WebWorker__getELU")] + pub extern "C" fn get_elu( + this: &WebWorker, + out_elapsed_ms: &mut f64, + out_idle_ms: &mut f64, + ) -> bool { + this.vm_lock.lock(); + let vm_ptr = this.vm_ptr(); + let loop_ptr = this.elu_loop.get(); + let mut live = !vm_ptr.is_null() && !loop_ptr.is_null(); + if live { + // SAFETY: both published under `vm_lock`, held here; the idle counter, loop start and + // idle base are atomics. Only the two atomic fields are borrowed, never a + // `&VirtualMachine`: the worker thread owns `&mut` to the whole struct. + unsafe { + // Idle before elapsed, so the derived active (elapsed - idle) never dips negative. + let raw_idle_ns = bun_uws::us_loop_idle_ns(loop_ptr); + let elapsed = VirtualMachine::loop_elapsed_ms_from(&(*vm_ptr).loop_start_ns); + match elapsed { + Some(elapsed_ms) => { + *out_idle_ms = VirtualMachine::loop_idle_ms_from( + &(*vm_ptr).loop_idle_base_ns, + raw_idle_ns, + ); + *out_elapsed_ms = elapsed_ms; + } + None => live = false, + } + } + } + this.vm_lock.unlock(); + live + } + /// The parent is releasing this thread: drop the keep-alive on the parent's /// loop and forget it as a child. Parent thread. #[unsafe(export_name = "WebWorker__releaseParentPollRef")] @@ -520,6 +578,11 @@ impl WebWorker { self.execution_context_id } + /// The `worker.threadId` node exposes (context ids start at 1 on the main thread). + pub(crate) fn thread_id(&self) -> u32 { + self.execution_context_id.saturating_sub(1) + } + /// The C++ `WorkerMessagingProxy`, handed to `Zig__GlobalObject__create` so /// the worker's global is born knowing its options (env, argv, workerData). #[inline] @@ -609,24 +672,23 @@ impl WebWorker { // and passes the owned struct as `args` to the new VM. let mut transform_options = (*parent.transpiler.options.transform_options).clone(); - if let Some(exec_argv) = self.exec_argv() { - // Parse `execArgv` with the - // RunCommand param table. The param table lives in - // `bun_runtime::cli` (forward-dep), so dispatch through - // `RuntimeHooks::parse_worker_exec_argv_allow_addons`. Currently - // only honours `--no-addons`; the hook owns the temporary UTF-8 - // alloc + clap parse + `args.deinit()`. `None` on parse failure - // (the parent's setting is kept). - - // SAFETY: `exec_argv` borrows C++ `WorkerOptions` kept alive by the - // owning `WebCore::Worker` for `self`'s lifetime; the hook only - // reads the slice and owns its own temporary allocations. - let parsed = unsafe { (hooks.parse_worker_exec_argv_allow_addons)(exec_argv) }; - if let Some(allow_addons) = parsed { - let parent_allows = transform_options.allow_addons.unwrap_or(true); - transform_options.allow_addons = Some(parent_allows && allow_addons); - } + // A worker's own `execArgv` is parsed with the RunCommand param table (in `bun_runtime::cli`, + // hence the hook); a worker without one inherits the parent's per-Environment settings. + let own_exec_argv = self.exec_argv(); + let mut exec_argv: virtual_machine::WorkerExecArgv = match own_exec_argv { + // SAFETY: borrows the proxy's `WorkerOptions`, alive as long as the proxy; read only. + Some(a) => unsafe { (hooks.parse_worker_exec_argv)(a) }, + None => Default::default(), + }; + if let Some(allow_addons) = exec_argv.allow_addons { + let parent_allows = transform_options.allow_addons.unwrap_or(true); + transform_options.allow_addons = Some(parent_allows && allow_addons); } + let use_system_ca_flag = if own_exec_argv.is_some() { + exec_argv.use_system_ca + } else { + parent.use_system_ca_flag + }; // worker-thread only field; no other thread reads `arena`. self.arena.set(Some(bun_alloc::Arena::new())); @@ -652,6 +714,13 @@ impl WebWorker { // Ensure map entries point at the exact bytes we hold refs on. temp_proxy_slots.sync_into(&mut map); + // node_worker.cc: a Worker starts from the parent's resolved option, a custom `env` re-derives + // it from that env, and then the flags of its execArgv (the parent's when it has none) win. + let use_system_ca_base = match self.env_use_system_ca { + -1 => parent.use_system_ca, + v => Some(v == 1), + }; + // `heap::alloc`'d and stashed on `self` so `shutdown()` step 5 reclaims // it on every path — including the early-terminate checkpoint below, // which calls `shutdown()` before the VM exists. @@ -676,6 +745,8 @@ impl WebWorker { env_loader: NonNull::new(loader_ptr), store_fd: self.store_fd, graph: parent.standalone_module_graph, + use_system_ca: use_system_ca_flag.or(use_system_ca_base), + use_system_ca_flag, ..Default::default() }, )?; @@ -702,6 +773,36 @@ impl WebWorker { vm_ref.is_main_thread = false; VirtualMachine::set_is_main_thread_vm(false); vm_ref.on_unhandled_rejection = on_unhandled_rejection; + + // `--cpu-prof` / `--cpu-prof-md` (with -name/-dir/-interval) in this worker's execArgv, + // or the parent's profiling options when the worker has no execArgv of its own, as node's + // per-Environment options work. The profile is written by the VM's exit path. + let profile = if exec_argv.cpu_prof || exec_argv.cpu_prof_md { + let defaults = crate::bun_cpu_profiler::CPUProfilerConfig::default(); + Some(crate::bun_cpu_profiler::CPUProfilerConfig { + name: exec_argv.cpu_prof_name.take().unwrap_or_default(), + dir: exec_argv.cpu_prof_dir.take().unwrap_or_default(), + md_format: exec_argv.cpu_prof_md, + json_format: exec_argv.cpu_prof, + interval: exec_argv.cpu_prof_interval.unwrap_or(defaults.interval), + thread_id: self.thread_id(), + }) + } else if own_exec_argv.is_none() { + self.parent_cpu_profiler_config.as_ref().map(|c| { + crate::bun_cpu_profiler::CPUProfilerConfig { + thread_id: self.thread_id(), + ..c.clone() + } + }) + } else { + None + }; + if let Some(config) = profile { + // The sampling interval is thread-local: set it from this thread. + crate::bun_cpu_profiler::set_sampling_interval(config.interval); + vm_ref.cpu_profiler_config = Some(config); + crate::bun_cpu_profiler::start_cpu_profiler(vm_ref.jsc_vm_mut()); + } } // Publish `vm` now (rather than at the end of startVM) so that: @@ -717,6 +818,9 @@ impl WebWorker { self.vm_lock.lock(); // vm_lock held; this is the publish point. self.vm.set(vm); + // SAFETY: `vm` is valid; `init_worker` installed the thread's uSockets loop on its event loop. + self.elu_loop + .set(unsafe { (*(*vm).event_loop()).usockets_loop() }); self.vm_lock.unlock(); // Post-publish: do NOT re-form `&mut VirtualMachine`. Field/method @@ -792,6 +896,10 @@ impl WebWorker { return self.shutdown(); } + // The thread is up: the parent sees 'online' before the entry point runs, as in node + // (its bootstrap posts UP_AND_RUNNING right before evaluating the entry). + WebWorker__workerThreadStarted(self.messaging_proxy); + // `preloads` is owned by `self` (heap `WebWorker` outlives the VM). // `preload: Vec>` — clone the boxes (cheap, ≤handful). vm.as_mut().preload.clone_from(&self.preloads); @@ -840,6 +948,9 @@ impl WebWorker { // standalone module graph, or `self.unresolved_specifier` — all of // which outlive the worker VM. `vm.main` stores it as a raw BACKREF // (see `VirtualMachine::set_main`); no lifetime extension needed. + // A worker's script runs inside its already-running loop (node's worker bootstrap is a loop + // iteration), so its ELU counts from here; the main thread's counts from its first poll. + vm.mark_loop_started(); let promise = match vm.as_mut().load_entry_point_for_web_worker(path) { Ok(p) => p, Err(_) => { @@ -904,10 +1015,8 @@ impl WebWorker { self.flush_logs(vm); log!("[{}] event loop start", self.execution_context_id); - // Pending -> Running: 'online' is posted to the parent and messages/tasks - // that arrived while the entry point was loading are delivered. After the - // entry point on purpose, so the parent observes 'online' only once the - // worker's top-level code has run (up to its first top-level await). + // Started -> Running: messages and tasks that arrived while the entry point + // was loading are delivered now, and later ones are routed directly. WebWorker__workerGlobalScopeStarted(self.messaging_proxy, vm.global()); self.set_status(Status::Running); @@ -993,6 +1102,7 @@ impl WebWorker { self.vm_lock.lock(); // vm_lock held; this is the unpublish point. let vm_ptr = self.vm.replace(core::ptr::null_mut()); + self.elu_loop.set(core::ptr::null_mut()); self.vm_lock.unlock(); // ---- 2. User exit handlers ----------------------------------------- diff --git a/src/runtime/bake/production.rs b/src/runtime/bake/production.rs index 95aa242fdfa6..9c419f2a1ea1 100644 --- a/src/runtime/bake/production.rs +++ b/src/runtime/bake/production.rs @@ -107,6 +107,12 @@ pub fn build_command(ctx: Context) -> crate::Result<()> { log: NonNull::new(ctx.log), args: ctx.args.clone(), smol: ctx.runtime_options.smol, + // Not `is_main_thread`: that takes the process's one initial script execution context id, + // and a production build creates further globals in this process, so this VM has to draw a + // generated id like the rest. `is_main_thread` is set on the VM below instead; the flag is + // therefore passed explicitly, since init() only derives it for main-thread options. + use_system_ca: crate::cli::Arguments::main_use_system_ca(), + use_system_ca_flag: crate::cli::Arguments::main_use_system_ca(), ..Default::default() })?; // SAFETY: `init_bake` returns a freshly-allocated VM owned by this thread; diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 0ace6e3b893f..56554eec0ae8 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -282,6 +282,9 @@ const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!( "--use-system-ca Use the system's trusted certificate authorities" ), + parse_param!( + "--no-use-system-ca Do not use the system's trusted certificate authorities, overriding $NODE_USE_SYSTEM_CA" + ), parse_param!("--use-openssl-ca Use OpenSSL's default CA store"), parse_param!("--use-bundled-ca Use bundled CA store"), parse_param!("--tls-min-v1.0 Set the default TLS minimum to TLSv1.0"), @@ -726,7 +729,7 @@ pub(crate) static Bun__Node__ProcessPendingDeprecation: core::sync::atomic::Atom core::sync::atomic::AtomicBool::new(false); /// Node parity: `--cpu-prof-name` supports a `${pid}` placeholder. -fn replace_pid_placeholder(name: &[u8]) -> Box<[u8]> { +pub(crate) fn replace_pid_placeholder(name: &[u8]) -> Box<[u8]> { if !bun_core::strings::contains(name, b"${pid}") { return name.into(); } @@ -758,6 +761,37 @@ static Bun__Node__CAStore: core::sync::atomic::AtomicU8 = #[unsafe(no_mangle)] pub(crate) static Bun__Node__UseSystemCA: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); +/// `--no-use-system-ca`: the only thing that beats NODE_USE_SYSTEM_CA. Read by +/// C++ (root_certs.cpp) so connections restrict trust too, not just the +/// getCACertificates() reporting path. +#[unsafe(no_mangle)] +pub(crate) static Bun__Node__NoUseSystemCA: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); + +/// `--use-system-ca` was passed (as opposed to NODE_USE_SYSTEM_CA, which also sets +/// `Bun__Node__UseSystemCA`): only a flag is a per-thread option that workers inherit. +static Bun__Node__UseSystemCAFlag: core::sync::atomic::AtomicBool = + core::sync::atomic::AtomicBool::new(false); + +/// The main thread's explicit CA intent; `None` leaves NODE_USE_SYSTEM_CA to +/// decide (per thread, from that thread's env). `--use-bundled-ca`/`--use-openssl-ca` +/// are deliberately not `Some(false)` — node lets the env var win under those. +pub(crate) fn main_use_system_ca() -> Option { + if Bun__Node__NoUseSystemCA.load(core::sync::atomic::Ordering::Relaxed) { + return Some(false); + } + if Bun__Node__UseSystemCAFlag.load(core::sync::atomic::Ordering::Relaxed) { + return Some(true); + } + None +} + +/// `--use-openssl-ca`: process-wide, as in node. The default store is then OpenSSL's +/// own lookups instead of the bundled roots (root_certs.cpp), so the reporting path +/// has to leave the bundled and system sets out as well. +pub(crate) fn use_openssl_ca() -> bool { + Bun__Node__CAStore.load(core::sync::atomic::Ordering::Relaxed) == BunCAStore::Openssl as u8 +} // ─── bunfig loading ────────────────────────────────────────────────────────── // their private helpers moved to `bun_bunfig::arguments` so `bun_install` can @@ -1458,7 +1492,11 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result) -> crate::Result = if use_bundled_ca { Some(BunCAStore::Bundled) } else if use_openssl_ca { Some(BunCAStore::Openssl) + } else if no_use_system_ca { + Some(BunCAStore::Bundled) } else if use_system_ca || env_var::NODE_USE_SYSTEM_CA.get().unwrap_or(false) { Some(BunCAStore::System) } else { diff --git a/src/runtime/cli/repl_command.rs b/src/runtime/cli/repl_command.rs index 061ac4de2b1a..07f868a0e752 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -75,6 +75,7 @@ impl ReplCommand { // not take a caller-provided allocator in the Rust port; `vm.arena` is // set below). let vm: *mut VirtualMachine = VirtualMachine::init(jsc::VirtualMachineInitOptions { + use_system_ca: crate::cli::Arguments::main_use_system_ca(), transform_options: core::mem::take(&mut ctx.args), debugger: core::mem::take(&mut ctx.runtime_options.debugger), log: core::ptr::NonNull::new(ctx.log), diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index aa99cd121241..c797bf19e778 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -937,6 +937,7 @@ Full documentation is available at https://bun.com/docs/cli/run bun_ast::initialize_store(); let vm_ptr = VirtualMachine::init(VmInitOptions { + use_system_ca: crate::cli::Arguments::main_use_system_ca(), transform_options: ctx.args.clone(), log: ::core::ptr::NonNull::new(ctx.log), debugger: ::core::mem::take(&mut ctx.runtime_options.debugger), @@ -1159,6 +1160,7 @@ Full documentation is available at https://bun.com/docs/cli/run graph: Some(graph_dyn), is_main_thread: true, smol: ctx.runtime_options.smol, + use_system_ca: crate::cli::Arguments::main_use_system_ca(), // `Options::dns_result_order` is `u8` until the // b2-cycle widens it to `bun_dns::Order`; the enum is // `#[repr(u8)]` so `as u8` is exact. @@ -1351,18 +1353,15 @@ impl Run { // ── CPU profiler ──────────────────────────────────────────────────── if ctx.runtime_options.cpu_prof.enabled { let opts = &ctx.runtime_options.cpu_prof; - // SAFETY: `ctx` is process-lifetime; erase `Box<[u8]>` borrows to - // `'static` for `CPUProfilerConfig`. - let name: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.name.as_ref()) }; - // SAFETY: same process-lifetime erasure as `name` above. - let dir: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.dir.as_ref()) }; - vm.cpu_profiler_config = Some(bun_jsc::bun_cpu_profiler::CPUProfilerConfig { - name, - dir, + let config = bun_jsc::bun_cpu_profiler::CPUProfilerConfig { + name: opts.name.clone(), + dir: opts.dir.clone(), md_format: opts.md_format, json_format: opts.json_format, interval: opts.interval, - }); + thread_id: 0, + }; + vm.cpu_profiler_config = Some(config); bun_jsc::bun_cpu_profiler::set_sampling_interval(opts.interval); // SAFETY: `vm.jsc_vm` set in `init`. bun_jsc::bun_cpu_profiler::start_cpu_profiler(unsafe { &mut *vm.jsc_vm }); @@ -1372,7 +1371,7 @@ impl Run { // ── Heap profiler ─────────────────────────────────────────────────── if ctx.runtime_options.heap_prof.enabled { let opts = &ctx.runtime_options.heap_prof; - // SAFETY: `ctx` is process-lifetime; see CPU-profiler note above. + // SAFETY: `ctx` is process-lifetime; erase the `Box<[u8]>` borrow to `'static`. let name: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.name.as_ref()) }; // SAFETY: same process-lifetime erasure as `name` above. let dir: &'static [u8] = unsafe { &*std::ptr::from_ref::<[u8]>(opts.dir.as_ref()) }; diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index a682e399fa12..a0df8362da45 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2287,6 +2287,7 @@ impl TestCommand { // SAFETY: `init` returns the heap-allocated process-lifetime VM; deref once. let vm: &mut VirtualMachine = unsafe { &mut *VirtualMachine::init(jsc::virtual_machine::InitOptions { + use_system_ca: crate::cli::Arguments::main_use_system_ca(), // Clone (not take): ParallelRunner::run_as_coordinator → build_worker_argv // reads ctx.args.{conditions,define,loaders,tsconfig_override,drop, // main_fields,extension_order,env_files,feature_flags,preserve_symlinks, diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 143056933394..6e4034c98410 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -61,17 +61,53 @@ pub use bun_sys_jsc::error_jsc::TestingAPIs::translate_uv_error_to_e as sys_sys_ pub use bun_http_jsc::headers_jsc::h2_live_counts as http_h2_client_testing_ap_is_live_counts; pub use bun_http_jsc::headers_jsc::h3_quic_live_counts as http_h3_client_testing_ap_is_quic_live_counts; -/// Lives here (not in `src/bun.rs`) -/// because the flag it reads — `cli::Arguments::Bun__Node__UseSystemCA` — is -/// owned by `bun_runtime`; placing the body in a lower crate would invert the -/// dependency edge. +/// This thread's resolved `--use-system-ca` decision (see `VirtualMachine::use_system_ca`); +/// `undefined` when nothing decided it, in which case tls.ts falls back to NODE_USE_SYSTEM_CA the +/// way the process default store does: +/// https://github.com/nodejs/node/blob/v26.3.0/src/node_options.cc#L2207 pub(crate) fn bun_get_use_system_ca( _global: &JSGlobalObject, _frame: &CallFrame, ) -> JsResult { - let v = - crate::cli::Arguments::Bun__Node__UseSystemCA.load(core::sync::atomic::Ordering::Relaxed); - Ok(JSValue::js_boolean(v)) + Ok( + match bun_jsc::virtual_machine::VirtualMachine::get().use_system_ca { + Some(v) => JSValue::js_boolean(v), + None => JSValue::UNDEFINED, + }, + ) +} + +/// Process-wide `--use-openssl-ca`, under which the default store holds neither the bundled nor +/// the system roots; `getCACertificates('default')` leaves them out to match, as node's does: +/// https://github.com/nodejs/node/blob/v26.3.0/lib/tls.js#L157 +pub(crate) fn bun_get_use_openssl_ca( + _global: &JSGlobalObject, + _frame: &CallFrame, +) -> JsResult { + Ok(JSValue::js_boolean(crate::cli::Arguments::use_openssl_ca())) +} + +/// `[elapsedSinceLoopStartMs, idleMs]` for THIS thread's loop — the two numbers +/// performance.eventLoopUtilization() is defined in terms of (node derives +/// active as now - loopStart - idle) — or `null` before the loop has begun. +pub(crate) fn bun_get_loop_elu(global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { + let vm = bun_jsc::virtual_machine::VirtualMachine::get(); + // SAFETY: the VM owns this loop (installed by `ensure_waker` before any JS ran; `usockets_loop` + // panics rather than return null) and this runs on its thread. Raw *mut, no &Loop — a + // &mut PosixLoop is live above us via tick_with_timeout for the whole tick. + // Idle before elapsed, so the derived active (elapsed - idle) never dips negative. + let raw_idle_ns = unsafe { bun_uws::us_loop_idle_ns((*vm.event_loop).usockets_loop()) }; + let idle_ms = bun_jsc::virtual_machine::VirtualMachine::loop_idle_ms_from( + &vm.loop_idle_base_ns, + raw_idle_ns, + ); + let Some(elapsed_ms) = vm.loop_elapsed_ms() else { + return Ok(JSValue::NULL); + }; + let arr = JSValue::create_empty_array(global, 2)?; + arr.put_index(global, 0, JSValue::js_number(elapsed_ms))?; + arr.put_index(global, 1, JSValue::js_number(idle_ms))?; + Ok(arr) } mod css { diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 60b81f6a0412..c7765a542d6e 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -272,6 +272,8 @@ pub(crate) unsafe fn runtime_state_of(vm: *mut VirtualMachine) -> *mut RuntimeSt /// # Safety /// `vm` must be the live per-thread VM; called only from the JS thread. pub(crate) unsafe fn default_client_ssl_ctx(vm: *mut VirtualMachine) -> *mut bun_uws::SslCtx { + // SAFETY: per fn contract. + let use_system_ca = unsafe { (*vm).tls_use_system_ca_option() }; // SAFETY: per fn contract; `rare_data()` lazy-inits the box. let rare = unsafe { (*vm).rare_data() }; if rare.default_client_ssl_ctx.is_none() { @@ -291,8 +293,14 @@ pub(crate) unsafe fn default_client_ssl_ctx(vm: *mut VirtualMachine) -> *mut bun // weak cache so a `tls.connect()` with default options later resolves // to the same CTX rather than building a second one with the same // digest. The +1 ref returned here is held for the VM's lifetime, so - // the entry never tombstones. - match cache.get_or_create_opts(&Default::default(), &mut err) { + // the entry never tombstones. `use_system_ca` is this thread's + // --use-system-ca decision (per-Environment in node), the same value + // `tls_true_defaults` stamps, so the two resolve to one CTX. + let opts = bun_uws::us_bun_socket_context_options_t { + use_system_ca, + ..Default::default() + }; + match cache.get_or_create_opts(&opts, &mut err) { Some(ctx) => rare.default_client_ssl_ctx = Some(ctx), None => bun_core::Output::panic(format_args!( "default client SSL_CTX init failed: {}", @@ -1541,7 +1549,7 @@ static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { console_print_runtime_object, load_standalone_sourcemap, apply_standalone_runtime_flags, - parse_worker_exec_argv_allow_addons, + parse_worker_exec_argv, stop_cron_for_vm_teardown, cron_clear_all_reload, retroactively_report_discovered_tests, @@ -1581,26 +1589,23 @@ unsafe fn apply_standalone_runtime_flags( crate::run_main::apply_standalone_runtime_flags(unsafe { &mut *transpiler }, graph); } -/// Parse a Worker's `execArgv` against the -/// `RunCommand` param table and return `!args.flag("--no-addons")`, or `None` -/// on parse error. -/// -/// Note: the Rust `bun_clap::parse_ex` port currently constrains -/// `ArgIter<'static>` (parsed values are stored by reference), which would -/// force leaking the per-call UTF-8 copies of `exec_argv`. Spec only ever -/// reads the single `--no-addons` flag from the result (per the in-tree -/// `// TODO: currently this only checks for --no-addons`), so this body scans -/// the converted argv directly with the same `stop_after_positional_at = 1` -/// short-circuit. Full clap routing can return when `ComptimeClap` grows a -/// borrowed-lifetime variant. -/// +/// Parse a Worker's `execArgv`; scans argv directly since `ArgIter<'static>` would leak the UTF-8 copies. /// # Safety -/// Each `WTFStringImpl` in `exec_argv` is a live WTF string (the C++ -/// `Worker::create` array, kept alive for the worker's lifetime). -unsafe fn parse_worker_exec_argv_allow_addons( +/// Each `WTFStringImpl` in `exec_argv` is a live WTF string kept alive for the worker's lifetime. +unsafe fn parse_worker_exec_argv( exec_argv: &[bun_core::WTFStringImpl], -) -> Option { +) -> bun_jsc::virtual_machine::WorkerExecArgv { + use crate::cli::arguments::replace_pid_placeholder; + enum Pending { + None, + Interval, + Name, + Dir, + } + let mut out = bun_jsc::virtual_machine::WorkerExecArgv::default(); let mut no_addons = false; + let mut pending = Pending::None; + let parse_interval = |v: &[u8]| std::str::from_utf8(v).ok().and_then(|s| s.parse().ok()); for &arg in exec_argv { if arg.is_null() { continue; @@ -1608,19 +1613,56 @@ unsafe fn parse_worker_exec_argv_allow_addons( // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. let owned = unsafe { &*arg }.to_owned_slice_z(); let bytes = owned.as_bytes(); - // `stop_after_positional_at = 1` — first non-flag token ends parsing. + match core::mem::replace(&mut pending, Pending::None) { + Pending::None => {} + Pending::Interval => { + out.cpu_prof_interval = parse_interval(bytes); + continue; + } + Pending::Name => { + out.cpu_prof_name = Some(replace_pid_placeholder(bytes)); + continue; + } + Pending::Dir => { + out.cpu_prof_dir = Some(bytes.into()); + continue; + } + } + // execArgv holds no positionals: a bare token is the value of a flag this parser doesn't model + // (`-r ./preload.js`, `--conditions x`), so skip it rather than ending the scan. if bytes.first() != Some(&b'-') { - break; + continue; } if bytes == b"--" { break; } if bytes == b"--no-addons" { no_addons = true; + } else if bytes == b"--use-system-ca" { + out.use_system_ca = Some(true); + } else if bytes == b"--no-use-system-ca" { + out.use_system_ca = Some(false); + } else if bytes == b"--cpu-prof" { + out.cpu_prof = true; + } else if bytes == b"--cpu-prof-md" { + out.cpu_prof_md = true; + } else if bytes == b"--cpu-prof-interval" { + pending = Pending::Interval; + } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-interval=") { + out.cpu_prof_interval = parse_interval(v); + } else if bytes == b"--cpu-prof-name" { + pending = Pending::Name; + } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-name=") { + out.cpu_prof_name = Some(replace_pid_placeholder(v)); + } else if bytes == b"--cpu-prof-dir" { + pending = Pending::Dir; + } else if let Some(v) = bytes.strip_prefix(b"--cpu-prof-dir=") { + out.cpu_prof_dir = Some(v.into()); } } - // Override `allow_addons` unconditionally on successful parse. - Some(!no_addons) + // Override `allow_addons` unconditionally. + out.allow_addons = Some(!no_addons); + out } /// `jsc.API.cron.CronJob.clearAllForVM(vm, .teardown)` — diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index d021b241ea51..079c2c0e32fa 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -358,18 +358,16 @@ mod _impl { } } } - // Node's whole-token aliases are not params, so they never - // land above; an alias takes a value iff its target does. - for (from, to) in crate::cli::arguments::NODE_SHORT_ALIASES { - if set.contains(to) { - bun_core::handle_oom(set.insert(from)); - } - } set }); if let Some(p) = prev { - if MAP.contains(p) { + let takes_value = MAP.contains(p) + || (!seen_run + && crate::cli::arguments::NODE_SHORT_ALIASES + .iter() + .any(|(from, to)| *from == p && MAP.contains(to))); + if takes_value { args.push(BunString::clone_utf8(arg)); prev = Some(arg); continue; diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index a164e34f6915..4760fe9d02f4 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -152,6 +152,7 @@ impl SSLConfigFromJs for SSLConfig { ) -> JsResult> { let mut result = SSLConfig::zero(); // `result` cleanup handled by Drop on error-path `?` + result.use_system_ca = vm.tls_use_system_ca_option(); let mut any = false; if let Some(passphrase) = generated.passphrase.get() { @@ -261,6 +262,13 @@ impl SSLConfigFromJs for SSLConfig { || result.client_renegotiation_limit != 0 || generated.client_renegotiation_window != 0; + // ORed in after `any` is decided: the VM-level CA choice must shape a + // real TLS config's request context without turning a TLS-less object + // into Some(config) (callers treat None as "no TLS here", and the + // no-tls differing-CA case is handled at the fetch call site). + result.requires_custom_request_ctx = + result.requires_custom_request_ctx || vm.tls_use_system_ca_differs_from_process(); + // We don't need to deinit `result` if `any` is false. if any { Ok(Some(result)) } else { Ok(None) } } @@ -271,6 +279,8 @@ impl SSLConfigFromJs for SSLConfig { pub fn tls_true_defaults(vm: &VirtualMachine) -> SSLConfig { let mut cfg = SSLConfig::zero(); cfg.reject_unauthorized = vm.get_tls_reject_unauthorized() as i32; + cfg.use_system_ca = vm.tls_use_system_ca_option(); + cfg.requires_custom_request_ctx = vm.tls_use_system_ca_differs_from_process(); cfg } diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index 7d7da9fa4bdc..a93d414c8745 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -252,7 +252,7 @@ pub(super) mod ffi { pub(crate) fn OPENSSL_sk_num(sk: *const c_void) -> usize; // The process-wide default root store; up-refs before returning, so // the caller owns a reference it must release with X509_STORE_free. - pub(crate) fn us_get_shared_default_ca_store() -> *mut X509_STORE; + pub(crate) fn us_get_shared_default_ca_store(use_system_ca: i32) -> *mut X509_STORE; pub(crate) fn X509_STORE_free(store: *mut X509_STORE); // X509_STORE_CTX lifecycle for issuer lookups; `new` allocates, // `init` borrows the store, `free` releases. Used to extend the peer @@ -557,7 +557,9 @@ pub(super) fn get_peer_certificate( // reference is released after the walk. let mut shared_store: *mut boringssl::X509_STORE = core::ptr::null_mut(); if store.is_null() || ffi::OPENSSL_sk_num(ffi::X509_STORE_get0_objects(store)) == 0 { - shared_store = ffi::us_get_shared_default_ca_store(); + shared_store = ffi::us_get_shared_default_ca_store(i32::from( + bun_jsc::virtual_machine::VirtualMachine::get().tls_use_system_ca(), + )); if !shared_store.is_null() { store = shared_store; } diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 2a41b66e2cc4..9c1985e32bb9 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -792,6 +792,15 @@ fn fetch_impl( return Ok(JSValue::ZERO); } + // No `tls` options, but this thread's --use-system-ca decision differs from the process + // default the HTTP thread's shared client context was built with: give the request a config of + // its own (interned, so all such requests share one cached context). + if ssl_config.is_none() && vm.tls_use_system_ca_differs_from_process() { + ssl_config = Some(ssl_config_intern_for_http( + crate::socket::tls_true_defaults(vm), + )); + } + // unix: string | undefined unix_socket_path = 'extract_unix_socket_path: { let objects_to_try = [ diff --git a/src/uws/lib.rs b/src/uws/lib.rs index bd0c3a6afec9..6bc7ac0e8e96 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -416,7 +416,11 @@ pub mod ssl_wrapper { boring_sys::SSL_VERIFY_PEER, Some(always_continue_verify), ); - if let Some(roots) = NonNull::new(us_get_shared_default_ca_store()) { + // Same roots variant the context was built with (its creator's + // --use-system-ca decision is recorded on the SSL_CTX). + if let Some(roots) = NonNull::new(us_get_shared_default_ca_store( + us_ssl_ctx_use_system_ca(ctx.as_ptr()), + )) { let _ = boring_sys::SSL_set0_verify_cert_store( ssl.as_ptr(), roots.as_ptr(), @@ -1224,8 +1228,11 @@ pub mod ssl_wrapper { /// Process-wide bundled root store from `root_certs.cpp` — built once and /// up_ref'd per consumer so the ~150-cert load happens once total, not per /// CTX. Returns null if root loading fails (treated as "no roots"). - // safe: no args; idempotent lazy init reading a process global — no preconditions. - safe fn us_get_shared_default_ca_store() -> *mut boring_sys::X509_STORE; + // safe: the by-value flag is collapsed to 0/1 before indexing on the C++ side; + // idempotent lazy init of a process global — no preconditions. + safe fn us_get_shared_default_ca_store(use_system_ca: i32) -> *mut boring_sys::X509_STORE; + /// The system-CA decision an SSL_CTX built by usockets was created with. + fn us_ssl_ctx_use_system_ca(ctx: *mut boring_sys::SSL_CTX) -> i32; /// Implemented in uSockets C; reads /// `SSL_get_verify_result` and maps it onto the C `us_bun_verify_error_t`. fn us_ssl_socket_verify_error_from_ssl(ssl: *mut boring_sys::SSL) -> us_bun_verify_error_t; @@ -1258,7 +1265,7 @@ pub mod ssl_wrapper { // loop_data.h) and `struct us_loop_t` (epoll_kqueue.h / libuv.h). Re-exported // from bun_uws_sys so `bun_uws::Loop` and `bun_uws_sys::Loop` are the same // type (bun_io's EventLoopCtxVTable is typed against the uws_sys version). -pub use bun_uws_sys::loop_::{LoopHandler, us_wakeup_loop}; +pub use bun_uws_sys::loop_::{LoopHandler, us_loop_idle_clock_ns, us_loop_idle_ns, us_wakeup_loop}; pub use bun_uws_sys::{InternalLoopData, Loop, NOW_NS_UNKNOWN}; /// Extension methods on the re-exported `bun_uws_sys::InternalLoopData` for the diff --git a/src/uws_sys/InternalLoopData.rs b/src/uws_sys/InternalLoopData.rs index 5add2c03a003..c58d9a380ab4 100644 --- a/src/uws_sys/InternalLoopData.rs +++ b/src/uws_sys/InternalLoopData.rs @@ -37,6 +37,18 @@ pub struct InternalLoopData { pub(crate) nq_head: *mut c_void, #[cfg(windows)] pub quic_timer: *mut Timer, + /// Nanoseconds this loop has spent parked, for eventLoopUtilization(). + /// Mirrors the `#ifndef LIBUS_USE_LIBUV` field in loop_data.h — libuv tracks + /// the same itself via uv_metrics_idle_time. + #[cfg(not(windows))] + pub idle_ns: u64, + /// Monotonic ns the current park began, 0 when not parked. Mirrors + /// loop_data.h — see the layout warning on `idle_ns`. + #[cfg(not(windows))] + pub idle_entry_ns: u64, + /// Seqlock guarding the park-exit update of the two fields above; mirrors loop_data.h. + #[cfg(not(windows))] + pub idle_seq: u64, pub iterator: *mut SocketGroup, pub recv_buf: *mut u8, pub send_buf: *mut u8, diff --git a/src/uws_sys/Loop.rs b/src/uws_sys/Loop.rs index 5120f7d8bb40..57618e5a43f5 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -624,6 +624,8 @@ mod c { #[cfg(windows)] pub(super) fn us_loop_pump(loop_: *mut Loop); pub fn us_wakeup_loop(loop_: *mut Loop); + pub fn us_loop_idle_ns(loop_: *mut Loop) -> u64; + pub safe fn us_loop_idle_clock_ns() -> u64; pub(super) fn uws_loop_addPostHandler(loop_: *mut Loop, ctx: *mut c_void, cb: LoopCtxCb); pub(super) fn uws_loop_addPreHandler(loop_: *mut Loop, ctx: *mut c_void, cb: LoopCtxCb); #[cfg(not(windows))] @@ -650,7 +652,7 @@ mod c { // event-loop thread parks inside it while worker threads call // `us_wakeup_loop` concurrently; routing either through a `&mut self` // receiver would create two live `&mut Loop` to the same singleton (UB). -pub use c::{us_loop_run, us_wakeup_loop}; +pub use c::{us_loop_idle_clock_ns, us_loop_idle_ns, us_loop_run, us_wakeup_loop}; unsafe extern "C" { // safe: no args; frees this thread's lazily-created uws loop if it exists. diff --git a/src/uws_sys/SocketContext.rs b/src/uws_sys/SocketContext.rs index a61ef5541871..7fd531c7d743 100644 --- a/src/uws_sys/SocketContext.rs +++ b/src/uws_sys/SocketContext.rs @@ -124,6 +124,9 @@ pub struct BunSocketContextOptions { pub allow_partial_trust_chain: i32, pub sigalgs: *const c_char, pub ecdh_curve: *const c_char, + /// The context's default root store includes the system CAs: 0 = process default + /// (CLI flags / NODE_USE_SYSTEM_CA), 1 = include, -1 = exclude. See libusockets.h. + pub use_system_ca: i32, } impl Default for BunSocketContextOptions { @@ -155,6 +158,7 @@ impl Default for BunSocketContextOptions { allow_partial_trust_chain: 0, sigalgs: ptr::null(), ecdh_curve: ptr::null(), + use_system_ca: 0, } } } @@ -260,6 +264,7 @@ impl BunSocketContextOptions { h.update(bun_core::bytes_of(&self.allow_partial_trust_chain)); feed_z(&mut h, self.sigalgs); feed_z(&mut h, self.ecdh_curve); + h.update(bun_core::bytes_of(&self.use_system_ca)); let mut out = [0u8; 32]; h.final_(&mut out); out diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 4d5fe51aea7b..df1a4395637d 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -125,6 +125,105 @@ describe.concurrent("--cpu-prof", () => { expect(exitCode).toBe(0); }); + test("--cpu-prof-name is inherited by workers, as node does", async () => { + using dir = tempDir("cpu-prof-name-worker", { + "test.js": ` + const { Worker } = require("node:worker_threads"); + const w = new Worker(\`const end = Date.now() + 100; while (Date.now() < end) {}\`, { eval: true }); + const end = Date.now() + 100; + while (Date.now() < end) {} + await new Promise(r => w.on("exit", r)); + `, + }); + + const customName = "main.cpuprofile"; + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof", "--cpu-prof-name", customName, "test.js"], + cwd: String(dir), + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + const exitCode = await proc.exited; + + const profiles = readdirSync(String(dir)).filter(f => f.endsWith(".cpuprofile")); + expect(profiles).toEqual([customName]); + expect(exitCode).toBe(0); + }); + + // With default names each thread gets its own file, and the tid segment is worker.threadId + // (CPU..