diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000000..a5201f07adbd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# OpenClaw Bun fork changelog + +## Unreleased + +- Integrate 19 pending upstream PRs for filesystem, SQLite, worker, async-hook, HTTP/TLS, module-loader, process, and path compatibility, retaining their original histories and required worker/query-cache prerequisites. +- Resolve interactions between worker and timer hooks, pending HTTPS listen configuration, forceful shutdown after graceful close, and literal filename delimiters in module resolution and lookup paths. diff --git a/docs/runtime/sqlite.mdx b/docs/runtime/sqlite.mdx index 6ff266a5dec1..bed3c16f5917 100644 --- a/docs/runtime/sqlite.mdx +++ b/docs/runtime/sqlite.mdx @@ -649,6 +649,8 @@ const db = new Database(); db.loadExtension("myext"); ``` +Workers can repeat `setCustomSQLite()` with the exact same path. Bun rejects a different path after SQLite loads. + ### `.fileControl(cmd: number, value: any)` diff --git a/packages/bun-types/sqlite.d.ts b/packages/bun-types/sqlite.d.ts index a853cee36fa4..10e134ec4c45 100644 --- a/packages/bun-types/sqlite.d.ts +++ b/packages/bun-types/sqlite.d.ts @@ -341,11 +341,10 @@ declare module "bun:sqlite" { * * @note macOS-only * - * This only works before SQLite is loaded, that is, - * before you call `new Database()`. - * - * It can only be run once because it loads - * the SQLite library into the process. + * The initial call only works before SQLite is loaded, that is, + * before you call `new Database()`. Later calls with the exact same path + * are idempotent so workers can adopt the process-wide selection. + * A different path is rejected after SQLite loads. * * @param path The path to the SQLite library */ diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index cb5d8706a08a..ca9a29f95f40 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -169,6 +169,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; @@ -450,6 +452,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); @@ -961,13 +964,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) { @@ -979,7 +984,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; } @@ -1269,6 +1274,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); @@ -1401,7 +1409,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) @@ -1792,7 +1800,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); } } @@ -3277,6 +3285,23 @@ static int sni_cb(SSL *ssl, int *al, void *arg) { return SSL_TLSEXT_ERR_OK; } +void us_listen_socket_set_ssl_ctx(struct us_listen_socket_t *ls, SSL_CTX *ctx) { + if (ls->ssl_ctx == ctx) return; + + SSL_CTX_up_ref(ctx); + SSL_CTX *previous = ls->ssl_ctx; + ls->ssl_ctx = ctx; + + if (ls->sni) { + SSL_CTX_set_tlsext_servername_callback(ctx, sni_cb); + } + if (ls->on_server_name) { + SSL_CTX_set_select_certificate_cb(ctx, us_select_cert_cb); + } + + if (previous) SSL_CTX_free(previous); +} + int us_listen_socket_add_server_name(struct us_listen_socket_t *ls, const char *hostname_pattern, SSL_CTX *ctx, void *user) { diff --git a/packages/bun-usockets/src/crypto/root_certs.cpp b/packages/bun-usockets/src/crypto/root_certs.cpp index 3a6f010c3bea..d9a50414a622 100644 --- a/packages/bun-usockets/src/crypto/root_certs.cpp +++ b/packages/bun-usockets/src/crypto/root_certs.cpp @@ -1,6 +1,7 @@ #include "./root_certs_header.h" #include "./internal/internal.h" #include +#include #include #include #include @@ -48,22 +49,34 @@ static void us_cert_file_into_bio(void *ctx, const uint8_t *data, size_t len) { // 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) @@ -291,39 +304,49 @@ const us_system_certs_t &us_get_root_system_certs() { 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; } X509_STORE_set_flags(store, X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS); - X509_LAZY_CERT_SET *bundled = us_get_bundled_root_cert_set(); - if (bundled == NULL || !X509_STORE_add_lazy_cert_set(store, bundled)) { - X509_STORE_free(store); - return NULL; - } + // --use-openssl-ca: OpenSSL's default lookups *instead of* the bundled roots, and no system store, as in node's + // NewRootCertStore (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) { + if (!X509_STORE_set_default_paths(store)) { + X509_STORE_free(store); + return NULL; + } + } else { + X509_LAZY_CERT_SET *bundled = us_get_bundled_root_cert_set(); + if (bundled == NULL || !X509_STORE_add_lazy_cert_set(store, bundled)) { + X509_STORE_free(store); + return NULL; + } - // What X509_STORE_set_default_paths(store) trusts: the default certificate file (above) and the default hashed - // certificate directory, which BoringSSL already consults lazily per lookup. - const us_openssl_default_cert_file &file = us_get_openssl_default_cert_file(); - if (file.certs != nullptr && !X509_STORE_add_lazy_cert_set(store, file.certs)) { - X509_STORE_free(store); - return NULL; - } - for (size_t i = 0; file.trusted != nullptr && i < sk_X509_num(file.trusted); i++) { - X509_STORE_add_cert(store, sk_X509_value(file.trusted, i)); - } - for (size_t i = 0; file.crls != nullptr && i < sk_X509_CRL_num(file.crls); i++) { - X509_STORE_add_crl(store, sk_X509_CRL_value(file.crls, i)); - } - X509_LOOKUP *hash_dir = X509_STORE_add_lookup(store, X509_LOOKUP_hash_dir()); - if (hash_dir == NULL) { - X509_STORE_free(store); - return NULL; + // What X509_STORE_set_default_paths(store) trusts: the default certificate file (above) and the default hashed + // certificate directory, which BoringSSL already consults lazily per lookup. + const us_openssl_default_cert_file &file = us_get_openssl_default_cert_file(); + if (file.certs != nullptr && !X509_STORE_add_lazy_cert_set(store, file.certs)) { + X509_STORE_free(store); + return NULL; + } + for (size_t i = 0; file.trusted != nullptr && i < sk_X509_num(file.trusted); i++) { + X509_STORE_add_cert(store, sk_X509_value(file.trusted, i)); + } + for (size_t i = 0; file.crls != nullptr && i < sk_X509_CRL_num(file.crls); i++) { + X509_STORE_add_crl(store, sk_X509_CRL_value(file.crls, i)); + } + X509_LOOKUP *hash_dir = X509_STORE_add_lookup(store, X509_LOOKUP_hash_dir()); + if (hash_dir == NULL) { + X509_STORE_free(store); + return NULL; + } + X509_LOOKUP_add_dir(hash_dir, NULL, X509_FILETYPE_DEFAULT); + ERR_clear_error(); } - X509_LOOKUP_add_dir(hash_dir, NULL, X509_FILETYPE_DEFAULT); - ERR_clear_error(); STACK_OF(X509) *root_extra_cert_instances = us_get_root_extra_cert_instances(); if (root_extra_cert_instances) { @@ -332,7 +355,9 @@ extern "C" X509_STORE *us_get_default_ca_store() { } } - if (us_should_use_system_ca()) { + // `use_system_ca` is the decision of the thread this store is built for (us_resolve_use_system_ca), not only the + // process-wide flag: node makes --use-system-ca a per-Environment option. + if (use_system_ca && !openssl_ca) { const us_system_certs_t &system = us_get_root_system_certs(); if (system.lazy != nullptr && !X509_STORE_add_lazy_cert_set(store, system.lazy)) { X509_STORE_free(store); @@ -346,18 +371,27 @@ extern "C" X509_STORE *us_get_default_ca_store() { 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), so roots parsed for -// one connection's chain are already there for the next. -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), +// so roots parsed for one connection's chain are already there for the next. +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 98620ad6eb08..40fbf0924d2c 100644 --- a/packages/bun-usockets/src/crypto/root_certs_header.h +++ b/packages/bun-usockets/src/crypto/root_certs_header.h @@ -20,5 +20,10 @@ const us_system_certs_t &us_get_root_system_certs(); #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 43d29c89a52b..3dd0e284b12c 100644 --- a/packages/bun-usockets/src/eventing/epoll_kqueue.c +++ b/packages/bun-usockets/src/eventing/epoll_kqueue.c @@ -523,6 +523,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): @@ -541,6 +545,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 32a6ee993a10..02ac68b0589e 100644 --- a/packages/bun-usockets/src/eventing/libuv.c +++ b/packages/bun-usockets/src/eventing/libuv.c @@ -314,6 +314,7 @@ struct us_loop_t *us_create_loop(void *hint, return NULL; } 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 3f7112b8f7da..ad8384fe9353 100644 --- a/packages/bun-usockets/src/internal/internal.h +++ b/packages/bun-usockets/src/internal/internal.h @@ -169,6 +169,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 959a9110204c..ef1f5d0acc44 100644 --- a/packages/bun-usockets/src/internal/loop_data.h +++ b/packages/bun-usockets/src/internal/loop_data.h @@ -69,6 +69,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 ad8974160762..079f8d8afc33 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -398,6 +398,10 @@ struct us_listen_socket_t *us_socket_group_listen_fd(us_socket_group_r group, LIBUS_SOCKET_DESCRIPTOR fd, int backlog, int options, int socket_ext_size, int *error) __attribute__((nonnull(1, 8))); /* ssl_ctx nullable */ void us_listen_socket_close(struct us_listen_socket_t *ls) nonnull_fn_decl; +/* Replaces the default SSL_CTX used by future accepts. Existing sockets keep + * their own SSL_CTX references and continue uninterrupted. */ +void us_listen_socket_set_ssl_ctx(struct us_listen_socket_t *ls, + struct ssl_ctx_st *ssl_ctx) __attribute__((nonnull(1, 2))); /* SNI: tree hangs off the listen socket. ssl_ctx is up_ref'd; user is opaque * (uWS stores a per-domain HttpRouter*). user may be NULL. */ @@ -519,6 +523,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 f9a9b6359fbe..0d2eb1ef7f37 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -121,6 +121,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 +} + /* -1 if the wakeup async cannot be created; nothing is left allocated in loop->data. */ int 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)) { diff --git a/packages/bun-usockets/src/quic.c b/packages/bun-usockets/src/quic.c index 64c11e908ca9..0ed7a873bd41 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); extern struct us_bun_verify_error_t us_ssl_socket_verify_error_from_ssl(SSL *ssl); #define US_QUIC_READ_BUF (16 * 1024) @@ -1196,8 +1197,9 @@ us_quic_socket_context_t *us_create_quic_client_context( X509_VERIFY_PARAM_set_flags(SSL_CTX_get0_param(ssl), X509_V_FLAG_IGNORE_EXPIRED_TRUST_ANCHORS); /* 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 f2444a46bbde..508bb059ae7f 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -87,6 +87,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 { @@ -216,6 +218,33 @@ struct TemplatedApp { return sslCtx; } + bool setSecureContext(SocketContextOptions options, const char *const *additionalCa, unsigned int additionalCaCount) { + if constexpr (!SSL) { + return false; + } else { + enum create_bun_socket_error_t err = CREATE_BUN_SOCKET_ERROR_NONE; + struct ssl_ctx_st *next = us_ssl_ctx_from_options(options, &err); + if (!next) return false; + + for (unsigned int i = 0; i < additionalCaCount; i++) { + if (!us_ssl_ctx_add_ca_cert(next, additionalCa[i])) { + us_internal_ssl_ctx_unref(next); + return false; + } + } + if (httpContext->getSocketContextData()->http2Context) { + us_ssl_ctx_enable_http2_alpn(next, httpContext->getSocketContextData()->allowHttp1); + } + forEachListenSocket([&](us_listen_socket_t *ls) { + us_listen_socket_set_ssl_ctx(ls, next); + }); + + us_internal_ssl_ctx_unref(sslCtx); + sslCtx = next; + return true; + } + } + /* Attaches a "filter" function to track socket connections/disconnections */ TemplatedApp &&filter(MoveOnlyFunction *, int)> &&filterHandler) { httpContext->filter(std::move(filterHandler)); diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index b23f44d5d116..a272c108fabf 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5955,11 +5955,9 @@ pub mod bv2_impl { let decoded: &'static [u8] = unsafe { bun_ptr::detach_lifetime_ref::<[u8]>(self.free_list.last().unwrap()) }; parse.contents_or_fd = parse_task::ContentsOrFd::Contents(decoded); - parse.loader = Some(match data_url.decode_mime_type().category { - bun_http_types::MimeType::Category::Javascript => Loader::Js, - bun_http_types::MimeType::Category::Css => Loader::Css, - bun_http_types::MimeType::Category::Json => Loader::Json, - _ => parse.loader.unwrap_or(Loader::File), + parse.loader = Some(match parse.loader { + Some(loader) if loader != Loader::Dataurl => loader, + _ => crate::options::data_url_loader(parse.path.text), }); } diff --git a/src/bundler/options.rs b/src/bundler/options.rs index 2540706e9d45..35ba69983e29 100644 --- a/src/bundler/options.rs +++ b/src/bundler/options.rs @@ -423,6 +423,14 @@ pub type OpaqueBlob = *mut (); pub use crate::{VmLoaderCtx, VmLoaderCtxKind}; +/// Runtime loader for a `data:` URL, whose MIME type is its type authority. +pub fn data_url_loader(text: &[u8]) -> Loader { + bun_resolver::DataURL::parse_without_check(text) + .ok() + .and_then(|data_url| data_url.loader()) + .unwrap_or(Loader::Tsx) +} + pub(crate) fn normalize_specifier<'a>( jsc_vm: &VmLoaderCtx, slice_: &'a [u8], @@ -432,6 +440,11 @@ pub(crate) fn normalize_specifier<'a>( return (slice, slice, b""); } + // Everything after a data: URL's comma is payload, including `?`. + if strings::has_prefix_comptime(slice, b"data:") { + return (slice, slice, b""); + } + let host = jsc_vm.origin_host(); let opath = jsc_vm.origin_path(); if slice.starts_with(host) { @@ -482,10 +495,19 @@ pub fn get_loader_and_virtual_source<'a>( ) -> Result, GetLoaderAndVirtualSourceErr> { let (normalized_file_path_from_specifier, specifier, query) = normalize_specifier(jsc_vm, specifier_str); - let mut path = Fs::Path::init(normalized_file_path_from_specifier); + let is_data_url = strings::has_prefix_comptime(normalized_file_path_from_specifier, b"data:"); + let mut path = if is_data_url { + Fs::Path::init_with_namespace(normalized_file_path_from_specifier, b"dataurl") + } else { + Fs::Path::init(normalized_file_path_from_specifier) + }; - // SAFETY: loaders() returns a borrow tied to jsc_vm.owner - let mut loader: Option = path.loader(unsafe { &*jsc_vm.loaders() }); + let mut loader: Option = if is_data_url { + Some(data_url_loader(path.text)) + } else { + // SAFETY: loaders() returns a borrow tied to jsc_vm.owner. + path.loader(unsafe { &*jsc_vm.loaders() }) + }; let mut virtual_source: Option<&'a bun_ast::Source> = None; if let Some(eval_source) = jsc_vm.eval_source() { diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 1957363dd686..2c91ec6cfdfc 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -1466,7 +1466,9 @@ impl<'a> Transpiler<'a> { // threaded once `bun_ast::Source.contents` becomes `Cow`. let contents: &'static [u8] = unsafe { bun_ptr::detach_lifetime_ref::<[u8]>(source_backing.as_slice()) }; - break 'brk bun_ast::Source::init_path_string(path.text, contents); + let mut source = bun_ast::Source::init_path_string(path.text, contents); + source.path.namespace = path.namespace; + break 'brk source; } // Thread 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/http_types/MimeType.rs b/src/http_types/MimeType.rs index 8539527f2257..f6a97fac5df7 100644 --- a/src/http_types/MimeType.rs +++ b/src/http_types/MimeType.rs @@ -182,7 +182,7 @@ impl Category { } impl Category { - pub(crate) fn init(str: &[u8]) -> Category { + pub fn init(str: &[u8]) -> Category { if let Some(slash) = strings::index_of_char(str, b'/') { let category = &str[0..slash as usize]; let mut after_slash: &[u8] = if str.len() > slash as usize + 1 { diff --git a/src/js/builtins/BunBuiltinNames.h b/src/js/builtins/BunBuiltinNames.h index b9593398cb56..419f62868117 100644 --- a/src/js/builtins/BunBuiltinNames.h +++ b/src/js/builtins/BunBuiltinNames.h @@ -66,6 +66,7 @@ using namespace JSC; macro(createInternalModuleById) \ macro(createUninitializedArrayBuffer) \ macro(ctimeMs) \ + macro(cwd) \ macro(data) \ macro(decode) \ macro(dest) \ diff --git a/src/js/builtins/ProcessObjectInternals.ts b/src/js/builtins/ProcessObjectInternals.ts index 37501f664d0b..0c46722bd680 100644 --- a/src/js/builtins/ProcessObjectInternals.ts +++ b/src/js/builtins/ProcessObjectInternals.ts @@ -347,6 +347,7 @@ export function initializeNextTickQueue( reportUncaughtExceptionFn, ) { var queue; + var asyncHooksTick; var tickInitHooks; var process; var nextTickQueue = nextTickQueue; @@ -359,7 +360,8 @@ export function initializeNextTickQueue( setup = () => { const { FixedQueue } = require("internal/fixed_queue"); queue = new FixedQueue(); - tickInitHooks = require("internal/async_hooks_tick").tickInitHooks; + asyncHooksTick = require("internal/async_hooks_tick"); + tickInitHooks = asyncHooksTick.tickInitHooks; function processTicksAndRejections() { var tock; @@ -427,24 +429,28 @@ export function initializeNextTickQueue( if (tickInitHooks.length !== 0) { // node fires one TickObject init per process.nextTick() call, at // construction time (before the callback runs). - const asyncHooksTick = require("internal/async_hooks_tick"); const asyncId = asyncHooksTick.newAsyncId(); // Snapshot: enable()/disable() from inside a hook must not affect the // in-flight dispatch (node stages such mutations in tmp_array until // the emit completes). const hooks = tickInitHooks.slice(); - for (let i = 0; i < hooks.length; i++) { - try { - hooks[i](asyncId, "TickObject", 0, tock); - } catch (err) { - // node: a throwing init hook is fatal (fatalError: print + exit 1), - // never surfaced to the process.nextTick() caller. console is a - // user-mutable global, so shield the print; exit regardless. + asyncHooksTick.beginHookDispatch(); + try { + for (let i = 0; i < hooks.length; i++) { try { - console.error(typeof err?.stack === "string" ? err.stack : err); - } catch {} - process.exit(1); + hooks[i](asyncId, "TickObject", 0, tock); + } catch (err) { + // node: a throwing init hook is fatal (fatalError: print + exit 1), + // never surfaced to the process.nextTick() caller. console is a + // user-mutable global, so shield the print; exit regardless. + try { + console.error(typeof err?.stack === "string" ? err.stack : err); + } catch {} + process.exit(1); + } } + } finally { + asyncHooksTick.endHookDispatch(); } } queue.push(tock); @@ -811,6 +817,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.ts b/src/js/internal/async_hooks.ts index 178bd6bd2c08..46f0388da4c4 100644 --- a/src/js/internal/async_hooks.ts +++ b/src/js/internal/async_hooks.ts @@ -1,7 +1,6 @@ // Minimal port of node's lib/internal/async_hooks.js surface for -// --expose-internals consumers (vendored node tests). Bun's createHook is a -// stub whose callbacks never fire; what CAN be tracked honestly is whether -// any hook is currently enabled, which is all enabledHooksExist() reports. +// --expose-internals consumers (vendored node tests). This module tracks +// whether any of Bun's partially supported createHook callbacks are enabled. let activeHooks = 0; function enabledHooksExist() { diff --git a/src/js/internal/async_hooks_tick.ts b/src/js/internal/async_hooks_tick.ts index 8b000fceac8a..07e116055fb5 100644 --- a/src/js/internal/async_hooks_tick.ts +++ b/src/js/internal/async_hooks_tick.ts @@ -1,20 +1,69 @@ -// 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. +// Shared init hooks for TickObject and WORKER resources. Keep this array +// identity stable: process.nextTick captures it once. Timer hooks share the +// async ID generator and deferred hook-mutation boundary below. const tickInitHooks = []; -let nextAsyncId = 1; +const allocateAsyncHooksId = $newRustFunction("runtime/timer/Timer.rs", "internal_bindings.new_async_hooks_id", 0); +let hookDispatchDepth = 0; +let pendingTickInitHooks; +let deferredHookMutations; + +function mutableTickInitHooks() { + if (hookDispatchDepth === 0) return tickInitHooks; + if (pendingTickInitHooks === undefined) { + pendingTickInitHooks = []; + for (var i = 0, n = tickInitHooks.length; i < n; i++) $arrayPush(pendingTickInitHooks, tickInitHooks[i]); + } + return pendingTickInitHooks; +} + +function removeFromArray(array, value) { + for (var i = 0, n = array.length; i < n; i++) { + if (array[i] !== value) continue; + for (var j = i + 1; j < n; j++) array[j - 1] = array[j]; + array.length = n - 1; + return; + } +} export default { tickInitHooks, + addInitHook(hook) { + $arrayPush(mutableTickInitHooks(), hook); + }, + removeInitHook(hook) { + removeFromArray(mutableTickInitHooks(), hook); + }, + beginHookDispatch() { + hookDispatchDepth++; + }, + endHookDispatch() { + if (--hookDispatchDepth !== 0) return; + if (pendingTickInitHooks !== undefined) { + tickInitHooks.length = 0; + for (var i = 0, n = pendingTickInitHooks.length; i < n; i++) { + $arrayPush(tickInitHooks, pendingTickInitHooks[i]); + } + pendingTickInitHooks = undefined; + } + const deferred = deferredHookMutations; + deferredHookMutations = undefined; + if (deferred !== undefined) { + for (var i = 0, n = deferred.length; i < n; i++) deferred[i](); + } + }, + hookDispatchActive() { + return hookDispatchDepth !== 0; + }, + deferHookMutation(callback) { + if (hookDispatchDepth === 0) { + callback(); + } else if (deferredHookMutations === undefined) { + deferredHookMutations = [callback]; + } else { + $arrayPush(deferredHookMutations, callback); + } + }, newAsyncId() { - return ++nextAsyncId; + return allocateAsyncHooksId(); }, }; diff --git a/src/js/internal/http.ts b/src/js/internal/http.ts index 35dc33231283..c96300f6625e 100644 --- a/src/js/internal/http.ts +++ b/src/js/internal/http.ts @@ -37,6 +37,7 @@ const typeSymbol = Symbol("type"); const kAbortController = Symbol.for("kAbortController"); const kInternalSocketData = Symbol.for("::bunternal::"); const serverSymbol = Symbol.for("::bunternal::"); +const setSecureContextSymbol = Symbol("setSecureContext"); const kPendingCallbacks = Symbol("pendingCallbacks"); const kRequest = Symbol("request"); const kCloseCallback = Symbol("closeCallback"); @@ -536,6 +537,7 @@ export { parseProxyUrl, serverSymbol, setMaxHTTPHeaderSize, + setSecureContextSymbol, setServerAppFlags, setServerCustomOptions, tlsSymbol, diff --git a/src/js/internal/net/server.ts b/src/js/internal/net/server.ts new file mode 100644 index 000000000000..ac8dbdf7c12a --- /dev/null +++ b/src/js/internal/net/server.ts @@ -0,0 +1,50 @@ +const { isIPv6 } = require("internal/net/isIP"); + +let dns: typeof import("node:dns"); + +function isIPv6LinkLocal(address: string): boolean { + if (!isIPv6(address)) return false; + + const first = address.$charCodeAt(0) | 0x20; + const second = address.$charCodeAt(1) | 0x20; + const third = address.$charCodeAt(2) | 0x20; + return first === 0x66 && second === 0x65 && (third === 0x38 || third === 0x39 || third === 0x61 || third === 0x62); +} + +function selectListenAddress(addresses: Array<{ address: string; family: number }>) { + // Match Node's lookupAndListen: prefer a routable result when getaddrinfo also returns IPv6 link-local addresses. + for (let i = 0; i < addresses.length; i++) { + if (!isIPv6LinkLocal(addresses[i].address)) return addresses[i]; + } + return addresses[0]; +} + +function lookupListenAddress( + hostname: string, + callback: (err: Error | null, address?: string, family?: number) => void, +) { + if (dns === undefined) dns = require("node:dns"); + dns.lookup(hostname, { all: true }, (err, addresses) => { + if (err) { + callback(err); + return; + } + + const selected = selectListenAddress(addresses); + callback(null, selected.address, selected.family === 6 ? 6 : 4); + }); +} + +function registerListenCallback(server, callback) { + server.once("listening", callback); +} + +function emitListeningEvent(server, ...args) { + return server.emit("listening", ...args); +} + +export default { + emitListeningEvent, + lookupListenAddress, + registerListenCallback, +}; 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/_http_server.ts b/src/js/node/_http_server.ts index ddc6718c9311..e68bdf0e77d0 100644 --- a/src/js/node/_http_server.ts +++ b/src/js/node/_http_server.ts @@ -14,9 +14,11 @@ const { validateLinkHeaderValue, validateBoolean, validateInteger, + validateNumber, validateFunction, validateOneOf, } = require("internal/validators"); +const { kArmHandshakeTimeout } = require("internal/net/symbols"); const { ConnResetException, hasObserver, startPerf, stopPerf, kInternalSendOptions } = require("internal/shared"); const kServerResponseStatistics = Symbol("ServerResponseStatistics"); @@ -24,6 +26,7 @@ const { isPrimary } = require("internal/cluster/isPrimary"); const { kInternalSocketData, serverSymbol, + setSecureContextSymbol, kHandle, kRealListen, tlsSymbol, @@ -55,6 +58,7 @@ const { } = require("internal/http"); const { FakeSocket } = require("internal/http/FakeSocket"); const NumberIsNaN = Number.isNaN; +const { emitListeningEvent, lookupListenAddress, registerListenCallback } = require("internal/net/server"); const { IncomingMessage, kReqShouldKeepAlive } = require("node:_http_incoming"); const { @@ -71,7 +75,11 @@ const { kIncomingMessage } = require("node:_http_common"); let http1Fallback; const kConnectionsCheckingInterval = Symbol("http.server.connectionsCheckingInterval"); const kTrackedConnections = Symbol("http.server.trackedConnections"); +const kPendingDrainClose = Symbol("http.server.pendingDrainClose"); +const kPendingCloseGenerations = Symbol("http.server.pendingCloseGenerations"); +const kListenerGeneration = Symbol("http.server.listenerGeneration"); const kHttpAllowHalfOpen = Symbol("http.server.httpAllowHalfOpen"); +const kListeningId = Symbol("http.server.listeningId"); // node.http trace events ('http.server.request' b/e). The agent module is // only created on the first request, and emission is gated per-request on the @@ -103,11 +111,27 @@ const DateNow = Date.now; let cluster; function emitCloseServer(self: Server) { - callCloseCallback(self); + // Native close promises belong to one listener, while Node's Server close + // spans overlapping listeners and handed-off sockets on the shared object. + // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2439-L2454 + if (!self[kPendingDrainClose]) return; + if (self[serverSymbol] || self[kTrackedConnections].size > 0) return; + const pendingGenerations = self[kPendingCloseGenerations]; + for (const generation of pendingGenerations) { + if (!generation.nativeClosed) return; + } + self[kPendingDrainClose] = false; + pendingGenerations.clear(); + self[kListenerGeneration] = undefined; self.emit("close"); } -function emitCloseNTServer(this: Server) { - process.nextTick(emitCloseServer, this); +function markListenerGenerationClosed(self: Server, generation) { + generation.nativeClosed = true; + generation.server = undefined; + emitCloseServer(self); +} +function emitCloseNTServer(this: Server, generation) { + process.nextTick(markListenerGenerationClosed, this, generation); } function setCloseCallback(self, callback) { @@ -228,7 +252,7 @@ function emitListeningNextTick(self, hostname, port) { // Nothing to announce if close() ran in the same tick as listen(). if (!self[serverSymbol]) return; // Node passes no arguments. The extra ones are a Bun extension. - self.emit("listening", null, hostname, port); + emitListeningEvent(self, null, hostname, port); } function emitListenErrorNextTick(self, err) { @@ -248,11 +272,17 @@ function normalizeServerTls(tls) { return tls; } +function getAdditionalCAOptions(tls) { + const pfxExtraCAs = tls?._pfxExtraCACerts; + if (!pfxExtraCAs?.length || tls.ca != null) return undefined; + return { __proto__: null, ca: pfxExtraCAs }; +} + // Node registers connectionListener on every http.Server so `server.emit("connection", socket)` // works for foreign Duplex sockets. The native listener handles its own sockets end to end; -// this picks up the rest. https://github.com/nodejs/node/blob/main/lib/_http_server.js -function connectionListener(this: Server, socket) { - if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket) return; +// an HTTPS server must TLS-wrap the others before handing them to the HTTP parser. +// https://github.com/nodejs/node/blob/main/lib/_http_server.js +function connectionListenerHTTP1(this: Server, socket) { (http1Fallback ??= require("internal/http1_server_fallback")).connectionListenerHTTP1(this, socket, { http1Options: { IncomingMessage: this[kIncomingMessage], @@ -261,6 +291,62 @@ function connectionListener(this: Server, socket) { }); } +function tlsVersionName(version) { + switch (version) { + case 0x0301: + return "TLSv1"; + case 0x0302: + return "TLSv1.1"; + case 0x0303: + return "TLSv1.2"; + case 0x0304: + return "TLSv1.3"; + default: + return undefined; + } +} + +function connectionListener(this: Server, socket) { + if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket) return; + const tlsOptions = this[tlsSymbol]; + if (!tlsOptions) { + connectionListenerHTTP1.$call(this, socket); + return; + } + + let wrapped; + try { + const { TLSSocket } = require("node:tls"); + wrapped = new TLSSocket(socket, { + ...tlsOptions, + isServer: true, + servername: tlsOptions.serverName, + minVersion: tlsVersionName(tlsOptions.minVersion), + maxVersion: tlsVersionName(tlsOptions.maxVersion), + ALPNProtocols: this.ALPNProtocols, + ALPNCallback: this.ALPNCallback, + SNICallback: this._SNICallback, + }); + } catch (err) { + socket.destroy(); + this.emit("error", err); + return; + } + wrapped.server = this; + wrapped._requestCert = tlsOptions.requestCert; + wrapped._rejectUnauthorized = tlsOptions.rejectUnauthorized; + require("node:net").Server.prototype[kArmHandshakeTimeout].$call(this, wrapped); +} + +function secureConnectionListener(this: Server, socket) { + if (NodeHTTPServerSocket && socket instanceof NodeHTTPServerSocket) return; + connectionListenerHTTP1.$call(this, socket); +} + +function tlsClientErrorListener(this: Server, err, socket) { + if (!this.emit("clientError", err, socket)) socket.destroy(err); +} + function Server(options, callback): void { if (!(this instanceof Server)) return new Server(options, callback); EventEmitter.$call(this); @@ -279,6 +365,9 @@ function Server(options, callback): void { defineHttpAllowHalfOpen(this); this[kInternalSocketData] = undefined; this[kTrackedConnections] = new Set(); + this[kPendingDrainClose] = false; + this[kPendingCloseGenerations] = new Set(); + this[kListenerGeneration] = undefined; this[tlsSymbol] = null; this.noDelay = true; if (typeof options === "function") { @@ -312,12 +401,9 @@ function Server(options, callback): void { } let ca = tlsOptions.ca; - // PKCS#12-embedded CAs extend the trust set; the server path hands raw - // {key, cert, ca} to the native config and has no addCACert hook, so fold - // them into `ca` (mirrors tls.Server.setSecureContext). const pfxExtraCAs = tlsOptions._pfxExtraCACerts; - if (pfxExtraCAs?.length) { - ca = ca == null ? pfxExtraCAs : $isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; + if (pfxExtraCAs?.length && ca != null) { + ca = $isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; } if (ca) { tlsHelpers.throwOnInvalidTLSArray("options.ca", ca); @@ -359,6 +445,11 @@ function Server(options, callback): void { key, cert, ca, + crl: tlsOptions.crl, + allowPartialTrustChain: tlsOptions.allowPartialTrustChain, + sessionTimeout: tlsOptions.sessionTimeout, + sigalgs: tlsOptions.sigalgs, + ecdhCurve: tlsOptions.ecdhCurve, passphrase, secureOptions, minVersion, @@ -366,7 +457,12 @@ function Server(options, callback): void { ciphers: typeof options.ciphers === "string" && options.ciphers ? options.ciphers : undefined, requestCert: options.requestCert, rejectUnauthorized: options.rejectUnauthorized, + _pfxExtraCACerts: pfxExtraCAs, }); + this._SNICallback = options.SNICallback; + const handshakeTimeout = options.handshakeTimeout || 120 * 1000; + validateNumber(handshakeTimeout, "options.handshakeTimeout", 0); + this._handshakeTimeout = handshakeTimeout; } else { this[tlsSymbol] = null; } @@ -375,6 +471,11 @@ function Server(options, callback): void { this[optionsSymbol] = options; storeHTTPOptions.$call(this, options); + if (this[tlsSymbol]) { + this.on("secureConnection", secureConnectionListener); + this.on("tlsClientError", tlsClientErrorListener); + } + if (callback) this.on("request", callback); return this; } @@ -445,11 +546,19 @@ Server.prototype.unref = function () { Server.prototype.closeAllConnections = function () { http1Fallback?.closeAllHttp1Connections(this); + // close() clears the active listener, but its draining connections must + // remain force-closeable through their original native owner. + for (const generation of this[kPendingCloseGenerations]) { + generation.server?.stop(true); + } const server = this[serverSymbol]; if (!server) { return; } + const generation = this[kListenerGeneration]; + if (generation) this[kPendingCloseGenerations].add(generation); this[serverSymbol] = undefined; + this[kPendingDrainClose] = true; clearInterval(this[kConnectionsCheckingInterval]); this.listening = false; @@ -473,6 +582,7 @@ Server.prototype.closeIdleConnections = function () { }; Server.prototype.close = function (optionalCallback?) { + this[kListeningId] = (this[kListeningId] || 0) + 1; const server = this[serverSymbol]; // Node.js's httpServerPreClose clears the connections-checking interval // even when the server was never listening. @@ -483,9 +593,12 @@ Server.prototype.close = function (optionalCallback?) { // Like Node.js's net.Server#close, close() returns the server. return this; } + if (typeof optionalCallback === "function") this.once("close", optionalCallback); + const generation = this[kListenerGeneration]; + if (generation) this[kPendingCloseGenerations].add(generation); this[serverSymbol] = undefined; - if (typeof optionalCallback === "function") setCloseCallback(this, optionalCallback); this.listening = false; + this[kPendingDrainClose] = true; server.closeIdleConnections(); // stop() queues the task that emits 'close', which holds the loop one more turn, as node's uv_close() does. server.stop(); @@ -533,11 +646,77 @@ Server.prototype.address = function () { return this[serverSymbol].address; }; +Server.prototype[setSecureContextSymbol] = function (options) { + validateObject(options, "options"); + const current = this[tlsSymbol]; + if (!current) { + throw $ERR_INVALID_ARG_VALUE("options", options, "server is not configured for TLS"); + } + + const { + processPfxOptions, + validateSecureProtocol, + secureProtocolToVersionRange, + tlsStringToProtocolVersion, + } = require("internal/tls"); + // Match Node's synchronous option validation before publishing a replacement. + require("node:tls").createSecureContext(options); + const tlsOptions = processPfxOptions(options); + let ca = tlsOptions.ca; + const pfxExtraCAs = tlsOptions._pfxExtraCACerts; + if (pfxExtraCAs?.length && ca != null) { + ca = $isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; + } + validateSecureProtocol(tlsOptions.secureProtocol); + const range = secureProtocolToVersionRange(tlsOptions.secureProtocol); + const next = { + ...tlsOptions, + ca, + minVersion: range ? range[0] : tlsStringToProtocolVersion(tlsOptions.minVersion), + maxVersion: range ? range[1] : tlsStringToProtocolVersion(tlsOptions.maxVersion), + serverName: tlsOptions.servername, + requestCert: current.requestCert, + rejectUnauthorized: current.rejectUnauthorized, + }; + this[serverSymbol]?._setNodeHTTPSSecureContext(next, getAdditionalCAOptions(next)); + this[tlsSymbol] = normalizeServerTls(next); +}; + +function startServerListen(server, tls, port, host, socketPath, serverNameHost) { + if (isPrimary) { + server[kRealListen](tls, port, host, socketPath, false, serverNameHost); + return; + } + + if (cluster === undefined) cluster = require("node:cluster"); + + server.once("listening", () => { + // No channel (NODE_UNIQUE_ID inherited by a plain child, or already disconnected): nothing to notify. + if (!process.connected) return; + cluster.worker.state = "listening"; + const address = server.address(); + const isObjectAddress = address !== null && typeof address === "object"; + const boundHost = host && isObjectAddress ? address : null; + const message = { + cmd: "NODE_CLUSTER", + act: "listening", + port: socketPath ? -1 : (isObjectAddress && address.port) || port, + data: null, + address: socketPath ?? (boundHost && boundHost.address) ?? null, + addressType: socketPath ? -1 : boundHost && boundHost.family === "IPv6" ? 6 : 4, + }; + process.send(message, undefined, kInternalSendOptions); + }); + + server[kRealListen](tls, port, host, socketPath, true, serverNameHost); +} + Server.prototype.listen = function () { const server = this; let port, host; let socketPath; - let tls = this[tlsSymbol]; + const initialTls = this[tlsSymbol]; + let tls = initialTls; // This logic must align with: // - https://github.com/nodejs/node/blob/2eff28fb7a93d3f672f80b582f664a7c701569fb/lib/net.js#L274-L307 @@ -582,38 +761,39 @@ Server.prototype.listen = function () { const lastArg = arguments[argc - 1]; if ($isCallable(lastArg)) { // Before the bind, as in node, so a listen() retried from the 'error' handler still calls it. - this.once("listening", lastArg); + registerListenCallback(this, lastArg); } - try { - // listenInCluster - - if (isPrimary) { - server[kRealListen](tls, port, host, socketPath, false); - return this; - } + const listeningId = (this[kListeningId] = (this[kListeningId] || 0) + 1); + const serverNameHost = host; + if (host) { + lookupListenAddress(host, (err, address) => { + if (listeningId !== server[kListeningId]) return; + if (err) { + server.emit("error", err); + return; + } - if (cluster === undefined) cluster = require("node:cluster"); - - server.once("listening", () => { - // No channel (NODE_UNIQUE_ID inherited by a plain child, or already disconnected): nothing to notify. - if (!process.connected) return; - cluster.worker.state = "listening"; - const address = server.address(); - const isObjectAddress = address !== null && typeof address === "object"; - const boundHost = host && isObjectAddress ? address : null; - const message = { - cmd: "NODE_CLUSTER", - act: "listening", - port: socketPath ? -1 : (isObjectAddress && address.port) || port, - data: null, - address: socketPath ?? (boundHost && boundHost.address) ?? null, - addressType: socketPath ? -1 : boundHost && boundHost.family === "IPv6" ? 6 : 4, - }; - process.send(message, undefined, kInternalSendOptions); + try { + // setSecureContext can replace the server's context during lookup. + const currentTls = server[tlsSymbol]; + startServerListen( + server, + currentTls !== initialTls ? currentTls : tls, + port, + address, + socketPath, + serverNameHost, + ); + } catch (err) { + process.nextTick(emitListenErrorNextTick, server, err); + } }); + return this; + } - server[kRealListen](tls, port, host, socketPath, true); + try { + startServerListen(server, tls, port, host, socketPath, serverNameHost); } catch (err) { process.nextTick(emitListenErrorNextTick, server, err); } @@ -621,17 +801,18 @@ Server.prototype.listen = function () { return this; }; -Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort) { +Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort, serverNameHost) { { const ResponseClass = this[optionsSymbol].ServerResponse || ServerResponse; const RequestClass = this[optionsSymbol].IncomingMessage || IncomingMessage; const canUseInternalAssignSocket = ResponseClass?.prototype.assignSocket === ServerResponse.prototype.assignSocket; let server = this; + let listenerGeneration; if (tls) { - this.serverName = tls.serverName || host || "localhost"; + this.serverName = tls.serverName || serverNameHost || host || "localhost"; } - this[serverSymbol] = Bun.serve({ + const bunServer = Bun.serve({ idleTimeout: 0, // nodejs dont have a idleTimeout by default tls, port, @@ -679,7 +860,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort isPipelinedDispatch?: boolean, ) { if (!socket) { - socket = new (getNodeHTTPServerSocket())(server, socketHandle, !!tls); + socket = new (getNodeHTTPServerSocket())(server, socketHandle, !!tls, listenerGeneration); } // Like Node.js's resetSocketTimeout (parserOnIncoming): a new request @@ -1021,7 +1202,7 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort } } - socket.cork(); + socket[kCorkForDispatcher](); if (isPipelined) { // Completion of a queued response is tracked through the pipeline @@ -1048,10 +1229,27 @@ Server.prototype[kRealListen] = function (tls, port, host, socketPath, reusePort return pendingPromise; }, }); + const additionalCAOptions = getAdditionalCAOptions(tls); + if (additionalCAOptions) { + try { + bunServer._setNodeHTTPSSecureContext(tls, additionalCAOptions); + } catch (error) { + bunServer.stop(true); + throw error; + } + } + this[serverSymbol] = bunServer; + const handle = this[serverSymbol]; + listenerGeneration = { + isUnix: !!socketPath, + nativeClosed: false, + server: bunServer, + }; + this[kListenerGeneration] = listenerGeneration; // Bun.serve() has bound and listened by now, so the flag is true at once, as node's getter is. this.listening = true; - getBunServerAllClosedPromise(this[serverSymbol]).$then(emitCloseNTServer.bind(this)); + getBunServerAllClosedPromise(handle).$then(emitCloseNTServer.bind(this, listenerGeneration)); applyServerCustomOptions(this); if (this?._unref) { @@ -1074,8 +1272,8 @@ function applyServerCustomOptions(server: Server) { true, serverLenientFlags(server), typeof server.maxHeaderSize !== "undefined" ? server.maxHeaderSize : getMaxHTTPHeaderSize(), - onServerClientError.bind(server), - onServerConnection.bind(server), + onServerClientError.bind(server, server[kListenerGeneration]), + onServerConnection.bind(server, server[kListenerGeneration]), !!server.httpAllowHalfOpen, ); } @@ -1120,13 +1318,13 @@ function defineHttpAllowHalfOpen(server: Server) { // Native callback fired when the server accepts a connection (for TLS, when // its handshake completes), before any request bytes - like Node.js's // net.Server 'connection' / tls.Server 'secureConnection' events. -function onServerConnection(this: Server, socketHandle) { +function onServerConnection(this: Server, listenerGeneration, socketHandle) { if (socketHandle.duplex) { // Already wrapped (shouldn't happen for a brand-new connection). return; } const isTLS = !!this[tlsSymbol]; - const socket = new (getNodeHTTPServerSocket())(this, socketHandle, isTLS); + const socket = new (getNodeHTTPServerSocket())(this, socketHandle, isTLS, listenerGeneration); // Node's net.Server accept path refuses at maxConnections and emits 'drop'; the native // listener bypasses that, so gate it here. `>` (not Node's `>=`) because the constructor @@ -1207,7 +1405,13 @@ enum HttpParserError { // socketOnError, exactly like Node's onParserExecuteCommon: the server's // 'clientError' listener (or the default handler) decides what to write back // and when to destroy the connection. -function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, rawPacket: ArrayBuffer) { +function onServerClientError( + listenerGeneration, + ssl: boolean, + socket: unknown, + errorCode: number, + rawPacket: ArrayBuffer, +) { const self = this as Server; // A prior request on this keep-alive connection may already have wrapped // the native handle (the native side returns the existing handle); a second @@ -1215,7 +1419,7 @@ function onServerClientError(ssl: boolean, socket: unknown, errorCode: number, r // kTrackedConnections. Reuse it, and only announce genuinely new // connections - the existing duplex already had its 'connection' event. const existingDuplex = (socket as any).duplex; - const nodeSocket = existingDuplex ?? new (getNodeHTTPServerSocket())(self, socket, ssl); + const nodeSocket = existingDuplex ?? new (getNodeHTTPServerSocket())(self, socket, ssl, listenerGeneration); if (!existingDuplex) { nodeSocket.parser = createServerParserShim(nodeSocket); self.emit("connection", nodeSocket); @@ -1310,6 +1514,9 @@ function onReadableStreamEnd() {} function clearUpgradeIncoming(socket) { socket[kUpgradeIncoming] = undefined; } +const kCorkForDispatcher = Symbol("corkForDispatcher"); +const kReleaseDispatcherCork = Symbol("releaseDispatcherCork"); +const kDispatcherCorkDepth = Symbol("dispatcherCorkDepth"); // Node.js hands the connection over to 'connect'/'upgrade' listeners with the // connection-listener set removed (onParserExecuteCommon removes its data/end/ @@ -1318,6 +1525,7 @@ function clearUpgradeIncoming(socket) { function detachSocketListenersForHandoff(socket) { socket.removeListener("error", socketOnError); socket.removeListener("timeout", onNodeHTTPServerSocketTimeout); + socket[kReleaseDispatcherCork](); socket.on("end", onReadableStreamEnd); } function resolveHandoffPromise(promise) { @@ -1477,6 +1685,7 @@ function getNodeHTTPServerSocket() { [kBytesWritten] = 0; [kHandle]; [kUpgradeIncoming] = undefined; + [kDispatcherCorkDepth] = 0; server: Server; _httpMessage; _secureEstablished = false; @@ -1485,7 +1694,8 @@ function getNodeHTTPServerSocket() { _paused = false; #pendingCallback = null; #pendingAbortMessage; - constructor(server: Server, handle, encrypted) { + #resetSupported; + constructor(server: Server, handle, encrypted, listenerGeneration) { // allowHalfOpen: node's connectionListener sockets never auto-end the // writable side on the peer's FIN (CONNECT/Upgrade tunnels stay writable); // net.Socket would otherwise default it to false. @@ -1503,6 +1713,7 @@ function getNodeHTTPServerSocket() { this._readableState.emitClose = true; this._writableState.decodeStrings = true; this.server = server; + this.#resetSupported = !encrypted && !listenerGeneration?.isUnix; this[kHandle] = handle; this._secureEstablished = !!handle?.secureEstablished; handle.onclose = this.#onClose.bind(this); @@ -1552,6 +1763,28 @@ function getNodeHTTPServerSocket() { } } } + [kCorkForDispatcher]() { + if (this[kDispatcherCorkDepth] !== 0) return; + const corkedBefore = this.writableCorked; + this.cork(); + const corkedAfter = this.writableCorked; + if (corkedAfter > corkedBefore) this[kDispatcherCorkDepth] = corkedAfter; + } + [kReleaseDispatcherCork]() { + if (this[kDispatcherCorkDepth] === 0) return; + this[kDispatcherCorkDepth] = 0; + super.uncork(); + } + uncork() { + const dispatcherCorkDepth = this[kDispatcherCorkDepth]; + const result = super.uncork(); + // Caller corks can sit below the dispatcher's cork. Follow partial + // releases down to zero so handoff still removes exactly Bun's cork. + if (dispatcherCorkDepth !== 0) { + this[kDispatcherCorkDepth] = Math.min(dispatcherCorkDepth, this.writableCorked); + } + return result; + } #onDrain() { const handle = this[kHandle]; this[kBytesWritten] = handle ? (handle.response?.getBytesWritten?.() ?? handle.bytesWritten ?? 0) : 0; @@ -1597,14 +1830,29 @@ function getNodeHTTPServerSocket() { // native on_abort has released the pending-request ref. this.#pendingAbortMessage = this._httpMessage; handle.onclose = this.#onCloseForDestroy.bind(this, callback, err); - handle.close(); + if (this.resetAndClosing) { + this.resetAndClosing = false; + handle.reset(); + } else { + handle.close(); + } } #onClose() { + const errored = this.errored; // freeParser equivalent: runs before 'close' listeners so they observe the // released parser (free() invoked, kOnTimeout nulled). releaseServerParserShim(this); this[kHandle] = null; - this.server?.[kTrackedConnections]?.delete(this); + const server = this.server; + const tracked = server?.[kTrackedConnections]; + if (tracked) { + tracked.delete(this); + if (tracked.size === 0 && server[kPendingDrainClose]) { + // The server callback follows this socket's public close event, as + // Node's _connections decrement does from Socket._destroy(). + this.once("close", () => process.nextTick(emitCloseServer, server)); + } + } const timer = this[kSocketTimeoutTimer]; if (timer) { clearTimeout(timer); @@ -1639,6 +1887,10 @@ function getNodeHTTPServerSocket() { const pending = this.#pendingAbortMessage; this.#pendingAbortMessage = undefined; const message = this._httpMessage ?? (pending?.destroyed ? pending : undefined); + const writeFailure = errored ?? $ERR_STREAM_DESTROYED("write"); + if (message) { + failPendingWriteCallbacks(message, writeFailure); + } const req = message?.req; if (req && !req.destroyed && !req[kHandle]?.upgraded) { @@ -1661,7 +1913,7 @@ function getNodeHTTPServerSocket() { // Pipelined responses (and their requests) that were still queued behind // the in-flight response are aborted, like Node.js's socketOnClose // (abortIncoming + abortOutgoing). - abortQueuedPipelinedResponses(this); + abortQueuedPipelinedResponses(this, writeFailure); // Node's server connection socket emits 'close' whenever the TCP // connection closes, even with no request in flight (this also covers @@ -1861,7 +2113,18 @@ function getNodeHTTPServerSocket() { this.address().family = val; } - resetAndDestroy() {} + resetAndDestroy() { + if (this[kHandle]) { + if (!this.#resetSupported) { + throw $ERR_INVALID_HANDLE_TYPE(); + } + this.resetAndClosing = true; + this.destroy(); + } else { + this.destroy($ERR_SOCKET_CLOSED()); + } + return this; + } setKeepAlive(_enable = false, _initialDelay = 0) {} @@ -2518,7 +2781,7 @@ function queuePipelinedResponse(socket, res, isAncient) { // in-flight one, abort them and their requests, like Node.js's socketOnClose // (abortIncoming). Runs from the native socket's close path and from the // http1 fallback's socket 'close' listener. -function abortQueuedPipelinedResponses(socket) { +function abortQueuedPipelinedResponses(socket, error = $ERR_STREAM_DESTROYED("write")) { const pipelined = socket[kPipelinedResponses]; const pipelinedLength = pipelined ? pipelined.length : 0; if (pipelinedLength) { @@ -2526,6 +2789,7 @@ function abortQueuedPipelinedResponses(socket) { for (let i = 0; i < pipelinedLength; i++) { const queuedRes = pipelined[i]; const queuedReq = queuedRes.req; + failQueuedPipelinedWriteCallbacks(queuedRes[kPipelinedQueuedState], error); if (queuedReq && !queuedReq.destroyed) { queuedReq[kHandle] = undefined; if (queuedReq.listenerCount("error") > 0) { @@ -2543,6 +2807,17 @@ function abortQueuedPipelinedResponses(socket) { } } +function failQueuedPipelinedWriteCallbacks(queued, error) { + const ops = queued?.ops; + const length = ops?.length ?? 0; + if (length === 0) return; + queued.ops = []; + for (let i = 0; i < length; i++) { + const callback = ops[i][3]; + if (typeof callback === "function") process.nextTick(callback, error); + } +} + function advanceResponsePipeline(server, socket) { // The previous response on this connection closed it (Connection: close, // HTTP/1.0, maxRequestsPerSocket): like Node.js's resOnFinish, advancing @@ -2563,6 +2838,7 @@ function advanceResponsePipeline(server, socket) { const handle = res[kHandle]; if (res.destroyed || !handle) { + failQueuedPipelinedWriteCallbacks(queued, res.errored ?? socket.errored ?? $ERR_STREAM_DESTROYED("write")); // The queued response was destroyed before it could be sent; the // connection cannot produce a response for this slot, so it is unusable. // Deliberate divergence from Node v26, which assigns the destroyed @@ -2583,6 +2859,7 @@ function advanceResponsePipeline(server, socket) { ) { // The connection is already gone; the socket close path destroys queued // responses, but make sure this (already dequeued) one is not skipped. + failQueuedPipelinedWriteCallbacks(queued, socket.errored ?? $ERR_STREAM_DESTROYED("write")); if (!res.destroyed) { res.destroy(); } @@ -3392,6 +3669,14 @@ ServerResponse.prototype.write = function (chunk, encoding, callback) { this._callPendingCallbacks(); if (callback) { + // A write callback reports that its bytes reached the underlying socket. + // Uncork them before the callback can destroy the response. + handle.flushHeaders(); + if (handle.bufferedAmount > 0) { + this[kPendingCallbacks].push(callback); + handle.onwritable = allowWritesToContinue.bind(this); + return false; + } process.nextTick(callback); } @@ -3753,6 +4038,17 @@ function allowWritesToContinue() { this.emit("drain"); } +function failPendingWriteCallbacks(res, error) { + const callbacks = res[kPendingCallbacks]; + const length = callbacks?.length ?? 0; + if (length === 0) return; + res[kPendingCallbacks] = []; + const failure = error ?? $ERR_STREAM_DESTROYED("write"); + for (let i = 0; i < length; i++) { + process.nextTick(callbacks[i], failure); + } +} + OriginalWriteHeadFn = ServerResponse.prototype.writeHead; OriginalImplicitHeadFn = ServerResponse.prototype._implicitHeader; diff --git a/src/js/node/async_hooks.ts b/src/js/node/async_hooks.ts index bf96b7273bae..07525f3f3a93 100644 --- a/src/js/node/async_hooks.ts +++ b/src/js/node/async_hooks.ts @@ -1,6 +1,6 @@ // Hardcoded module "node:async_hooks" -// Bun is only going to implement AsyncLocalStorage and AsyncResource (partial). -// The other functions are deprecated anyways, and would impact performance too much. +// Bun implements AsyncLocalStorage, part of AsyncResource, and selected +// createHook resource lifecycles. // API: https://nodejs.org/api/async_hooks.html // // JSC has been patched to include a special global variable $asyncContext which is set to @@ -27,6 +27,8 @@ // that shares its tail with every other capture. // const setAsyncHooksEnabled = $newCppFunction("NodeAsyncHooks.cpp", "jsSetAsyncHooksEnabled", 1); +const setAsyncHooksTimerDispatch = $newCppFunction("NodeAsyncHooks.cpp", "jsSetAsyncHooksTimerDispatch", 1); +const queueAsyncHooksMicrotask = $newCppFunction("NodeAsyncHooks.cpp", "jsQueueAsyncHooksMicrotask", 1); const { validateFunction, validateString, validateObject } = require("internal/validators"); // SameValue in pure operators. Node compares stores with the primordial // ObjectIs; capturing Object.is here would still inherit a patch applied @@ -475,13 +477,138 @@ function isEmptyFunction(f: Function) { return /^{\s*}$/.test(str); } -const createHookNotImpl = createWarning( - "async_hooks.createHook is not implemented in Bun. Hooks can still be created but will never be called.", +const createHookPartialWarning = createWarning( + "async_hooks.createHook in Bun emits init for TickObject and WORKER, and init/destroy for timers. " + + "before, after, promiseResolve, and other resource events are not implemented.", true, ); let hasEnabledCreateHook = false; const kHookEnabled = Symbol("kHookEnabled"); + +type TimerHook = { init?: Function; destroy?: Function; hook: object }; +let timerHooks: TimerHook[] = []; +const asyncHooksTick = require("internal/async_hooks_tick"); +let timerDispatchInstalled = false; +let pendingTimerDestroys: number[] | undefined; +let pendingTimerHooks: typeof timerHooks | undefined; + +function fatalTimerHookError(err) { + try { + console.error(typeof err?.stack === "string" ? err.stack : err); + } catch {} + process.exit(1); +} + +function refreshTimerDispatch() { + const needed = timerHooks.length !== 0; + if (needed === timerDispatchInstalled) return; + timerDispatchInstalled = needed; + setAsyncHooksTimerDispatch(needed ? dispatchTimerHook : undefined); +} + +function mutableTimerHooks() { + if (!asyncHooksTick.hookDispatchActive()) return timerHooks; + if (pendingTimerHooks === undefined) { + pendingTimerHooks = []; + for (var i = 0, n = timerHooks.length; i < n; i++) $arrayPush(pendingTimerHooks, timerHooks[i]); + asyncHooksTick.deferHookMutation(applyPendingTimerHooks); + } + return pendingTimerHooks; +} + +function applyPendingTimerHooks() { + if (pendingTimerHooks === undefined) return; + timerHooks = pendingTimerHooks; + pendingTimerHooks = undefined; + refreshTimerDispatch(); +} + +function emitTimerInit(asyncId: number, type: string, resource: object) { + asyncHooksTick.beginHookDispatch(); + try { + for (var i = 0, n = timerHooks.length; i < n; i++) { + const entry = timerHooks[i]; + if (entry.init === undefined) continue; + try { + entry.init.$call(entry.hook, asyncId, type, 0, resource); + } catch (err) { + fatalTimerHookError(err); + } + } + } finally { + asyncHooksTick.endHookDispatch(); + } +} + +function emitTimerDestroy(asyncId: number) { + asyncHooksTick.beginHookDispatch(); + try { + for (var i = 0, n = timerHooks.length; i < n; i++) { + const entry = timerHooks[i]; + if (entry.destroy === undefined) continue; + try { + entry.destroy.$call(entry.hook, asyncId); + } catch (err) { + fatalTimerHookError(err); + } + } + } finally { + asyncHooksTick.endHookDispatch(); + } +} + +function timerDestroyHooksExist() { + for (var i = 0, n = timerHooks.length; i < n; i++) { + if (timerHooks[i].destroy !== undefined) return true; + } + return false; +} + +function flushTimerDestroys() { + const pending = pendingTimerDestroys; + pendingTimerDestroys = undefined; + if (pending === undefined) return; + for (var i = 0, n = pending.length; i < n; i++) emitTimerDestroy(pending[i]); +} + +function queueTimerDestroy(asyncId: number) { + if (pendingTimerDestroys === undefined) { + pendingTimerDestroys = [asyncId]; + queueAsyncHooksMicrotask(flushTimerDestroys); + } else { + $arrayPush(pendingTimerDestroys, asyncId); + } +} + +// Called by the native timer owner. event is 0 for Timeout init, 1 for +// Immediate init, and 2 when either kind reaches a terminal state. +function dispatchTimerHook(event: number, resource: object, asyncId: number) { + if (event === 2) { + if (timerDestroyHooksExist()) queueTimerDestroy(asyncId); + return; + } + + if (timerHooks.length === 0) return; + emitTimerInit(asyncId, event === 0 ? "Timeout" : "Immediate", resource); +} + +function addTimerHook(hook) { + $arrayPush(mutableTimerHooks(), hook); + if (!asyncHooksTick.hookDispatchActive()) refreshTimerDispatch(); +} + +function removeTimerHook(hook) { + const hooks = mutableTimerHooks(); + for (var i = 0, n = hooks.length; i < n; i++) { + if (hooks[i] !== hook) continue; + for (var j = i + 1; j < n; j++) hooks[j - 1] = hooks[j]; + hooks.length = n - 1; + break; + } + if (!asyncHooksTick.hookDispatchActive()) refreshTimerDispatch(); +} + function createHook(hook) { validateObject(hook, "hook"); const { init, before, after, destroy, promiseResolve } = hook; @@ -493,20 +620,27 @@ function createHook(hook) { throw $ERR_ASYNC_CALLBACK("hook.promiseResolve"); let enabledInit; - return { + let timerHookEnabled = false; + let timerHook: TimerHook; + const asyncHook = { enable() { if (init !== undefined && enabledInit === undefined) { - // init is delivered for TickObject resources (process.nextTick); - // other resource types are still unimplemented. + // This init list serves TickObject and WORKER resources; timers + // register separately with their native lifecycle owner below. // 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); - require("internal/async_hooks_tick").tickInitHooks.push(enabledInit); + enabledInit = (asyncId, type, triggerAsyncId, resource) => + init.$call(asyncHook, asyncId, type, triggerAsyncId, resource); + asyncHooksTick.addInitHook(enabledInit); } - if (before !== undefined || after !== undefined || destroy !== undefined || promiseResolve !== undefined) { - createHookNotImpl(hook); + if (!timerHookEnabled && (init !== undefined || destroy !== undefined)) { + timerHookEnabled = true; + addTimerHook(timerHook); + } + if (before !== undefined || after !== undefined || promiseResolve !== undefined) { + createHookPartialWarning(hook); } hasEnabledCreateHook = true; if (!this[kHookEnabled]) { @@ -517,11 +651,13 @@ function createHook(hook) { }, disable() { if (enabledInit !== undefined) { - const hooks = require("internal/async_hooks_tick").tickInitHooks; - const idx = hooks.indexOf(enabledInit); - if (idx !== -1) hooks.splice(idx, 1); + asyncHooksTick.removeInitHook(enabledInit); enabledInit = undefined; } + if (timerHookEnabled) { + timerHookEnabled = false; + removeTimerHook(timerHook); + } if (this[kHookEnabled]) { this[kHookEnabled] = false; require("internal/async_hooks").markHookDisabled(); @@ -529,6 +665,8 @@ function createHook(hook) { return this; }, }; + timerHook = { init, destroy, hook: asyncHook }; + return asyncHook; } const executionAsyncIdNotImpl = createWarning( diff --git a/src/js/node/fs.promises.ts b/src/js/node/fs.promises.ts index 2c0bdf175caf..1b8d518295c1 100644 --- a/src/js/node/fs.promises.ts +++ b/src/js/node/fs.promises.ts @@ -323,7 +323,7 @@ const exports = { return _writeFile(fileHandleOrFdOrPath, ...args); }, readlink: asyncWrap(fs.readlink, "readlink"), - realpath: asyncWrap(fs.realpath, "realpath"), + realpath: asyncWrap(fs.realpathNative, "realpath"), rename: asyncWrap(fs.rename, "rename"), stat: asyncWrap(fs.stat, "stat"), symlink: asyncWrap(fs.symlink, "symlink"), diff --git a/src/js/node/https.ts b/src/js/node/https.ts index 414742fe2955..66751455a840 100644 --- a/src/js/node/https.ts +++ b/src/js/node/https.ts @@ -8,6 +8,7 @@ const { kEmptyObject, once } = require("internal/shared"); const { validateObject } = require("internal/validators"); const { kProxyConfig, checkShouldUseProxy, kWaitForProxyTunnel } = require("internal/http"); const { validateHeaderValue } = require("node:_http_common"); +const { setSecureContextSymbol } = require("internal/http"); const ArrayPrototypeShift = Array.prototype.shift; const ObjectAssign = Object.assign; @@ -520,6 +521,7 @@ function createServer(options, requestListener) { require("node:tls").convertALPNProtocols(optionsALPNProtocols, server); } server.ALPNCallback = options.ALPNCallback; + server.setSecureContext = server[setSecureContextSymbol]; return server; } diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 2b2762a81e2b..65a9f4bbef40 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -42,6 +42,7 @@ import type { TLSSocket } from "node:tls"; const { kTimeout, getTimerDuration } = require("internal/timers"); const { validateFunction, validateNumber, validateAbortSignal, validatePort, validateBoolean, validateInt32, validateString } = require("internal/validators"); // prettier-ignore const { isIPv4, isIPv6, isIP } = require("internal/net/isIP"); +const { emitListeningEvent, lookupListenAddress, registerListenCallback } = require("internal/net/server"); const { kArmHandshakeTimeout, kDestroyOnRead, @@ -3686,6 +3687,71 @@ Server.prototype.getConnections = function getConnections(callback) { return this; }; +function startServerListen( + server, + queryAddress, + queryPort, + queryAddressType, + backlog, + fd, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + flags, + path, + hostname, + onListen, + errorHostname, + errorPort, +) { + try { + let tls; + let TLSSocketClass; + const bunTLS = server[bunTlsSymbol]; + const options = server[bunSocketServerOptions]; + let contexts: Map | null = null; + if (typeof bunTLS === "function") { + [tls, TLSSocketClass] = bunTLS.$call(server, errorPort, errorHostname, false); + options.servername = tls.serverName; + options[kSocketClass] = TLSSocketClass; + contexts = tls.contexts; + if (!tls.requestCert) tls.rejectUnauthorized = false; + } else { + options[kSocketClass] = Socket; + } + + listenInCluster( + server, + queryAddress, + queryPort, + queryAddressType, + backlog, + fd, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + flags, + undefined, + path, + hostname, + tls, + contexts, + onListen, + ); + } catch (err) { + const isUnix = path != null; + process.nextTick( + emitErrorNextTick, + server, + formatListenError(err, isUnix ? path : errorHostname, isUnix ? undefined : errorPort), + ); + } +} + Server.prototype.listen = function listen(port, hostname, onListen) { const argsLength = arguments.length; if (typeof port === "string") { @@ -3845,44 +3911,59 @@ Server.prototype.listen = function listen(port, hostname, onListen) { } if (onListen != null) { - this.once("listening", onListen); + registerListenCallback(this, onListen); } - try { - var tls = undefined; - var TLSSocketClass = undefined; - const bunTLS = this[bunTlsSymbol]; - const options = this[bunSocketServerOptions]; - let contexts: Map | null = null; - if (typeof bunTLS === "function") { - [tls, TLSSocketClass] = bunTLS.$call(this, port, hostname, false); - options.servername = tls.serverName; - options[kSocketClass] = TLSSocketClass; - contexts = tls.contexts; - if (!tls.requestCert) { - tls.rejectUnauthorized = false; - } - } else { - options[kSocketClass] = Socket; - } - - const flags = (ipv6Only === true ? 1 : 0) | (reusePort === true ? 2 : 0); - let queryAddress = null; - let queryPort = port; - let queryAddressType = 4; - if (path) { - queryAddress = path; - queryPort = -1; - queryAddressType = -1; - } else if (typeof fd === "number" && fd >= 0) { - queryPort = null; - queryAddressType = null; - } else if (typeof clusterHost === "string") { - queryAddress = clusterHost; - queryAddressType = isIP(clusterHost) || 4; + const flags = (ipv6Only === true ? 1 : 0) | (reusePort === true ? 2 : 0); + let queryAddress = null; + let queryPort = port; + let queryAddressType = 4; + if (path) { + queryAddress = path; + queryPort = -1; + queryAddressType = -1; + } else if (typeof fd === "number" && fd >= 0) { + queryPort = null; + queryAddressType = null; + } else if (typeof clusterHost === "string") { + queryAddress = clusterHost; + queryAddressType = isIP(clusterHost) || 4; + } + + if (clusterHost && !path && fd === undefined && typeof queryPort === "number" && queryPort >= 0) { + const listeningId = (this[kClusterListeningId] = (this[kClusterListeningId] || 0) + 1); + try { + lookupListenAddress(clusterHost, (err, address, addressType) => { + if (listeningId !== this[kClusterListeningId]) return; + if (err) { + this.emit("error", err); + return; + } + startServerListen( + this, + address, + queryPort, + addressType, + backlog, + fd, + exclusive, + ipv6Only, + reusePort, + readableAll, + writableAll, + flags, + path, + address, + onListen, + clusterHost, + port, + ); + }); + } catch (err) { + process.nextTick(emitErrorNextTick, this, formatListenError(err, clusterHost, port)); } - - listenInCluster( + } else { + startServerListen( this, queryAddress, queryPort, @@ -3895,19 +3976,11 @@ Server.prototype.listen = function listen(port, hostname, onListen) { readableAll, writableAll, flags, - undefined, path, hostname, - tls, - contexts, onListen, - ); - } catch (err) { - const isUnix = path != null; - process.nextTick( - emitErrorNextTick, - this, - formatListenError(err, isUnix ? path : hostname, isUnix ? undefined : port), + hostname, + port, ); } return this; @@ -4060,7 +4133,7 @@ function addServerAbortSignalOption(self, options) { function emitListeningNextTick(self) { if (!self._handle) return; - self.emit("listening"); + emitListeningEvent(self); } const { isPrimary } = require("internal/cluster/isPrimary"); @@ -4091,48 +4164,6 @@ function listenInCluster( // of the places that happens, exclusive or not. if (!isPrimary && cluster === undefined) cluster = require("node:cluster"); - if ( - !isPrimary && - !exclusive && - typeof address === "string" && - address.length > 0 && - typeof port === "number" && - port >= 0 && - isIP(address) === 0 - ) { - const lookupListeningId = (server[kClusterListeningId] = (server[kClusterListeningId] || 0) + 1); - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2259-L2278 - require("node:dns").lookup(address, (err, ip, family) => { - if (lookupListeningId !== server[kClusterListeningId]) return; - if (err) { - // https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L2268-L2269 - server.emit("error", err); - return; - } - listenInCluster( - server, - ip, - port, - family === 6 ? 6 : 4, - backlog, - fd, - exclusive, - ipv6Only, - reusePort, - readableAll, - writableAll, - flags, - options, - path, - hostname, - tls, - contexts, - onListen, - ); - }); - return; - } - if (isPrimary || exclusive) { server[kRealListen]( path, diff --git a/src/js/node/os.ts b/src/js/node/os.ts index 3326d8093d54..f669b3a99a7c 100644 --- a/src/js/node/os.ts +++ b/src/js/node/os.ts @@ -1,4 +1,6 @@ // Hardcoded module "node:os" +const ObjectHasOwn = Object.hasOwn; + var tmpdir = function () { var env = Bun.env; @@ -103,7 +105,13 @@ function bound(binding) { }, freemem: binding.freemem, getPriority: binding.getPriority, - homedir: binding.homedir, + homedir: + process.platform === "win32" + ? binding.homedir + : function () { + const env = Bun.env; + return ObjectHasOwn(env, "HOME") ? env.HOME : binding.homedir(); + }, hostname: binding.hostname, loadavg: binding.loadavg, networkInterfaces: binding.networkInterfaces, diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 60b54f817404..031ced60c680 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 9bb397a2c3d5..fe8ac6ce3c0d 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -1695,22 +1695,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 1658bd7cb4eb..b6f22b64a45e 100644 --- a/src/js/node/worker_threads.ts +++ b/src/js/node/worker_threads.ts @@ -2,8 +2,9 @@ declare const self: typeof globalThis; type WebWorker = InstanceType; const EventEmitter = require("node:events"); +const AsyncContextFrame = require("internal/async_context_frame"); const { SafeMap } = require("internal/primordials"); -const { throwNotImplemented, warnNotImplementedOnce } = require("internal/shared"); +const { throwNotImplemented } = require("internal/shared"); const { validateString, validateObject, @@ -76,6 +77,8 @@ const { 14: MessageChannel, 15: BroadcastChannel, 16: WebWorker, + 17: _workerHasRef, + 18: _workerEventLoopUtilization, } = $cpp("Worker.cpp", "createNodeWorkerThreadsBinding") as [ unknown, number, @@ -97,6 +100,8 @@ const { // instance. This is so that it can emit the `worker` event on the process with the // node:worker_threads instance instead of the Web Worker instance. new (...args: [...ConstructorParameters, nodeWorker: Worker]) => WebWorker, + (worker: WebWorker) => boolean | undefined, + (worker: WebWorker) => [number, number] | null, ]; type NodeWorkerOptions = import("node:worker_threads").WorkerOptions; @@ -104,6 +109,9 @@ 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; +// Looked up here, not in the constructor: diagnostics_channel's registry is a Map, and `new Worker()` +// has to keep working after user code replaces Map.prototype (the tamper tests in worker_threads.test.ts). +const workerThreadsChannel = require("node:diagnostics_channel").channel("worker_threads"); function injectFakeEmitter(Class) { // Per-instance registry mapping each event to (user listener -> wrapper), so @@ -134,7 +142,7 @@ function injectFakeEmitter(Class) { function wrapped(run, listener) { return function (event) { - return listener(run(event)); + return listener.$call(this, run(event)); }; } @@ -201,7 +209,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; @@ -813,6 +821,7 @@ class Worker extends EventEmitter { #stdin; #stdout; #stderr; + #asyncContextFrame; // this is used by terminate(); // either is the exit code if exited, a promise resolving to the exit code, or undefined if we haven't sent .terminate() yet @@ -823,6 +832,7 @@ class Worker extends EventEmitter { constructor(filename: string, options: NodeWorkerOptions = {}) { super(); + this.#asyncContextFrame = AsyncContextFrame.current(); // The `= {}` default only covers undefined; normalize null too so the // option accesses below don't throw on `new Worker(file, null)`. @@ -830,6 +840,7 @@ class Worker extends EventEmitter { this.#name = normalizeWorkerName(options.name); + let evalSource: string | undefined; const builtinsGeneratorHatesEval = "ev" + "a" + "l"[0]; if (options[builtinsGeneratorHatesEval]) { // node requires the source to be a string when eval is set, rather than @@ -840,6 +851,7 @@ class Worker extends EventEmitter { options[builtinsGeneratorHatesEval], "must be false when 'filename' is not a string", ); + evalSource = filename; // eval: the source becomes a blob: URL the worker imports as its entry point. // The URL must outlive the worker: revoked on constructor failure (catch below), // on exit (#onClose), and via urlRevokeRegistry as a GC safety net. @@ -926,7 +938,7 @@ class Worker extends EventEmitter { // user-supplied value so it can't trigger env sharing on its own. options = { ...options, shareEnv: undefined } as NodeWorkerOptions; } - this.#worker = new WebWorker(filename, options as Bun.WorkerOptions, this); + this.#worker = new WebWorker(filename, options as Bun.WorkerOptions, this, evalSource); // Create the readables eagerly so the worker's writev is ack'd even when // worker.stdout/stderr is never touched; only captured streams ref their // port on first read (node's kIncrementsPortRef). @@ -956,17 +968,29 @@ class Worker extends EventEmitter { // `[worker N] ` thread-name metadata event. No-op when tracing is // off — the agent module is a tiny one-time load. require("internal/trace_events").emitWorkerThreadName(options.name, this.#worker.threadId); - this.#worker.addEventListener("close", this.#onClose.bind(this), { once: true }); - this.#worker.addEventListener("error", this.#onError.bind(this)); - this.#worker.addEventListener("open", this.#onOpen.bind(this), { + const inWorkerAsyncContext = listener => event => + AsyncContextFrame.run(this.#asyncContextFrame, listener, this, event); + this.#worker.addEventListener( + "close", + event => { + try { + return AsyncContextFrame.run(this.#asyncContextFrame, this.#onClose, this, event); + } finally { + this.#asyncContextFrame = undefined; + } + }, + { once: true }, + ); + this.#worker.addEventListener("error", inWorkerAsyncContext(this.#onError)); + this.#worker.addEventListener("open", inWorkerAsyncContext(this.#onOpen), { once: true, }); // Messages from parentPort.postMessage() arrive on the public port. Listening // starts the port. Node's setupPortReferencing: the port counts toward the // parent's liveness only while this Worker has 'message' listeners (and // ref()/unref() also touch it, together with the handle). - this.#publicPort.addEventListener("message", this.#onMessage.bind(this)); - this.#publicPort.addEventListener("messageerror", this.#onMessageError.bind(this)); + this.#publicPort.addEventListener("message", inWorkerAsyncContext(this.#onMessage)); + this.#publicPort.addEventListener("messageerror", inWorkerAsyncContext(this.#onMessageError)); this.#publicPort.unref(); const publicPort = this.#publicPort; this.on("newListener", function (this: Worker, name) { @@ -977,8 +1001,8 @@ class Worker extends EventEmitter { }); // A worker may also use the Web Worker global `postMessage()` / `self.onmessage` // pair, which travels through the Worker object itself; surface those too. - this.#worker.addEventListener("message", this.#onMessage.bind(this)); - this.#worker.addEventListener("messageerror", this.#onMessageError.bind(this)); + this.#worker.addEventListener("message", inWorkerAsyncContext(this.#onMessage)); + this.#worker.addEventListener("messageerror", inWorkerAsyncContext(this.#onMessageError)); if (this.#urlToRevoke) { if (!urlRevokeRegistry) { @@ -988,6 +1012,45 @@ class Worker extends EventEmitter { } urlRevokeRegistry.register(this.#worker, this.#urlToRevoke); } + this.#emitAsyncHooksInit(); + if (workerThreadsChannel.hasSubscribers) { + workerThreadsChannel.publish({ worker: this }); + } + } + + #emitAsyncHooksInit() { + const asyncHooksTick = require("internal/async_hooks_tick"); + const { tickInitHooks, newAsyncId } = asyncHooksTick; + 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]; + asyncHooksTick.beginHookDispatch(); + try { + 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); + } + } + } finally { + asyncHooksTick.endHookDispatch(); + } } get threadId() { @@ -1031,17 +1094,15 @@ 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) { + const { internalEventLoopUtilization } = require("internal/perf/event_loop_utilization"); + return internalEventLoopUtilization(_workerEventLoopUtilization(this.#worker), utilization1, utilization2); + } + terminate(callback: unknown) { if (typeof callback === "function") { process.emitWarning( @@ -1207,7 +1268,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/js_parser/fold.rs b/src/js_parser/fold.rs index ac17087f399a..3560a88ed502 100644 --- a/src/js_parser/fold.rs +++ b/src/js_parser/fold.rs @@ -618,11 +618,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O // Inline import.meta.path (full path) return Some(p.new_expr(e_string_init(p.source.path.text), name_loc)); } else if name == b"url" { - // Inline import.meta.url as file:// URL - let bunstr = bun_core::String::from_bytes(p.source.path.text); - let url = p.arena.alloc_slice_copy( - format!("{}", bun_url::file_url_from_string(&bunstr)).as_bytes(), - ); + let url = if p.source.path.is_data_url() { + p.source.path.text + } else { + // Inline import.meta.url as file:// URL + let bunstr = bun_core::String::from_bytes(p.source.path.text); + p.arena.alloc_slice_copy( + format!("{}", bun_url::file_url_from_string(&bunstr)) + .as_bytes(), + ) + }; return Some(p.new_expr(e_string_init(url), name_loc)); } } diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index e19965f5986e..19344dd10e1d 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -14,14 +14,16 @@ 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, } // C++ function declarations @@ -108,7 +110,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(), @@ -147,21 +149,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)?; } @@ -175,10 +177,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]) @@ -190,6 +193,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(); @@ -204,7 +208,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 1bb3babf3493..cd8d2d6ebd8f 100644 --- a/src/jsc/BunHeapProfiler.rs +++ b/src/jsc/BunHeapProfiler.rs @@ -123,7 +123,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/JSGlobalObject.rs b/src/jsc/JSGlobalObject.rs index 11115339f3f0..1090fbd6826f 100644 --- a/src/jsc/JSGlobalObject.rs +++ b/src/jsc/JSGlobalObject.rs @@ -1427,6 +1427,7 @@ extern "C" fn Zig__GlobalObject__resolve( specifier: &BunString, source: &BunString, query: &mut BunString, + split_query: bool, ) { crate::mark_binding(); match VirtualMachine::resolve_maybe_needs_trailing_slash::( @@ -1435,6 +1436,7 @@ extern "C" fn Zig__GlobalObject__resolve( source, Some(query), crate::virtual_machine::ResolveMode::Esm, + split_query, ) { Ok(Ok(path)) => *res = ErrorableString::ok(path), Ok(Err(value)) => *res = ErrorableString::err(value), diff --git a/src/jsc/NodeModuleModule.rs b/src/jsc/NodeModuleModule.rs index c6873982fda1..683618cc0554 100644 --- a/src/jsc/NodeModuleModule.rs +++ b/src/jsc/NodeModuleModule.rs @@ -96,6 +96,7 @@ fn find_path_inner( cur_path, None, crate::virtual_machine::ResolveMode::RequireResolve, + true, )? .ok()) } diff --git a/src/jsc/RuntimeTranspilerStore.rs b/src/jsc/RuntimeTranspilerStore.rs index df08a4a8e78c..b77d563c2b17 100644 --- a/src/jsc/RuntimeTranspilerStore.rs +++ b/src/jsc/RuntimeTranspilerStore.rs @@ -90,6 +90,10 @@ fn dump_source_string_failiable( { return Ok(()); } + // A `data:` URL is not a file path, and it can be longer than a path buffer. + if specifier.starts_with(b"data:") { + return Ok(()); + } let mut holder = BUN_DEBUG_HOLDER.lock(); @@ -323,10 +327,14 @@ impl RuntimeTranspilerStore { // The path text is heap-duplicated here and freed in `reset_for_pool` via // heap::take on `path.text`. let owned_text: *mut [u8] = bun_core::heap::into_raw(Box::<[u8]>::from(path.text)); - // SAFETY: owned_text was just allocated via heap::alloc and lives until - // `reset_for_pool` reconstructs and drops the Box. The unbounded - // lifetime from raw-ptr deref coerces to `'static` for `bun_paths::fs::Path<'static>`. - let owned_path = bun_paths::fs::Path::init(unsafe { &*owned_text.cast_const() }); + // SAFETY: the job owns this Box allocation until `reset_for_pool`; + // its Path borrows the bytes without mutating them. + let owned_text = unsafe { &*owned_text.cast_const() }; + let owned_path = if path.is_data_url() { + bun_paths::fs::Path::init_with_namespace(owned_text, b"dataurl") + } else { + bun_paths::fs::Path::init(owned_text) + }; let promise: *mut JSInternalPromise = JSInternalPromise::create(global_object); // NOTE: DirInfo should already be cached since module loading happens diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 769b4cb550b0..5b592d5fbca7 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, @@ -183,6 +191,17 @@ pub struct VirtualMachine { /// counter stays at zero). pub pending_unref_counter: core::sync::atomic::AtomicI32, pub preload: Vec>, + pub preload_require_start: usize, + pub preload_require_count: usize, + /// Effective execArgv preloads retained after this VM runs them, so child + /// workers can inherit the same startup contract. + pub worker_preloads: Vec>, + /// The require-style subset of `worker_preloads` that Node also applies + /// before eval Worker source. + pub worker_eval_preloads: Vec>, + pub worker_preload_require_start: usize, + pub worker_preload_require_count: usize, + pub worker_eval_mode: bun_options_types::context::WorkerEvalMode, pub unhandled_pending_rejection_to_capture: Option<*mut JSValue>, /// LAYERING: the real type is `bun_runtime`'s /// `html_rewriter::RewriterPipe` (a forward dep), stored type-erased. @@ -222,6 +241,12 @@ pub struct VirtualMachine { /// pushed after this (a finalizer deferred from the final collection) would only leak. 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, @@ -278,6 +303,17 @@ 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, + /// `bun run` sets this around the entry load: the ticks that fetch the entry graph (imports + /// transpile off-thread) do not stamp `loop_start_ns`; once `entry_evaluation_started`, a tick + /// is the loop running under a top-level await and does. Node's loopStart works out the same: + /// zeros throughout the entry's synchronous evaluation, however its graph was loaded. + pub loop_start_deferred: bool, pub(crate) origin_timestamp: u64, /// For fake timers: override performance.now() with a specific value (in nanoseconds). pub overridden_performance_now: Option, @@ -704,11 +740,12 @@ impl ExitHandler { vm.entry_evaluation_started = true; } - /// Only a worker's start waits on this (`wait_for_worker_entry_evaluation`); - /// any other VM answers `true` so the hook's registry probe never runs there. + /// Only a worker's start (`wait_for_worker_entry_evaluation`) and `bun run`'s deferred loop-start + /// stamp (`loop_start_deferred`) wait on this; any other VM answers `true` so the hook's registry + /// probe never runs there. #[unsafe(no_mangle)] pub(crate) extern "C" fn Bun__VM__entryEvaluationStarted(vm: &VirtualMachine) -> bool { - vm.entry_evaluation_started || vm.worker.is_none() + vm.entry_evaluation_started || !(vm.worker.is_some() || vm.loop_start_deferred) } /// The module-registry key of the current entry load's root: the @@ -2245,13 +2282,50 @@ extern crate alloc; /// casts back on the other side of each hook. pub type RuntimeState = *mut c_void; -/// Runtime flags a Worker's `execArgv` can set. `true` means allowed. -#[derive(Copy, Clone, Debug)] -pub struct WorkerExecArgvFlags { - /// `!--no-addons` - pub allow_addons: bool, - /// `!--no-ffi-cc` - pub allow_ffi_cc: bool, +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 { + /// `Some(false)` for `--no-addons`. + pub allow_addons: Option, + /// `Some(false)` for `--no-ffi-cc`. + pub allow_ffi_cc: 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 { @@ -2374,10 +2448,10 @@ pub struct RuntimeHooks { transpiler: *mut Transpiler<'static>, graph: &'static dyn bun_resolver::StandaloneModuleGraph, ), - /// Parse a Worker's `execArgv` for the flags in [`WorkerExecArgvFlags`]. - /// `None` if parsing failed. - pub parse_worker_exec_argv_flags: - 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` / `allow_ffi_cc` 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), @@ -2615,6 +2689,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. /// @@ -2697,6 +2776,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 @@ -2706,8 +2791,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 @@ -2882,10 +2970,70 @@ 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. A no-op while [`Self::loop_start_deferred`] holds. + #[inline] + pub fn mark_loop_started(&self) { + use core::sync::atomic::Ordering; + if self.loop_start_ns.load(Ordering::Relaxed) != 0 + || (self.loop_start_deferred && !self.entry_evaluation_started) + { + 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); + } + + /// See [`Self::loop_start_deferred`]. `bun run` sets it before loading the entry point and clears + /// it, then stamps, when it enters its run loop. + pub fn defer_loop_start(&mut self, deferred: bool) { + self.loop_start_deferred = deferred; + } + + /// Milliseconds since this thread's loop began polling; `None` before that. + pub fn loop_elapsed_ms(&self) -> Option { + Self::loop_elapsed_ms_since( + self.loop_start_ns + .load(core::sync::atomic::Ordering::Acquire), + ) + } + + /// `raw_idle_ns` (this thread's `us_loop_idle_ns`) minus the idle accumulated before the loop began. + pub fn loop_idle_ms(&self, raw_idle_ns: u64) -> f64 { + Self::loop_idle_ms_above( + self.loop_idle_base_ns + .load(core::sync::atomic::Ordering::Acquire), + raw_idle_ns, + ) + } + + /// The two formulas on copied-out stamps, shared with the parent-side reader of a worker's + /// loop (`WebWorker__getELU`), which holds no reference to the worker's VM. + pub fn loop_elapsed_ms_since(loop_start_ns: u64) -> Option { + if loop_start_ns == 0 { + return None; + } + let now = uws::us_loop_idle_clock_ns(); + Some(now.saturating_sub(loop_start_ns) as f64 / 1_000_000.0) + } + + pub fn loop_idle_ms_above(loop_idle_base_ns: u64, raw_idle_ns: u64) -> f64 { + raw_idle_ns.saturating_sub(loop_idle_base_ns) 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) }; @@ -2903,6 +3051,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) }; @@ -3397,6 +3546,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, } @@ -3474,13 +3627,14 @@ static SOURCE_CODE_PRINTER_FROM_MACRO: Cell = Cell::new(false); fn normalize_specifier_for_resolution<'a>( specifier_: &'a [u8], query_string: &mut &'a [u8], + split_query: bool, ) -> &'a [u8] { // In a `data:` URL everything after the comma is the payload; a `?` is // part of the data, not a query string. if bun_core::strings::has_prefix_comptime(specifier_, b"data:") { return specifier_; } - if let Some(i) = bun_core::strings::index_of_char_usize(specifier_, b'?') { + if split_query && let Some(i) = bun_core::strings::index_of_char_usize(specifier_, b'?') { *query_string = &specifier_[i..]; &specifier_[..i] } else { @@ -3583,13 +3737,6 @@ pub fn collect_macro_vm_garbage() { vm_ref.jsc_vm().run_gc(true); } -fn normalize_source(source: &[u8]) -> &[u8] { - if let Some(rest) = source.strip_prefix(b"file://") { - return rest; - } - source -} - // Additional FFI used by the formerly-gated impl. // C++ side defines `extern "C" SYSV_ABI` (BakeAdditionsToGlobalObject.cpp). // @@ -4164,6 +4311,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)?; @@ -4195,6 +4344,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(), @@ -4238,6 +4389,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`; @@ -4517,6 +4670,8 @@ impl VirtualMachine { source: &[u8], is_esm: bool, is_a_file_path: bool, + split_query: bool, + split_source_query: bool, ) -> crate::CrateResult<()> { use bun_js_parser::Macro; use bun_resolver::{ResultUnion, node_fallbacks}; @@ -4581,17 +4736,24 @@ impl VirtualMachine { let is_special_source = source == MAIN_FILE_NAME || Macro::is_macro_path(source); let mut query_string: &[u8] = b""; - let normalized_specifier = normalize_specifier_for_resolution(specifier, &mut query_string); + let normalized_specifier = + normalize_specifier_for_resolution(specifier, &mut query_string, split_query); let top_level_dir = self.top_level_dir(); let source_to_use: &[u8] = if !is_special_source { if is_a_file_path { + // `source` is the referrer's module key, which may be `?query`. + let source_path = if split_source_query { + crate::resolver_jsc::module_key_without_query(source) + } else { + source + }; // SAFETY: PORT — `dir_with_trailing_slash()` returns a // re-slice of `source`, which the caller guarantees outlives // the resolve call (and the resolver only borrows it for the // synchronous `resolve_and_auto_install`). unsafe { bun_ptr::detach_lifetime( - bun_resolver::fs::PathName::init(source).dir_with_trailing_slash(), + bun_resolver::fs::PathName::init(source_path).dir_with_trailing_slash(), ) } } else { @@ -4706,6 +4868,7 @@ impl VirtualMachine { source: &bun_core::String, query_string: Option<&mut bun_core::String>, mode: ResolveMode, + split_query: bool, ) -> JsResult> { const MAX_LEN: usize = (bun_paths::MAX_PATH_BYTES as f64 * 1.5) as usize; // `data:` URLs carry the module source inline and never touch the @@ -4756,7 +4919,17 @@ impl VirtualMachine { } let specifier_utf8 = specifier.to_utf8(); - let source_utf8 = source.to_utf8(); + // Parse the URL before decoding so a pathname's %3F never becomes a + // module-query delimiter. Hold its decoded storage through resolution. + let source_is_file_url = source.starts_with_ascii(b"file://"); + let decoded_source; + let source_for_resolve = if source_is_file_url { + decoded_source = bun_url::path_from_file_url(source); + &decoded_source + } else { + source + }; + let source_utf8 = source_for_resolve.to_utf8(); if jsc_vm.plugin_runner.is_some() { use bun_bundler::transpiler::PluginRunner; @@ -4772,7 +4945,7 @@ impl VirtualMachine { global, &bun_core::String::from_bytes(namespace), &bun_core::String::borrow_utf8(after_namespace), - source, + source_for_resolve, crate::BunPluginTarget::Bun, )? { return Ok(resolved_path); @@ -4860,9 +5033,11 @@ impl VirtualMachine { let resolve_result = jsc_vm._resolve( &mut result, specifier_utf8.slice(), - normalize_source(source_utf8.slice()), + source_utf8.slice(), mode.is_esm(), IS_A_FILE_PATH, + split_query, + !source_is_file_url, ); if let Err(err_) = resolve_result { let err = err_; @@ -4903,7 +5078,17 @@ impl VirtualMachine { } if let Some(query) = query_string { - *query = bun_core::String::clone_utf8(result.query_string); + // `bun build --compile` finds an embedded module by its exact key, so that key gets no suffix. + let is_embedded = result.result.as_ref().is_some_and(|resolved| { + resolved + .flags + .contains(bun_resolver::ResultFlags::IS_STANDALONE_MODULE) + }); + *query = if is_embedded { + bun_core::String::EMPTY + } else { + bun_core::String::clone_utf8(result.query_string) + }; } Ok(Ok(bun_core::String::clone_utf8(result.path))) @@ -4955,6 +5140,8 @@ impl VirtualMachine { // time and `load_preloads` clears the boxes but keeps the Vec buffer, // so reclaim it here or every Worker leaks it. drop(core::mem::take(&mut self.preload)); + drop(core::mem::take(&mut self.worker_preloads)); + drop(core::mem::take(&mut self.worker_eval_preloads)); // SAFETY: this VM is raw-`dealloc`'d (no field `Drop` runs), so // `transpiler` is never auto-dropped after `deinit` clears its fields. diff --git a/src/jsc/bindings/BunPlugin.cpp b/src/jsc/bindings/BunPlugin.cpp index 22073305f513..1ce9101f06c6 100644 --- a/src/jsc/bindings/BunPlugin.cpp +++ b/src/jsc/bindings/BunPlugin.cpp @@ -651,7 +651,7 @@ extern "C" JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(JSMock__jsModuleMock, __attr if (specifier.startsWith("file:"_s)) { URL fileURL = URL(url, specifier); if (fileURL.isValid()) { - specifier = fileURL.fileSystemPath(); + specifier = Bun::moduleKeyFromFileURL(fileURL); specifierString = jsString(vm, specifier); globalObject->onLoadPlugins.mustDoExpensiveRelativeLookup = true; return; @@ -683,7 +683,7 @@ extern "C" JSC_DEFINE_HOST_FUNCTION_WITH_ATTRIBUTES(JSMock__jsModuleMock, __attr globalObject->onLoadPlugins.mustDoExpensiveRelativeLookup = true; if (relativeURL.protocolIsFile()) - specifier = relativeURL.fileSystemPath(); + specifier = Bun::moduleKeyFromFileURL(relativeURL); else specifier = relativeURL.string(); @@ -889,7 +889,7 @@ std::optional BunPlugin::OnLoad::resolveVirtualModule(const String& path if (path.startsWith("./"_s) || path.startsWith(".."_s)) { auto url = WTF::URL::fileURLWithFileSystemPath(from); ASSERT(url.isValid()); - joinedPath = URL(url, path).fileSystemPath(); + joinedPath = Bun::moduleKeyFromFileURL(URL(url, path)); } return virtualModules->contains(joinedPath) ? std::optional { joinedPath } : std::nullopt; diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index b0067425958f..c8ef16dd2975 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -897,8 +897,10 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionExit, (JSC::JSGlobalObject * globalObje auto code = callFrame->argument(0); - setProcessExitCodeInner(globalObject, process, code); - RETURN_IF_EXCEPTION(throwScope, {}); + if (callFrame->argumentCount() > 0) { + setProcessExitCodeInner(globalObject, process, code); + RETURN_IF_EXCEPTION(throwScope, {}); + } Process__dispatchOnExit(zigGlobal, Bun__getExitCode(bunVM(zigGlobal))); RETURN_IF_EXCEPTION(throwScope, {}); @@ -2301,7 +2303,10 @@ bool setProcessExitCodeInner(JSC::JSGlobalObject* lexicalGlobalObject, Process* { auto throwScope = DECLARE_THROW_SCOPE(process->vm()); - if (!code.isUndefinedOrNull()) { + if (code.isUndefinedOrNull()) { + process->m_isExitCodeObservable = false; + Bun__setExitCode(process->globalObject()->bunVM(), 0); + } else { if (code.isString()) { auto codeString = code.getString(lexicalGlobalObject); RETURN_IF_EXCEPTION(throwScope, false); @@ -4740,9 +4745,48 @@ static inline JSValue getCachedCwd(JSC::JSGlobalObject* globalObject) RELEASE_AND_RETURN(scope, cwdStr); } -extern "C" EncodedJSValue Process__getCachedCwd(JSC::JSGlobalObject* globalObject) +// Match lib/path.js: a replaced process.cwd is observable when resolution needs a base. +extern "C" EncodedJSValue Process__getPathCwd(JSC::JSGlobalObject* globalObject, bool posix) { - return JSValue::encode(getCachedCwd(globalObject)); + auto& vm = JSC::getVM(globalObject); + auto* processObject = defaultGlobalObject(globalObject)->processObject(); + auto& cwdName = builtinNames(vm).cwdPublicName(); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue cwd = processObject->get(globalObject, cwdName); + RETURN_IF_EXCEPTION(scope, {}); + auto* function = dynamicDowncast(cwd); + JSValue result; + if (function && function->isHostFunction() && function->nativeFunction() == Process_functionCwd) { + result = getCachedCwd(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + } else { + auto callData = JSC::getCallData(cwd); + if (callData.type == CallData::Type::None) [[unlikely]] { + JSC::throwTypeError(globalObject, scope, "process.cwd is not a function"_s); + return {}; + } + result = JSC::profiledCall(globalObject, ProfilingReason::API, cwd, callData, processObject, JSC::MarkedArgumentBuffer()); + RETURN_IF_EXCEPTION(scope, {}); + if (!result.isString()) [[unlikely]] { + JSC::throwTypeError(globalObject, scope, "process.cwd returned a non-string value"_s); + return {}; + } + } + +#if OS(WINDOWS) + if (posix) { + auto value = result.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + value = makeStringByReplacingAll(value, '\\', '/'); + auto slash = value.find('/'); + auto start = slash == WTF::notFound ? (value.isEmpty() ? 0 : value.length() - 1) : slash; + result = jsString(vm, value.substring(start)); + } +#else + UNUSED_PARAM(posix); +#endif + + RELEASE_AND_RETURN(scope, JSValue::encode(result)); } JSC_DEFINE_HOST_FUNCTION(Process_functionCwd, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) diff --git a/src/jsc/bindings/BunString.cpp b/src/jsc/bindings/BunString.cpp index e49fa2c0ab59..47ea41ef7c2f 100644 --- a/src/jsc/bindings/BunString.cpp +++ b/src/jsc/bindings/BunString.cpp @@ -616,6 +616,22 @@ extern "C" BunString URL__getHref(const BunString* input) return Bun::toStringRef(url.string()); } +namespace Bun { +WTF::String moduleKeyFromFileURL(const WTF::URL& url) +{ + auto path = url.fileSystemPath(); + auto query = url.queryWithLeadingQuestionMark(); + auto fragment = url.fragmentIdentifierWithLeadingNumberSign(); + if (path.find('?') != WTF::notFound) + return makeString(WTF::URL::fileURLWithFileSystemPath(path).string(), query, fragment); + if (query.isEmpty() && fragment.isEmpty()) + return path; + // A module key is cut at its first '?' only, so a fragment always rides behind a '?'. + return makeString(path, query.isEmpty() ? "?"_s : ""_s, query, fragment); +} + +} + extern "C" BunString URL__pathFromFileURL(const BunString* input) { auto&& str = input->toWTFString(); @@ -626,6 +642,16 @@ extern "C" BunString URL__pathFromFileURL(const BunString* input) return Bun::toStringRef(url.fileSystemPath()); } +extern "C" BunString URL__suffixFromFileURL(const BunString* input) +{ + auto&& str = input->toWTFString(); + auto url = WTF::URL(str); + if (!url.isValid() || url.isEmpty()) + return { BunStringTag::Dead }; + + return Bun::toStringRef(makeString(url.queryWithLeadingQuestionMark(), url.fragmentIdentifierWithLeadingNumberSign())); +} + extern "C" BunString URL__getHrefJoin(const BunString* baseStr, const BunString* relativeStr) { auto base = baseStr->toWTFString(); diff --git a/src/jsc/bindings/ErrorCode.ts b/src/jsc/bindings/ErrorCode.ts index 9dad4856366d..f956ab1825f7 100644 --- a/src/jsc/bindings/ErrorCode.ts +++ b/src/jsc/bindings/ErrorCode.ts @@ -263,6 +263,7 @@ const errors: ErrorCodeMapping = [ ["ERR_USE_AFTER_CLOSE", Error], ["ERR_WEBASSEMBLY_RESPONSE", TypeError], ["ERR_WORKER_NOT_RUNNING", Error], + ["ERR_WORKER_INVALID_EXEC_ARGV", Error], ["ERR_WORKER_UNSUPPORTED_OPERATION", TypeError], ["ERR_WORKER_PATH", TypeError], ["ERR_ZLIB_INITIALIZATION_FAILED", Error], diff --git a/src/jsc/bindings/ImportMetaObject.cpp b/src/jsc/bindings/ImportMetaObject.cpp index dd998bb947f7..bc8d81fbfba0 100644 --- a/src/jsc/bindings/ImportMetaObject.cpp +++ b/src/jsc/bindings/ImportMetaObject.cpp @@ -89,12 +89,20 @@ ImportMetaObject* ImportMetaObject::create(JSC::JSGlobalObject* globalObject, JS ImportMetaObject* ImportMetaObject::createFromSpecifier(JSC::JSGlobalObject* globalObject, const String& specifier) { + if (specifier.startsWith("file://"_s) || specifier.startsWith("data:"_s)) + return create(globalObject, specifier); + auto index = specifier.find('?'); URL url; if (index != notFound) { StringView view = specifier; url = URL::fileURLWithFileSystemPath(view.substring(0, index)); - url.setQuery(view.substring(index + 1)); + // A fragment with no query rides behind the '?' in a module key: "/x.mjs?#a" is "file:///x.mjs#a". + auto suffix = view.substring(index + 1); + if (suffix.startsWith('#')) + url.setFragmentIdentifier(suffix.substring(1)); + else + url.setQuery(suffix); } else { url = URL::fileURLWithFileSystemPath(specifier); } @@ -210,6 +218,7 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo JSValue userPathList = callFrame->argument(4); JSValue parentModule = callFrame->argument(5); JSValue resolveFilenameOptions = callFrame->argument(6); + Strong referrerRoot; if (globalObject->onLoadPlugins.hasVirtualModules()) { if (moduleName.isString()) { @@ -258,6 +267,16 @@ extern "C" JSC::EncodedJSValue functionImportMeta__resolveSyncPrivate(JSC::JSGlo } } + if (auto* parent = dynamicDowncast(parentModule); parent && !parent->filenameIsModuleKey && from.isString()) { + auto filename = from.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto referrer = moduleReferrerFromFilename(filename, false); + if (referrer != filename) { + referrerRoot.set(vm, jsString(vm, referrer)); + from = referrerRoot.get(); + } + } + // node resolves builtin ids before validating `paths`, so `require.resolve("node:fs", // { paths: [0] })` must not throw. Only real builtins bypass; "node:nope" still validates. // https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js diff --git a/src/jsc/bindings/JSCommonJSExtensions.cpp b/src/jsc/bindings/JSCommonJSExtensions.cpp index 4dece4f37e58..9fad3aebdab5 100644 --- a/src/jsc/bindings/JSCommonJSExtensions.cpp +++ b/src/jsc/bindings/JSCommonJSExtensions.cpp @@ -244,6 +244,7 @@ JSC::EncodedJSValue builtinLoader(JSC::JSGlobalObject* globalObject, JSC::CallFr mod, specifierWtfString, loaderType, + false, scope); RETURN_IF_EXCEPTION(scope, {}); if (result == jsNumber(-1)) { diff --git a/src/jsc/bindings/JSCommonJSModule.cpp b/src/jsc/bindings/JSCommonJSModule.cpp index 484a71926b11..87e73ef0fe77 100644 --- a/src/jsc/bindings/JSCommonJSModule.cpp +++ b/src/jsc/bindings/JSCommonJSModule.cpp @@ -249,6 +249,7 @@ bool JSCommonJSModule::load(JSC::VM& vm, Zig::GlobalObject* globalObject) return true; } + JSValue requireMapKey = this->m_id.get(); evaluateCommonJSModuleOnce( globalObject->vm(), globalObject, @@ -263,7 +264,7 @@ bool JSCommonJSModule::load(JSC::VM& vm, Zig::GlobalObject* globalObject) // On error, remove the module from the require map/ // so that it can be re-evaluated on the next require. - bool wasRemoved = globalObject->requireMap()->remove(globalObject, this->filename()); + bool wasRemoved = globalObject->requireMap()->remove(globalObject, requireMapKey); RETURN_IF_EXCEPTION(scope, false); ASSERT(wasRemoved); @@ -346,7 +347,7 @@ JSC_DEFINE_HOST_FUNCTION(requireResolvePathsFunction, (JSGlobalObject * globalOb RELEASE_AND_RETURN(scope, JSValue::encode(constructEmptyArray(globalObject, nullptr, 0))); } RETURN_IF_EXCEPTION(scope, {}); - Bun::PathResolveModule parent = { .paths = nullptr, .filename = filename, .pathsArrayLazy = true }; + Bun::PathResolveModule parent = { .paths = nullptr, .filename = filename, .pathsArrayLazy = true, .filenameIsModuleKey = boundModule->filenameIsModuleKey }; RELEASE_AND_RETURN(scope, JSValue::encode(Bun::resolveLookupPaths(globalObject, requestStr, parent))); } @@ -548,7 +549,7 @@ JSC_DEFINE_CUSTOM_GETTER(getterPaths, (JSC::JSGlobalObject * globalObject, JSC:: auto filenameWtfStr = filename.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); BunString filenameStr = Bun::toString(filenameWtfStr); - JSValue paths = JSValue::decode(Resolver__nodeModulePathsJSValue(&filenameStr, globalObject, true)); + JSValue paths = JSValue::decode(Resolver__nodeModulePathsJSValue(&filenameStr, globalObject, true, thisObject->filenameIsModuleKey)); RETURN_IF_EXCEPTION(scope, {}); thisObject->m_paths.set(globalObject->vm(), thisObject, paths); return JSValue::encode(paths); @@ -655,6 +656,7 @@ JSC_DEFINE_CUSTOM_SETTER(setterFilename, JSString* string = JSValue::decode(value).toString(globalObject); RETURN_IF_EXCEPTION(scope, false); thisObject->m_filename.set(globalObject->vm(), thisObject, string); + thisObject->filenameIsModuleKey = false; return true; } @@ -915,13 +917,28 @@ JSCommonJSModule* JSCommonJSModule::create( { auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); - auto key = requireMapKey->value(globalObject); + WTF::String key = requireMapKey->value(globalObject); RETURN_IF_EXCEPTION(scope, nullptr); - auto index = key->reverseFind(PLATFORM_SEP, key->length()); + auto filenameString = key; + bool decodedFileURLKey = false; + if (key.startsWith("file://"_s)) { + auto url = WTF::URL(key); + if (url.isValid() && !url.isEmpty()) { + auto path = url.fileSystemPath(); + if (path.find('?') != WTF::notFound) { + filenameString = path; + decodedFileURLKey = true; + } + } + } + auto* filename = filenameString == key ? requireMapKey : jsString(vm, filenameString); + auto index = filenameString.reverseFind( + PLATFORM_SEP, + decodedFileURLKey ? filenameString.length() : moduleKeyPathLength(filenameString)); JSString* dirname; if (index != WTF::notFound) { - dirname = JSC::jsSubstring(globalObject, requireMapKey, 0, index); + dirname = JSC::jsSubstring(globalObject, filename, 0, index); RETURN_IF_EXCEPTION(scope, nullptr); } else { dirname = jsEmptyString(vm); @@ -930,7 +947,8 @@ JSCommonJSModule* JSCommonJSModule::create( auto* out = JSCommonJSModule::create( vm, globalObject->CommonJSModuleObjectStructure(), - requireMapKey, requireMapKey, dirname, SourceCode()); + requireMapKey, filename, dirname, SourceCode()); + out->filenameIsModuleKey = !decodedFileURLKey; out->putDirect( vm, @@ -1586,7 +1604,7 @@ static JSC::SourceCode commonJSModuleSyntheticSourceCode(const SourceOrigin& sou // On error, remove the module from the require map // so that it can be re-evaluated on the next require. - globalObject->requireMap()->remove(globalObject, moduleObject->filename()); + globalObject->requireMap()->remove(globalObject, keyValue); RETURN_IF_EXCEPTION(scope, {}); scope.throwException(globalObject, exception); diff --git a/src/jsc/bindings/JSCommonJSModule.h b/src/jsc/bindings/JSCommonJSModule.h index 398057728060..ac687c3fc04e 100644 --- a/src/jsc/bindings/JSCommonJSModule.h +++ b/src/jsc/bindings/JSCommonJSModule.h @@ -140,6 +140,8 @@ class JSCommonJSModule final : public JSC::JSDestructibleObject { } bool hasEvaluated = false; + // Loader keys can carry query suffixes; decoded URLs and assigned filenames are paths. + bool filenameIsModuleKey = true; JSCommonJSModule(JSC::VM& vm, JSC::Structure* structure, JSC::JSString* id, JSC::JSValue filename, JSC::JSString* dirname) : Base(vm, structure) diff --git a/src/jsc/bindings/ModuleLoader.cpp b/src/jsc/bindings/ModuleLoader.cpp index 32d233a8bb5a..306e85771a4e 100644 --- a/src/jsc/bindings/ModuleLoader.cpp +++ b/src/jsc/bindings/ModuleLoader.cpp @@ -651,7 +651,19 @@ JSValue fetchCommonJSModule( ErrorableResolvedSource resValue; ErrorableResolvedSource* res = &resValue; - BunString specifier = Bun::toString(specifierWtfString); + String fileURLPath = specifierWtfString; + bool preservePathDelimiters = false; + if (specifierWtfString.startsWith("file://"_s)) { + auto url = WTF::URL(specifierWtfString); + if (url.isValid() && !url.isEmpty()) { + auto path = url.fileSystemPath(); + if (path.find('?') != WTF::notFound) { + fileURLPath = path; + preservePathDelimiters = true; + } + } + } + BunString specifier = Bun::toString(fileURLPath); bool wasModuleMock = false; @@ -801,7 +813,7 @@ JSValue fetchCommonJSModule( } } - return fetchCommonJSModuleNonBuiltin(bunVM, vm, globalObject, &specifier, specifierValue, referrer, typeAttribute, res, target, specifierWtfString, BunLoaderTypeNone, scope); + return fetchCommonJSModuleNonBuiltin(bunVM, vm, globalObject, &specifier, specifierValue, referrer, typeAttribute, res, target, specifierWtfString, BunLoaderTypeNone, preservePathDelimiters, scope); } template @@ -817,9 +829,10 @@ JSValue fetchCommonJSModuleNonBuiltin( JSCommonJSModule* target, String specifierWtfString, BunLoaderType forceLoaderType, + bool preservePathDelimiters, JSC::ThrowScope& scope) { - Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, false, !isExtension, forceLoaderType); + Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, false, !isExtension, forceLoaderType, preservePathDelimiters); if (res->success && res->result.value.isCommonJSModule) { if constexpr (isExtension) { target->evaluateWithPotentiallyOverriddenCompile(globalObject, specifierWtfString, specifierValue, res->result.value); @@ -911,6 +924,7 @@ template JSValue fetchCommonJSModuleNonBuiltin( JSCommonJSModule* target, String specifierWtfString, BunLoaderType forceLoaderType, + bool preservePathDelimiters, JSC::ThrowScope& scope); template JSValue fetchCommonJSModuleNonBuiltin( void* bunVM, @@ -924,6 +938,7 @@ template JSValue fetchCommonJSModuleNonBuiltin( JSCommonJSModule* target, String specifierWtfString, BunLoaderType forceLoaderType, + bool preservePathDelimiters, JSC::ThrowScope& scope); extern "C" bool isBunTest; @@ -935,11 +950,16 @@ static JSValue fetchESMSourceCode( ErrorableResolvedSource* res, BunString* specifier, BunString* referrer, - BunString* typeAttribute) + BunString* typeAttribute, + bool preservePathDelimiters) { void* bunVM = globalObject->bunVM(); auto& vm = JSC::getVM(globalObject); auto scope = DECLARE_THROW_SCOPE(vm); + auto specifierKey = specifierJS->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto specifierKeyBun = Bun::toString(specifierKey); + auto* virtualSpecifier = preservePathDelimiters ? &specifierKeyBun : specifier; const auto reject = [&](JSC::JSValue exception) -> JSValue { if constexpr (allowPromise) { @@ -975,10 +995,10 @@ static JSValue fetchESMSourceCode( // When "bun test" is enabled, allow users to override builtin modules // This is important for being able to trivially mock things like the filesystem. if (isBunTest) { - JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, specifier, wasModuleMock); + JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, virtualSpecifier, wasModuleMock); RETURN_IF_EXCEPTION(scope, {}); if (virtualModuleResult) { - RELEASE_AND_RETURN(scope, handleVirtualModuleResult(globalObject, virtualModuleResult, res, specifier, referrer, wasModuleMock)); + RELEASE_AND_RETURN(scope, handleVirtualModuleResult(globalObject, virtualModuleResult, res, virtualSpecifier, referrer, wasModuleMock)); } } @@ -1072,16 +1092,16 @@ static JSValue fetchESMSourceCode( // When "bun test" is NOT enabled, disable users from overriding builtin modules if (!isBunTest) { - JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, specifier, wasModuleMock); + JSC::JSValue virtualModuleResult = Bun::runVirtualModule(globalObject, virtualSpecifier, wasModuleMock); RETURN_IF_EXCEPTION(scope, {}); if (virtualModuleResult) { - RELEASE_AND_RETURN(scope, handleVirtualModuleResult(globalObject, virtualModuleResult, res, specifier, referrer, wasModuleMock)); + RELEASE_AND_RETURN(scope, handleVirtualModuleResult(globalObject, virtualModuleResult, res, virtualSpecifier, referrer, wasModuleMock)); } } const bool useIsolationCache = Bun::IsolatedModuleCache::canUse(vm, bunVM, typeAttribute); if (useIsolationCache) { - if (auto* cached = Bun::IsolatedModuleCache::lookup(vm, specifier->toWTFString(BunString::ZeroCopy))) { + if (auto* cached = Bun::IsolatedModuleCache::lookup(vm, specifierKey)) { if (cached->sourceType() != JSC::SourceProviderSourceType::Program) { RELEASE_AND_RETURN(scope, rejectOrResolve(JSC::JSSourceCode::create(vm, JSC::SourceCode(Ref(*cached))))); } @@ -1107,12 +1127,12 @@ static JSValue fetchESMSourceCode( } if constexpr (allowPromise) { - auto* pendingCtx = Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, true, false, BunLoaderTypeNone); + auto* pendingCtx = Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, true, false, BunLoaderTypeNone, preservePathDelimiters); if (pendingCtx) { return pendingCtx; } } else { - Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, false, false, BunLoaderTypeNone); + Bun__transpileFile(bunVM, globalObject, specifier, referrer, typeAttribute, res, false, false, BunLoaderTypeNone, preservePathDelimiters); } if (res->success && res->result.value.isCommonJSModule) { @@ -1165,7 +1185,7 @@ static JSValue fetchESMSourceCode( value); auto source = JSC::SourceCode( JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); + JSC::SourceOrigin(), specifierKey)); JSC::ensureStillAliveHere(value); RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); } @@ -1182,7 +1202,7 @@ static JSValue fetchESMSourceCode( value); auto source = JSC::SourceCode( JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); + JSC::SourceOrigin(), specifierKey)); JSC::ensureStillAliveHere(value); RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); } else if (res->result.value.tag == SyntheticModuleType::ExportDefaultObject) { @@ -1197,14 +1217,14 @@ static JSValue fetchESMSourceCode( value); auto source = JSC::SourceCode( JSC::SyntheticSourceProvider::create(WTF::move(function), - JSC::SourceOrigin(), specifier->toWTFString(BunString::ZeroCopy))); + JSC::SourceOrigin(), specifierKey)); JSC::ensureStillAliveHere(value); RELEASE_AND_RETURN(scope, rejectOrResolve(JSSourceCode::create(globalObject->vm(), WTF::move(source)))); } auto provider = Zig::SourceProvider::create(globalObject, res->result.value); if (useIsolationCache) { - Bun::IsolatedModuleCache::insert(vm, specifier->toWTFString(BunString::ZeroCopy), provider.get()); + Bun::IsolatedModuleCache::insert(vm, specifierKey, provider.get()); } RELEASE_AND_RETURN(scope, rejectOrResolve(JSC::JSSourceCode::create(vm, JSC::SourceCode(WTF::move(provider))))); } @@ -1215,9 +1235,10 @@ JSValue fetchESMSourceCodeSync( ErrorableResolvedSource* res, BunString* specifier, BunString* referrer, - BunString* typeAttribute) + BunString* typeAttribute, + bool preservePathDelimiters) { - return fetchESMSourceCode(globalObject, specifierJS, res, specifier, referrer, typeAttribute); + return fetchESMSourceCode(globalObject, specifierJS, res, specifier, referrer, typeAttribute, preservePathDelimiters); } JSValue fetchESMSourceCodeAsync( @@ -1226,9 +1247,10 @@ JSValue fetchESMSourceCodeAsync( ErrorableResolvedSource* res, BunString* specifier, BunString* referrer, - BunString* typeAttribute) + BunString* typeAttribute, + bool preservePathDelimiters) { - return fetchESMSourceCode(globalObject, specifierJS, res, specifier, referrer, typeAttribute); + return fetchESMSourceCode(globalObject, specifierJS, res, specifier, referrer, typeAttribute, preservePathDelimiters); } } diff --git a/src/jsc/bindings/ModuleLoader.h b/src/jsc/bindings/ModuleLoader.h index dc31f18625a9..a09dafe8c877 100644 --- a/src/jsc/bindings/ModuleLoader.h +++ b/src/jsc/bindings/ModuleLoader.h @@ -82,7 +82,8 @@ JSValue fetchESMSourceCodeSync( ErrorableResolvedSource* res, BunString* specifier, BunString* referrer, - BunString* typeAttribute); + BunString* typeAttribute, + bool preservePathDelimiters); JSValue fetchESMSourceCodeAsync( Zig::GlobalObject* globalObject, @@ -90,7 +91,8 @@ JSValue fetchESMSourceCodeAsync( ErrorableResolvedSource* res, BunString* specifier, BunString* referrer, - BunString* typeAttribute); + BunString* typeAttribute, + bool preservePathDelimiters); JSValue fetchCommonJSModule( Zig::GlobalObject* globalObject, @@ -113,6 +115,7 @@ JSValue fetchCommonJSModuleNonBuiltin( JSCommonJSModule* target, String specifierWtfString, BunLoaderType forceLoaderType, + bool preservePathDelimiters, JSC::ThrowScope& scope); JSValue resolveAndFetchBuiltinModule( diff --git a/src/jsc/bindings/NodeAsyncHooks.cpp b/src/jsc/bindings/NodeAsyncHooks.cpp index 62eb6844160b..b80f9c3ecfbe 100644 --- a/src/jsc/bindings/NodeAsyncHooks.cpp +++ b/src/jsc/bindings/NodeAsyncHooks.cpp @@ -1,15 +1,63 @@ #include "config.h" #include "JavaScriptCore/JSObject.h" +#include "JavaScriptCore/JSMicrotask.h" +#include "JavaScriptCore/MicrotaskQueue.h" #include "JavaScriptCore/ObjectConstructor.h" #include "JavaScriptCore/ArrayConstructor.h" +#include "JavaScriptCore/ArgList.h" +#include "NodeAsyncHooks.h" #include "ZigGlobalObject.h" namespace Bun { using namespace JSC; +JSC_DEFINE_HOST_FUNCTION(jsQueueAsyncHooksMicrotask, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto callback = callFrame->argument(0); + ASSERT(callback.isCallable()); + JSC::QueuedTask task { nullptr, JSC::InternalMicrotask::BunPerformMicrotaskJob, 0, globalObject, callback, JSC::jsUndefined() }; + globalObject->vm().queueMicrotask(WTF::move(task)); + return JSC::JSValue::encode(JSC::jsUndefined()); +} + +JSC_DEFINE_HOST_FUNCTION(jsSetAsyncHooksTimerDispatch, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = globalObject->vm(); + auto* global = uncheckedDowncast(globalObject); + auto dispatch = callFrame->argument(0); + if (auto* function = dispatch.getObject(); function && function->isCallable()) { + global->m_asyncHooksTimerDispatch.set(vm, global, function); + } else { + global->m_asyncHooksTimerDispatch.clear(); + } + return JSC::JSValue::encode(JSC::jsUndefined()); +} + +extern "C" void Bun__AsyncHooks__emitTimerLifecycle( + JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue encodedTimer, uint64_t asyncHooksId, + AsyncHooksTimerLifecycleEvent event) +{ + auto* global = uncheckedDowncast(globalObject); + auto* dispatch = global->m_asyncHooksTimerDispatch.get(); + if (!dispatch) [[likely]] + return; + + auto& vm = globalObject->vm(); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + JSC::MarkedArgumentBuffer args; + args.append(JSC::jsNumber(static_cast(event))); + args.append(JSC::JSValue::decode(encodedTimer)); + args.append(JSC::jsNumber(static_cast(asyncHooksId))); + JSC::call(globalObject, dispatch, JSC::getCallData(dispatch), JSC::jsUndefined(), args); + if (auto* exception = scope.exception()) [[unlikely]] { + (void)scope.tryClearException(); + Bun__reportUnhandledError(globalObject, JSC::JSValue::encode(exception)); + } +} + // This is called when AsyncLocalStorage is constructed. JSC_DEFINE_HOST_FUNCTION(jsSetAsyncHooksEnabled, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { diff --git a/src/jsc/bindings/NodeAsyncHooks.h b/src/jsc/bindings/NodeAsyncHooks.h index 204704ae17e3..c0f6ae73168a 100644 --- a/src/jsc/bindings/NodeAsyncHooks.h +++ b/src/jsc/bindings/NodeAsyncHooks.h @@ -5,5 +5,17 @@ namespace Bun { JSC_DECLARE_HOST_FUNCTION(jsSetAsyncHooksEnabled); +JSC_DECLARE_HOST_FUNCTION(jsSetAsyncHooksTimerDispatch); +JSC_DECLARE_HOST_FUNCTION(jsQueueAsyncHooksMicrotask); + +enum class AsyncHooksTimerLifecycleEvent : uint8_t { + TimeoutInit = 0, + ImmediateInit = 1, + Destroy = 2, +}; + +extern "C" void Bun__AsyncHooks__emitTimerLifecycle( + JSC::JSGlobalObject*, JSC::EncodedJSValue timer, uint64_t asyncHooksId, + AsyncHooksTimerLifecycleEvent event); } diff --git a/src/jsc/bindings/PathInlines.h b/src/jsc/bindings/PathInlines.h index 49abea253b3a..73a38a1d978e 100644 --- a/src/jsc/bindings/PathInlines.h +++ b/src/jsc/bindings/PathInlines.h @@ -1,5 +1,6 @@ #pragma once #include "root.h" +#include #define POSIX_PATH_SEP_s "/"_s #define POSIX_PATH_SEP '/' @@ -52,6 +53,27 @@ ALWAYS_INLINE bool isAbsolutePath(WTF::String input) #endif } +/// Length of the `` of a `?query` module key. Twin of `module_key_without_query` (resolver_jsc.rs). +ALWAYS_INLINE unsigned moduleKeyPathLength(const WTF::String& key) +{ + unsigned devicePrefixLength = 0; +#if OS(WINDOWS) + // `\\?\C:\...` and `\\.\...` are paths, not queries. + if (key.length() >= 4 && IS_SLASH(key[0]) && IS_SLASH(key[1]) && (key[2] == '?' || key[2] == '.') && IS_SLASH(key[3])) + devicePrefixLength = 4; +#endif + size_t queryStart = key.find('?', devicePrefixLength); + return queryStart == WTF::notFound ? key.length() : static_cast(queryStart); +} + +// Filesystem filenames must not acquire module-query semantics after decoding a URL. +ALWAYS_INLINE WTF::String moduleReferrerFromFilename(const WTF::String& filename, bool isModuleKey) +{ + if (!isModuleKey && filename.find('?') != WTF::notFound) + return WTF::URL::fileURLWithFileSystemPath(filename).string(); + return filename; +} + #undef IS_LETTER #undef IS_SLASH diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index acced52b9d27..eb63a0c7c9f7 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -5,6 +5,7 @@ #include "BuiltinModuleKeys.h" #include "IsolatedModuleCache.h" #include "MessagePort.h" +#include "PathInlines.h" #include "helpers.h" #include "JavaScriptCore/ArgList.h" #include "JavaScriptCore/JSCellButterfly.h" @@ -3450,6 +3451,21 @@ extern "C" bool Bun__standaloneModuleHasModuleInfo(const Latin1Character*, size_ extern "C" bool Bun__hasStandaloneModuleGraph(); extern "C" int ModuleLoader__builtinAliasIndex(const Latin1Character*, size_t); extern "C" bool Bun__hasPluginRunner(void*); + +static String fileURLSuffix(const URL& url) +{ + return makeString(url.queryWithLeadingQuestionMark(), url.fragmentIdentifierWithLeadingNumberSign()); +} + +static String resolvedModuleKey(const String& resolved, const String& suffix) +{ + if (isAbsolutePath(resolved) && resolved.find('?') != WTF::notFound) + return makeString(URL::fileURLWithFileSystemPath(resolved).string(), suffix); + // Node's default ESM realpath finalization aliases #x and ?#x. + // https://github.com/nodejs/node/blob/v26.8.2/lib/internal/modules/esm/resolve.js + return makeString(resolved, suffix.startsWith('#') ? "?"_s : ""_s, suffix); +} + JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject, JSModuleLoader* loader, JSValue key, JSValue referrer, RefPtr, bool) @@ -3459,6 +3475,8 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject auto scope = DECLARE_THROW_SCOPE(vm); WTF::String keyString; + WTF::String requestedSuffix; + bool splitQuery = true; if (key.isString()) { auto moduleName = uncheckedDowncast(key)->value(globalObject); RETURN_IF_EXCEPTION(scope, {}); @@ -3480,6 +3498,8 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject auto url = WTF::URL(moduleName); if (url.isValid() && !url.isEmpty()) { keyString = url.fileSystemPath(); + requestedSuffix = fileURLSuffix(url); + splitQuery = false; } else { keyString = moduleName; } @@ -3491,9 +3511,18 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject RETURN_IF_EXCEPTION(scope, {}); } WTF::String referrerString; + WTF::String resolverReferrerString; if (referrer && referrer.isString()) { referrerString = referrer.toWTFString(globalObject); RETURN_IF_EXCEPTION(scope, {}); + // The resolver must distinguish encoded pathname delimiters from queries; + // plugin importers keep their existing decoded filesystem spelling. + resolverReferrerString = referrerString; + if (referrerString.startsWith("file://"_s)) { + auto url = WTF::URL(referrerString); + if (url.isValid() && !url.isEmpty()) + referrerString = url.fileSystemPath(); + } } if (globalObject->onLoadPlugins.hasVirtualModules()) { @@ -3529,21 +3558,17 @@ JSC::Identifier GlobalObject::moduleLoaderResolve(JSGlobalObject* jsGlobalObject ErrorableString res; BunString keyZ = Bun::toString(keyString); - BunString referrerZ = Bun::toString(referrerString); + BunString referrerZ = Bun::toString(resolverReferrerString); BunString queryZ = BunStringEmpty; - Zig__GlobalObject__resolve(&res, globalObject, &keyZ, &referrerZ, &queryZ); + Zig__GlobalObject__resolve(&res, globalObject, &keyZ, &referrerZ, &queryZ, splitQuery); RETURN_IF_EXCEPTION(scope, {}); if (!res.success) { throwException(scope, res.result.err, globalObject); return {}; } auto resolved = res.result.value.transferToWTFString(); - auto query = queryZ.transferToWTFString(); - - if (!query.isEmpty()) { - return Identifier::fromString(vm, makeString(resolved, query)); - } - return Identifier::fromString(vm, resolved); + auto suffix = requestedSuffix.isEmpty() ? queryZ.transferToWTFString() : requestedSuffix; + return Identifier::fromString(vm, resolvedModuleKey(resolved, suffix)); } JSC::Identifier StandaloneGlobalObject::moduleLoaderResolve(JSGlobalObject* globalObject, JSModuleLoader* loader, JSValue key, JSValue referrer, RefPtr fetcher, bool b) @@ -3603,10 +3628,7 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO sourceOriginStringHolder = String("."_s); } else if (sourceURL.protocolIsFile()) { sourceOriginStringHolder = sourceURL.fileSystemPath(); - auto query = sourceURL.queryWithLeadingQuestionMark(); - auto referrerKey = query.isEmpty() - ? JSC::Identifier::fromString(vm, sourceOriginStringHolder) - : JSC::Identifier::fromString(vm, makeString(sourceOriginStringHolder, query)); + auto referrerKey = JSC::Identifier::fromString(vm, resolvedModuleKey(sourceOriginStringHolder, fileURLSuffix(sourceURL))); referrerAsyncOrder = loader->asyncEvaluationOrderForKey(referrerKey); } else if (sourceURL.protocol() == "builtin"_s) { ASSERT(sourceURL.string().startsWith("builtin://"_s)); @@ -3628,31 +3650,30 @@ JSC::JSPromise* GlobalObject::moduleLoaderImportModule(JSGlobalObject* jsGlobalO } { + WTF::String requestedSuffix; + bool splitQuery = true; if (moduleName.startsWith("file://"_s)) { auto url = WTF::URL(moduleName); if (url.isValid() && !url.isEmpty()) { moduleName = url.fileSystemPath(); + requestedSuffix = fileURLSuffix(url); + splitQuery = false; } } ErrorableString res; BunString moduleNameZ = Bun::toString(moduleName); - BunString sourceOriginZ = Bun::toString(sourceOriginStringHolder); + BunString sourceOriginZ = Bun::toString(sourceURL.protocolIsFile() ? sourceURL.string() : sourceOriginStringHolder); BunString queryZ = BunStringEmpty; - Zig__GlobalObject__resolve(&res, globalObject, &moduleNameZ, &sourceOriginZ, &queryZ); + Zig__GlobalObject__resolve(&res, globalObject, &moduleNameZ, &sourceOriginZ, &queryZ, splitQuery); RETURN_IF_EXCEPTION(scope, JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope)); if (!res.success) [[unlikely]] { throwException(scope, res.result.err, globalObject); return JSC::JSPromise::rejectedPromiseWithCaughtException(globalObject, scope); } auto resolved = res.result.value.transferToWTFString(); - auto query = queryZ.transferToWTFString(); - - if (query.isEmpty()) { - resolvedIdentifier = JSC::Identifier::fromString(vm, resolved); - } else { - resolvedIdentifier = JSC::Identifier::fromString(vm, makeString(resolved, query)); - } + auto suffix = requestedSuffix.isEmpty() ? queryZ.transferToWTFString() : requestedSuffix; + resolvedIdentifier = JSC::Identifier::fromString(vm, resolvedModuleKey(resolved, suffix)); } // The C++ module loader now extracts `with.type` into a @@ -3699,11 +3720,21 @@ JSC::JSPromise* GlobalObject::moduleLoaderFetch(JSGlobalObject* globalObject, if (scope.exception()) [[unlikely]] return rejectedInternalPromise(globalObject, scope.exception()->value()); - if (moduleKey.endsWith(".node"_s)) { + WTF::String fetchKey = moduleKey; + bool preservePathDelimiters = false; + if (moduleKey.startsWith("file://"_s)) { + auto url = WTF::URL(moduleKey); + if (url.isValid() && !url.isEmpty()) { + fetchKey = url.fileSystemPath(); + preservePathDelimiters = true; + } + } + + if (fetchKey.endsWith(".node"_s) && !moduleKey.startsWith("data:"_s)) { return rejectedInternalPromise(globalObject, createTypeError(globalObject, "To load Node-API modules, use require() or process.dlopen instead of import."_s)); } - auto moduleKeyBun = Bun::toString(moduleKey); + auto moduleKeyBun = Bun::toString(fetchKey); auto& sourceString = vm.propertyNames->undefinedKeyword.string(); auto typeAttributeString = String(); @@ -3733,7 +3764,8 @@ JSC::JSPromise* GlobalObject::moduleLoaderFetch(JSGlobalObject* globalObject, &res, &moduleKeyBun, &source, - typeAttributeString.isEmpty() ? nullptr : &typeAttribute); + typeAttributeString.isEmpty() ? nullptr : &typeAttribute, + preservePathDelimiters); RETURN_IF_EXCEPTION(scope, rejectedInternalPromise(globalObject, scope.exception()->value())); if (auto* promise = dynamicDowncast(result)) return promise; @@ -3748,7 +3780,8 @@ JSC::JSPromise* GlobalObject::moduleLoaderFetch(JSGlobalObject* globalObject, &res, &moduleKeyBun, &source, - typeAttributeString.isEmpty() ? nullptr : &typeAttribute); + typeAttributeString.isEmpty() ? nullptr : &typeAttribute, + preservePathDelimiters); RETURN_IF_EXCEPTION(scope, rejectedInternalPromise(globalObject, scope.exception()->value())); ASSERT(result); @@ -3770,7 +3803,7 @@ static JSSourceCode* fetchSourceSync(Zig::GlobalObject* globalObject, const Iden ErrorableResolvedSource res; auto keyBun = Bun::toString(key.string()); auto source = Bun::toString(vm.propertyNames->undefinedKeyword.string()); - JSValue result = Bun::fetchESMSourceCodeSync(globalObject, jsString(vm, key.string()), &res, &keyBun, &source, nullptr); + JSValue result = Bun::fetchESMSourceCodeSync(globalObject, jsString(vm, key.string()), &res, &keyBun, &source, nullptr, false); RETURN_IF_EXCEPTION(scope, nullptr); return result ? dynamicDowncast(result) : nullptr; } diff --git a/src/jsc/bindings/ZigGlobalObject.h b/src/jsc/bindings/ZigGlobalObject.h index 46f1b9ce3912..7b3baaadb23f 100644 --- a/src/jsc/bindings/ZigGlobalObject.h +++ b/src/jsc/bindings/ZigGlobalObject.h @@ -501,6 +501,8 @@ class GlobalObject : public Bun::GlobalScope { \ V(public, WriteBarrier, m_nextTickQueue) \ \ + V(public, WriteBarrier, m_asyncHooksTimerDispatch) \ + \ /* WriteBarrier m_JSBunDebuggerValue; */ \ V(private, ThenablesArray, m_thenables) \ V(private, NativeModuleDefaultsArray, m_nativeModuleDefaults) \ diff --git a/src/jsc/bindings/headers-handwritten.h b/src/jsc/bindings/headers-handwritten.h index 1a321b82ebed..0482447e2176 100644 --- a/src/jsc/bindings/headers-handwritten.h +++ b/src/jsc/bindings/headers-handwritten.h @@ -33,6 +33,7 @@ typedef union BunStringImpl { namespace WTF { class StringImpl; class String; +class URL; } typedef union BunStringImpl { @@ -357,6 +358,8 @@ BunString toStringRef(WTF::StringImpl* wtfString); // This creates a detached string view, which cannot be ref/unref. // Be very careful using this, and ensure the memory owner does not get destroyed. BunString toStringView(WTF::StringView view); + +WTF::String moduleKeyFromFileURL(const WTF::URL&); } typedef struct { @@ -395,7 +398,8 @@ extern "C" JSC::JSPromise* Bun__transpileFile( ErrorableResolvedSource* result, bool allowPromise, bool isCommonJSRequire, - BunLoaderType forceLoaderType); + BunLoaderType forceLoaderType, + bool preservePathDelimiters); extern "C" bool Bun__fetchBuiltinModule( void* bunVM, diff --git a/src/jsc/bindings/headers.h b/src/jsc/bindings/headers.h index 7e790c2a04df..91208aadd0d0 100644 --- a/src/jsc/bindings/headers.h +++ b/src/jsc/bindings/headers.h @@ -397,7 +397,7 @@ ZIG_DECL void Zig__GlobalObject__fetch(ErrorableResolvedSource* arg0, JSC::JSGlo ZIG_DECL void Zig__GlobalObject__onCrash(); ZIG_DECL JSC::EncodedJSValue Zig__GlobalObject__promiseRejectionTracker(JSC::JSGlobalObject* arg0, JSC::JSPromise* arg1, uint32_t JSPromiseRejectionOperation2); ZIG_DECL JSC::EncodedJSValue Zig__GlobalObject__reportUncaughtException(JSC::JSGlobalObject* arg0, JSC::Exception* arg1); -ZIG_DECL void Zig__GlobalObject__resolve(ErrorableString* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2, const BunString* arg3, BunString* arg4); +ZIG_DECL void Zig__GlobalObject__resolve(ErrorableString* arg0, JSC::JSGlobalObject* arg1, const BunString* arg2, const BunString* arg3, BunString* arg4, bool arg5); #endif diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp index 7c9bae02e0d3..17c0603cdbd1 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.cpp @@ -91,6 +91,13 @@ void JSNodeHTTPServerSocket::close() } } +void JSNodeHTTPServerSocket::reset() +{ + if (socket) { + us_socket_close(socket, LIBUS_SOCKET_CLOSE_CODE_CONNECTION_RESET, nullptr); + } +} + template static void upgradeToTunnelModeImpl(us_socket_t* socket, bool afterBody) { diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocket.h b/src/jsc/bindings/node/JSNodeHTTPServerSocket.h index bdd077af2db8..aa27ea6016ca 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocket.h +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocket.h @@ -155,6 +155,7 @@ class JSNodeHTTPServerSocket : public JSC::JSDestructibleObject { } void detach(); + void reset(); void syncPeerCertificateVerification(); void onClose(); void onDrain(); diff --git a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp index bdfc8aca98a0..745cb6506f03 100644 --- a/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp +++ b/src/jsc/bindings/node/JSNodeHTTPServerSocketPrototype.cpp @@ -29,6 +29,7 @@ JSC_DECLARE_CUSTOM_SETTER(jsNodeHttpServerSocketSetterOnData); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterOnData); JSC_DECLARE_CUSTOM_GETTER(jsNodeHttpServerSocketGetterBytesWritten); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketClose); +JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReset); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketWrite); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketEnd); JSC_DECLARE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketUpgradeToTunnel); @@ -65,6 +66,7 @@ static const JSC::HashTableValue JSNodeHTTPServerSocketPrototypeTableValues[] = { "remoteAddress"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterRemoteAddress, noOpSetter } }, { "localAddress"_s, static_cast(JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::ReadOnly), JSC::NoIntrinsic, { JSC::HashTableValue::GetterSetterType, jsNodeHttpServerSocketGetterLocalAddress, noOpSetter } }, { "close"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketClose, 0 } }, + { "reset"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketReset, 0 } }, { "write"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketWrite, 2 } }, { "end"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketEnd, 0 } }, { "upgradeToTunnel"_s, static_cast(JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontEnum), JSC::NoIntrinsic, { JSC::HashTableValue::NativeFunctionType, jsFunctionNodeHTTPServerSocketUpgradeToTunnel, 0 } }, @@ -101,6 +103,18 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketClose, (JSC::JSGlobalObje return JSValue::encode(JSC::jsUndefined()); } +JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketReset, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto* thisObject = dynamicDowncast(callFrame->thisValue()); + if (!thisObject) [[unlikely]] { + return JSValue::encode(JSC::jsUndefined()); + } + if (!thisObject->isClosed()) { + thisObject->reset(); + } + return JSValue::encode(JSC::jsUndefined()); +} + JSC_DEFINE_HOST_FUNCTION(jsFunctionNodeHTTPServerSocketUpgradeToTunnel, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto* thisObject = dynamicDowncast(callFrame->thisValue()); diff --git a/src/jsc/bindings/sqlite/JSSQLStatement.cpp b/src/jsc/bindings/sqlite/JSSQLStatement.cpp index a625e2c00e8b..f1f4774f74f2 100644 --- a/src/jsc/bindings/sqlite/JSSQLStatement.cpp +++ b/src/jsc/bindings/sqlite/JSSQLStatement.cpp @@ -1202,22 +1202,30 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementSetCustomSQLite, (JSC::JSGlobalObject * l } #if LAZY_LOAD_SQLITE - if (sqlite3_handle) { - throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "SQLite already loaded\nThis function can only be called before SQLite has been loaded and exactly once. SQLite auto-loads when the first time you open a Database."_s)); - return {}; - } - - // Use a static CString to keep the string alive for the lifetime of the process + auto requestedPath = sqliteStrValue.toWTFString(lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, {}); static CString sqlite3_lib_path_storage; - sqlite3_lib_path_storage = sqliteStrValue.toWTFString(lexicalGlobalObject).utf8(); + static String selectedSQLitePath; + auto requestedPathUTF8 = requestedPath.utf8(); RETURN_IF_EXCEPTION(scope, {}); - sqlite3_lib_path = sqlite3_lib_path_storage.data(); - - if (lazyLoadSQLite() == -1) { - sqlite3_handle = nullptr; - WTF::String msg = WTF::String::fromUTF8(dlerror()); - throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, msg)); - return {}; + { + WTF::Locker locker { sqlite3_handle_lock }; + if (sqlite3_handle) { + if (selectedSQLitePath.isNull() || selectedSQLitePath != requestedPath) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, "SQLite already loaded\nA custom SQLite path can only be selected before SQLite is loaded. Repeating a path is allowed only when that path selected the loaded library."_s)); + return {}; + } + } else { + // Keep the selected path alive for the process-global SQLite handle. + sqlite3_lib_path_storage = requestedPathUTF8; + sqlite3_lib_path = sqlite3_lib_path_storage.data(); + WTF::String msg; + if (lazyLoadSQLiteUnlocked(&msg) == -1) { + throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, msg)); + return {}; + } + selectedSQLitePath = requestedPath; + } } #endif @@ -1268,8 +1276,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementDeserialize, (JSC::JSGlobalObject * lexic } #if LAZY_LOAD_SQLITE - if (lazyLoadSQLite() < 0) [[unlikely]] { - WTF::String msg = WTF::String::fromUTF8(dlerror()); + WTF::String msg; + if (lazyLoadSQLite(&msg) < 0) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, msg)); return {}; } @@ -1766,8 +1774,8 @@ JSC_DEFINE_HOST_FUNCTION(jsSQLStatementOpenStatementFunction, (JSC::JSGlobalObje } #if LAZY_LOAD_SQLITE - if (lazyLoadSQLite() < 0) [[unlikely]] { - WTF::String msg = WTF::String::fromUTF8(dlerror()); + WTF::String msg; + if (lazyLoadSQLite(&msg) < 0) [[unlikely]] { throwException(lexicalGlobalObject, scope, createError(lexicalGlobalObject, msg)); return {}; } diff --git a/src/jsc/bindings/sqlite/NodeSqlite.cpp b/src/jsc/bindings/sqlite/NodeSqlite.cpp index 9babd8824381..01d2e5a190de 100644 --- a/src/jsc/bindings/sqlite/NodeSqlite.cpp +++ b/src/jsc/bindings/sqlite/NodeSqlite.cpp @@ -1041,8 +1041,9 @@ bool JSDatabaseSync::open(JSGlobalObject* globalObject, ThrowScope& scope) } #if LAZY_LOAD_SQLITE - if (lazyLoadSQLite() < 0) [[unlikely]] { - scope.throwException(globalObject, createError(globalObject, WTF::String::fromUTF8(dlerror()))); + WTF::String msg; + if (lazyLoadSQLite(&msg) < 0) [[unlikely]] { + scope.throwException(globalObject, createError(globalObject, msg)); return false; } #endif diff --git a/src/jsc/bindings/sqlite/lazy_sqlite3.h b/src/jsc/bindings/sqlite/lazy_sqlite3.h index c6b4a87d79a9..818727dd0da7 100644 --- a/src/jsc/bindings/sqlite/lazy_sqlite3.h +++ b/src/jsc/bindings/sqlite/lazy_sqlite3.h @@ -340,9 +340,18 @@ inline WTF::Lock sqlite3_handle_lock; // APIs must be runtime-gated on this instead of compiled out. inline bool lazy_sqlite3_has_session = false; -inline int lazyLoadSQLite() +inline void unloadSQLiteHandleUnlocked() +{ +#if OS(WINDOWS) + FreeLibrary(sqlite3_handle); +#else + dlclose(sqlite3_handle); +#endif + sqlite3_handle = nullptr; +} + +inline int lazyLoadSQLiteUnlocked(WTF::String* errorMessage = nullptr) { - WTF::Locker locker { sqlite3_handle_lock }; if (sqlite3_handle) return 0; #if OS(WINDOWS) @@ -352,10 +361,17 @@ inline int lazyLoadSQLite() #endif if (!sqlite3_handle) { + if (errorMessage) + *errorMessage = WTF::String::fromUTF8(dlerror()); return -1; } lazy_sqlite3_open_v2 = (lazy_sqlite3_open_v2_type)dlsym(sqlite3_handle, "sqlite3_open_v2"); - if (!lazy_sqlite3_open_v2) return -1; + if (!lazy_sqlite3_open_v2) { + if (errorMessage) + *errorMessage = WTF::String::fromUTF8(dlerror()); + unloadSQLiteHandleUnlocked(); + return -1; + } lazy_sqlite3_bind_blob = (lazy_sqlite3_bind_blob_type)dlsym(sqlite3_handle, "sqlite3_bind_blob"); lazy_sqlite3_bind_blob64 = (lazy_sqlite3_bind_blob64_type)dlsym(sqlite3_handle, "sqlite3_bind_blob64"); lazy_sqlite3_bind_double = (lazy_sqlite3_bind_double_type)dlsym(sqlite3_handle, "sqlite3_bind_double"); @@ -494,6 +510,12 @@ inline int lazyLoadSQLite() return 0; } +inline int lazyLoadSQLite(WTF::String* errorMessage = nullptr) +{ + WTF::Locker locker { sqlite3_handle_lock }; + return lazyLoadSQLiteUnlocked(errorMessage); +} + #if OS(WINDOWS) #undef dlsym #endif diff --git a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp index 4c382710a053..361af88940f5 100644 --- a/src/jsc/bindings/webcore/JSBroadcastChannel.cpp +++ b/src/jsc/bindings/webcore/JSBroadcastChannel.cpp @@ -384,7 +384,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/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index b94283911342..1c2d62c23b4e 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -78,6 +78,151 @@ namespace WebCore { using namespace JSC; +static bool isNodeWorkerBooleanExecArgv(const String& flag) +{ + return flag == "--enable-source-maps"_s + || flag == "--no-addons"_s + || flag == "--no-deprecation"_s + || flag == "--no-ffi-cc"_s + || flag == "--no-warnings"_s + || flag == "--pending-deprecation"_s + || flag == "--preserve-symlinks"_s + || flag == "--preserve-symlinks-main"_s + || flag == "--throw-deprecation"_s + || flag == "--tls-max-v1.2"_s + || flag == "--tls-max-v1.3"_s + || flag == "--tls-min-v1.0"_s + || flag == "--tls-min-v1.1"_s + || flag == "--tls-min-v1.2"_s + || flag == "--tls-min-v1.3"_s + || flag == "--trace-deprecation"_s + || flag == "--trace-env"_s + || flag == "--trace-env-js-stack"_s + || flag == "--trace-env-native-stack"_s + || flag == "--trace-events-enabled"_s + || flag == "--trace-exit"_s + || flag == "--trace-warnings"_s + || flag == "--use-system-ca"_s; +} + +static bool isNodeWorkerValueExecArgv(const String& flag) +{ + return flag == "-C"_s + || flag == "--conditions"_s + || flag == "--diagnostic-dir"_s + || flag == "--disable-warning"_s + || flag == "--dns-result-order"_s + || flag == "--max-http-header-size"_s + || flag == "--network-family-autoselection-attempt-timeout"_s + || flag == "--redirect-warnings"_s + || flag == "--tls-keylog"_s + || flag == "--trace-event-categories"_s + || flag == "--trace-event-file-pattern"_s + || flag == "--unhandled-rejections"_s; +} + +static bool isNodeWorkerDisallowedExecArgv(const String& flag) +{ + return flag == "--perf-basic-prof"_s + || flag == "--perf-basic-prof-only-functions"_s + || flag == "--perf-prof"_s + || flag == "--perf-prof-unwinding-info"_s + || flag == "--stack-trace-limit"_s + || flag == "--title"_s + || flag == "--use-bundled-ca"_s + || flag == "--use-openssl-ca"_s + || flag == "--zero-fill-buffers"_s; +} + +static std::optional parseNodeWorkerExecArgv(const Vector& execArgv, Vector& outputPreloads, size_t& evalPreloadCount, size_t& bunPreloadCount, size_t& requirePreloadCount, WorkerEvalMode& evalMode) +{ + Vector bunPreloads; + Vector requirePreloads; + Vector importPreloads; + + for (size_t i = 0; i < execArgv.size(); i++) { + const String& argument = execArgv[i]; + size_t equals = argument.find('='); + bool hasInlineValue = equals != notFound; + String flag = hasInlineValue ? argument.left(equals) : argument; + + bool isBunPreload = flag == "--preload"_s; + bool isRequire = flag == "--require"_s || flag == "-r"_s; + bool isImport = flag == "--import"_s; + if (isBunPreload || isRequire || isImport) { + if (flag == "-r"_s && hasInlineValue) + return makeString("Initiated Worker with invalid execArgv flags: "_s, argument); + + String value; + if (hasInlineValue) { + value = argument.substring(equals + 1); + if (value.isEmpty()) + return makeString("Initiated Worker with invalid execArgv flags: "_s, argument, " requires an argument"_s); + } else { + if (i + 1 >= execArgv.size() || execArgv[i + 1].startsWith("-"_s)) + return makeString("Initiated Worker with invalid execArgv flags: "_s, flag, " requires an argument"_s); + value = execArgv[++i]; + } + + if (isBunPreload) + bunPreloads.append(WTF::move(value)); + else if (isRequire) + requirePreloads.append(WTF::move(value)); + else + importPreloads.append(WTF::move(value)); + continue; + } + + if (flag == "--input-type"_s) { + String value; + if (hasInlineValue) { + value = argument.substring(equals + 1); + if (value.isEmpty()) + return makeString("Initiated Worker with invalid execArgv flags: "_s, flag, " requires an argument"_s); + } else if (i + 1 < execArgv.size() && !execArgv[i + 1].startsWith("-"_s)) { + value = execArgv[++i]; + } else { + return makeString("Initiated Worker with invalid execArgv flags: "_s, flag, " requires an argument"_s); + } + if (value == "module"_s || value == "module-typescript"_s) + evalMode = WorkerEvalMode::Module; + else if (value == "commonjs"_s || value == "commonjs-typescript"_s) + evalMode = WorkerEvalMode::CommonJS; + else + evalMode = WorkerEvalMode::Auto; + continue; + } else if (isNodeWorkerBooleanExecArgv(flag)) { + continue; + } else if (isNodeWorkerValueExecArgv(flag)) { + if (hasInlineValue) { + if (!argument.substring(equals + 1).isEmpty()) + continue; + } else if (i + 1 < execArgv.size() && !execArgv[i + 1].startsWith("-"_s)) { + i++; + continue; + } + return makeString("Initiated Worker with invalid execArgv flags: "_s, flag, " requires an argument"_s); + } else if (flag == "--inspect"_s || flag == "--inspect-brk"_s || flag == "--inspect-port"_s) { + continue; + } else if (!isNodeWorkerDisallowedExecArgv(flag)) { + // The complete option parser lives above this binding. Preserve + // previously accepted flags instead of rejecting valid Node + // options that this local list does not need to interpret. + continue; + } + + return makeString("Initiated Worker with invalid execArgv flags: "_s, argument); + } + + bunPreloadCount = bunPreloads.size(); + requirePreloadCount = requirePreloads.size(); + outputPreloads.appendVector(WTF::move(bunPreloads)); + outputPreloads.appendVector(WTF::move(requirePreloads)); + evalPreloadCount = outputPreloads.size(); + outputPreloads.appendVector(WTF::move(importPreloads)); + return std::nullopt; +} + // Functions static JSC_DECLARE_HOST_FUNCTION(jsWorkerPrototypeFunction_terminate); @@ -163,7 +308,7 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES // every option has validated (below). bool shareEnv = false; JSValue nodeWorkerObject {}; - if (callFrame->argumentCount() == 3) { + if (callFrame->argumentCount() >= 3) { nodeWorkerObject = callFrame->argument(2); options.kind = WorkerOptions::Kind::Node; } @@ -201,6 +346,14 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES options.evalMode = eval.toBoolean(lexicalGlobalObject); } + if (options.evalMode && options.kind == WorkerOptions::Kind::Node && callFrame->argumentCount() >= 4) { + auto evalSource = callFrame->argument(3); + if (evalSource.isString()) { + options.evalSource = evalSource.toWTFString(lexicalGlobalObject).isolatedCopy(); + RETURN_IF_EXCEPTION(throwScope, {}); + } + } + auto preloadModulesValue = optionsObject->getIfPropertyExists(lexicalGlobalObject, Identifier::fromString(vm, "preload"_s)); RETURN_IF_EXCEPTION(throwScope, {}); if (preloadModulesValue) { @@ -335,6 +488,12 @@ template<> __attribute__((minsize)) JSC::EncodedJSValue JSC_HOST_CALL_ATTRIBUTES }); RETURN_IF_EXCEPTION(throwScope, {}); options.execArgv.emplace(WTF::move(execArgv)); + if (options.kind == WorkerOptions::Kind::Node) { + if (auto error = parseNodeWorkerExecArgv(*options.execArgv, options.execArgvPreloadModules, options.execArgvEvalPreloadCount, options.execArgvBunPreloadCount, options.execArgvRequirePreloadCount, options.execArgvEvalMode)) { + throwScope.throwException(lexicalGlobalObject, Bun::createError(globalObject, Bun::ErrorCode::ERR_WORKER_INVALID_EXEC_ARGV, *error)); + return encodedJSValue(); + } + } } } diff --git a/src/jsc/bindings/webcore/Worker.cpp b/src/jsc/bindings/webcore/Worker.cpp index 15b0b9ff83e8..5c007387468b 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 "InternalModuleRegistry.h" #include "ErrorCode.h" @@ -129,6 +130,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()) @@ -211,6 +222,34 @@ extern "C" void WebWorker__dispatchError(Zig::GlobalObject* globalObject, Worker JSC_DECLARE_HOST_FUNCTION(jsFunctionSetParentPort); JSC_DECLARE_HOST_FUNCTION(jsFunctionSetNodeWorkerStdioPorts); +// 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); @@ -342,7 +381,7 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) bool isNodeWorker = proxy && proxy->options().kind == WorkerOptions::Kind::Node; - JSObject* array = constructEmptyArray(globalObject, nullptr, 17); + JSObject* array = constructEmptyArray(globalObject, nullptr, 19); RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, 0, workerData); RETURN_IF_EXCEPTION(scope, {}); @@ -380,6 +419,10 @@ JSValue createNodeWorkerThreadsBinding(Zig::GlobalObject* globalObject) RETURN_IF_EXCEPTION(scope, {}); array->putDirectIndex(globalObject, 16, JSWorker::getConstructor(vm, globalObject)); RETURN_IF_EXCEPTION(scope, {}); + array->putDirectIndex(globalObject, 17, JSFunction::create(vm, globalObject, 1, "workerHasRef"_s, jsFunctionWorkerHasRef, ImplementationVisibility::Public, NoIntrinsic)); + RETURN_IF_EXCEPTION(scope, {}); + array->putDirectIndex(globalObject, 18, JSFunction::create(vm, globalObject, 1, "workerEventLoopUtilization"_s, jsFunctionWorkerEventLoopUtilization, ImplementationVisibility::Public, NoIntrinsic)); + RETURN_IF_EXCEPTION(scope, {}); 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 cda4ce937db5..4df189efd72e 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" @@ -39,6 +40,9 @@ #include "Worker.h" #include "ZigGlobalObject.h" #include +#include +#include +#include #include namespace WebCore { @@ -55,6 +59,7 @@ void* WebWorker__create( void* parentVM, const BunString* name, const BunString* url, + const BunString* evalSource, BunString* errorMessage, uint32_t parentContextId, uint32_t contextId, @@ -67,12 +72,22 @@ 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); + size_t preloadModulesLen, + BunString* execArgvPreloadModulesPtr, + size_t execArgvPreloadModulesLen, + size_t execArgvEvalPreloadCount, + size_t execArgvBunPreloadCount, + size_t execArgvRequirePreloadCount, + uint8_t execArgvEvalMode); // 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 @@ -133,6 +148,11 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr preloadModules.append(Bun::toString(str)); } + Vector execArgvPreloadModules; + execArgvPreloadModules.reserveInitialCapacity(m_options.execArgvPreloadModules.size()); + for (auto& str : m_options.execArgvPreloadModules) + execArgvPreloadModules.append(Bun::toString(str)); + static_assert(sizeof(WTF::String) == sizeof(WTF::StringImpl*)); std::span execArgv = m_options.execArgv .transform([](Vector& vec) -> std::span { @@ -140,16 +160,24 @@ 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; BunString name = Bun::toString(m_options.name); BunString url = Bun::toString(scriptURL); + BunString evalSource = Bun::toString(m_options.evalSource); m_workerThread = WebWorker__create( this, WebCore::clientData(m_scriptExecutionContext->vm())->bunVM, &name, &url, + &evalSource, &errorMessage, m_loaderContextIdentifier, m_workerContextIdentifier, @@ -162,9 +190,17 @@ ExceptionOr WorkerMessagingProxy::startWorkerGlobalScope(const String& scr !m_options.execArgv.has_value(), execArgv.data(), execArgv.size(), + envUseSystemCa, preloadModules.begin(), - preloadModules.size()); + preloadModules.size(), + execArgvPreloadModules.begin(), + execArgvPreloadModules.size(), + m_options.execArgvEvalPreloadCount, + m_options.execArgvBunPreloadCount, + m_options.execArgvRequirePreloadCount, + static_cast(m_options.execArgvEvalMode)); m_options.preloadModules.clear(); + m_options.execArgvPreloadModules.clear(); if (!m_workerThread) { m_state.store(State::Closed); @@ -190,6 +226,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()); @@ -445,18 +499,168 @@ void WorkerMessagingProxy::postMessageToWorkerObject(MessageWithMessagePorts&& m } } -void WorkerMessagingProxy::postMessageErrorToWorkerObject(String&& message) +void WorkerMessagingProxy::postMessageErrorToWorkerObject(String&& message, String&& code) { - ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, m_loaderLoopKind, [protectedThis = Ref { *this }, message = WTF::move(message).isolatedCopy()](ScriptExecutionContext&) { + ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, m_loaderLoopKind, [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; +} + +class SerializedWorkerErrorMetadata final : public ThreadSafeRefCounted { +public: + static Ref create() { return adoptRef(*new SerializedWorkerErrorMetadata); } + + RefPtr properties; + RefPtr cause; + RefPtr causeMetadata; + bool causeEnumerable { false }; + +private: + SerializedWorkerErrorMetadata() = default; +}; + +static constexpr unsigned maxWorkerErrorMetadataDepth = 32; + +static RefPtr serializeWorkerErrorMetadata(Zig::GlobalObject& globalObject, JSC::ErrorInstance& error, HashSet& seen, bool includeCode, unsigned depth) +{ + if (!seen.add(&error).isNewEntry) + return nullptr; + + auto& vm = JSC::getVM(&globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + auto metadata = SerializedWorkerErrorMetadata::create(); + + auto* propertiesObject = JSC::constructEmptyObject(&globalObject); + JSC::Strong protectedProperties(vm, propertiesObject); + JSC::PropertyNameArrayBuilder properties(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + error.methodTable()->getOwnPropertyNames(&error, &globalObject, properties, JSC::DontEnumPropertiesMode::Exclude); + if (!scope.exception()) { + for (const auto& property : properties) { + if (property == vm.propertyNames->cause || (!includeCode && property == WebCore::builtinNames(vm).codePublicName())) + continue; + JSC::JSValue propertyValue = error.get(&globalObject, property); + if (scope.exception()) { + scope.clearException(); + continue; + } + if (!propertyValue.isCallable() && !propertyValue.isSymbol()) { + propertiesObject->putDirectMayBeIndex(&globalObject, property, propertyValue); + if (scope.exception()) + scope.clearException(); + } + } + metadata->properties = SerializedScriptValue::create(globalObject, propertiesObject, SerializationForStorage::No, SerializationErrorMode::NonThrowing); + } + CLEAR_IF_EXCEPTION(scope); + + JSC::PropertyDescriptor causeDescriptor; + bool hasCause = error.getOwnPropertyDescriptor(&globalObject, vm.propertyNames->cause, causeDescriptor); + if (scope.exception()) { + scope.clearException(); + return metadata; + } + if (!hasCause) + return metadata; + + JSC::JSValue cause = error.get(&globalObject, vm.propertyNames->cause); + if (scope.exception()) { + scope.clearException(); + return metadata; + } + if (cause.isCallable() || cause.isSymbol()) + return metadata; + + metadata->cause = SerializedScriptValue::create(globalObject, cause, SerializationForStorage::No, SerializationErrorMode::NonThrowing); + CLEAR_IF_EXCEPTION(scope); + if (!metadata->cause) + return metadata; + metadata->causeEnumerable = causeDescriptor.enumerable(); + if (depth + 1 < maxWorkerErrorMetadataDepth) { + if (auto* causeError = dynamicDowncast(cause)) + metadata->causeMetadata = serializeWorkerErrorMetadata(globalObject, *causeError, seen, true, depth + 1); + } + return metadata; +} + +static void applyWorkerErrorMetadata(JSC::JSGlobalObject& globalObject, JSC::JSObject& error, const SerializedWorkerErrorMetadata& metadata) +{ + auto& vm = JSC::getVM(&globalObject); + auto scope = DECLARE_TOP_EXCEPTION_SCOPE(vm); + + if (metadata.properties) { + JSC::JSValue propertiesValue = metadata.properties->deserialize(globalObject, &globalObject, SerializationErrorMode::NonThrowing); + if (scope.exception()) { + scope.clearException(); + } else if (auto* properties = propertiesValue.getObject()) { + JSC::PropertyNameArrayBuilder names(vm, JSC::PropertyNameMode::Strings, JSC::PrivateSymbolMode::Exclude); + properties->methodTable()->getOwnPropertyNames(properties, &globalObject, names, JSC::DontEnumPropertiesMode::Exclude); + if (!scope.exception()) { + for (const auto& name : names) { + JSC::JSValue propertyValue = properties->get(&globalObject, name); + if (scope.exception()) { + scope.clearException(); + continue; + } + error.putDirectMayBeIndex(&globalObject, name, propertyValue); + if (scope.exception()) + scope.clearException(); + } + } + CLEAR_IF_EXCEPTION(scope); + } + } + + if (!metadata.cause) + return; + JSC::JSValue cause = metadata.cause->deserialize(globalObject, &globalObject, SerializationErrorMode::NonThrowing); + if (scope.exception()) { + scope.clearException(); + return; + } + JSC::PropertyDescriptor descriptor; + descriptor.setValue(cause); + descriptor.setWritable(true); + descriptor.setEnumerable(metadata.causeEnumerable); + descriptor.setConfigurable(true); + error.methodTable()->defineOwnProperty(&error, &globalObject, vm.propertyNames->cause, descriptor, false); + if (scope.exception()) { + scope.clearException(); + return; + } + if (metadata.causeMetadata) { + if (auto* causeError = dynamicDowncast(cause)) + applyWorkerErrorMetadata(globalObject, *causeError, *metadata.causeMetadata); + } +} + 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 @@ -466,20 +670,34 @@ 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); + + // Node's worker error serializer preserves an Error's own enumerable metadata and recursively + // serializes `cause`. The ordinary structured-clone Error path intentionally keeps only + // standard Error fields, so carry these worker-only additions separately. + RefPtr serializedMetadata; + if (auto* errorInstance = dynamicDowncast(value)) { + HashSet seen; + serializedMetadata = serializeWorkerErrorMetadata(workerGlobalObject, *errorInstance, seen, false, 0); } - return ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, m_loaderLoopKind, [protectedThis = Ref { *this }, serialized = serialized.releaseNonNull(), errorCode = WTF::move(errorCode).isolatedCopy()](ScriptExecutionContext& context) { + return ScriptExecutionContext::postTaskTo(m_loaderContextIdentifier, m_loaderLoopKind, [protectedThis = Ref { *this }, serialized = serialized.releaseNonNull(), serializedMetadata = WTF::move(serializedMetadata), errorCode = WTF::move(errorCode).isolatedCopy()](ScriptExecutionContext& context) { RefPtr workerObject = protectedThis->m_workerObject; if (!workerObject) return; @@ -492,6 +710,10 @@ bool WorkerMessagingProxy::postSerializedErrorToWorkerObject(Zig::GlobalObject& if (auto* errorObject = deserialized.getObject()) errorObject->putDirect(vm, WebCore::builtinNames(vm).codePublicName(), JSC::jsString(vm, errorCode)); } + if (serializedMetadata) { + if (auto* errorObject = deserialized.getObject()) + applyWorkerErrorMetadata(*globalObject, *errorObject, *serializedMetadata); + } ErrorEvent::Init init; init.error = deserialized; workerObject->dispatchEvent(ErrorEvent::create(eventNames().errorEvent, init, EventIsTrusted::Yes)); @@ -502,11 +724,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 273a0f129f95..466e274acc33 100644 --- a/src/jsc/bindings/webcore/WorkerMessagingProxy.h +++ b/src/jsc/bindings/webcore/WorkerMessagingProxy.h @@ -85,6 +85,10 @@ 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. @@ -126,7 +130,7 @@ class WorkerMessagingProxy final : public ThreadSafeRefCounted argv; // If nullopt, inherit execArgv from the parent thread std::optional> execArgv; + // --require/--import modules parsed from an explicit node Worker execArgv. + // Kept raw so resolution and evaluation happen in the worker VM. + Vector execArgvPreloadModules; + size_t execArgvEvalPreloadCount { 0 }; + size_t execArgvBunPreloadCount { 0 }; + size_t execArgvRequirePreloadCount { 0 }; + WorkerEvalMode execArgvEvalMode { WorkerEvalMode::Auto }; + String evalSource; }; } // namespace WebCore diff --git a/src/jsc/event_loop.rs b/src/jsc/event_loop.rs index e63b3583a000..b31df908b757 100644 --- a/src/jsc/event_loop.rs +++ b/src/jsc/event_loop.rs @@ -714,6 +714,11 @@ impl EventLoop { .as_ptr() } + /// `usockets_loop()` without the panic, for callers that can run before `ensure_waker`. + pub fn try_usockets_loop(&self) -> Option<*mut uws::Loop> { + self.uws_loop.map(|l| l.as_ptr()) + } + /// [`usockets_loop`](Self::usockets_loop) as the platform-native loop /// (`us_loop_t*` on POSIX, its `uv_loop_t*` on Windows). #[inline] diff --git a/src/jsc/modules/NodeModuleModule.cpp b/src/jsc/modules/NodeModuleModule.cpp index feea89f168f6..64ee659a82c2 100644 --- a/src/jsc/modules/NodeModuleModule.cpp +++ b/src/jsc/modules/NodeModuleModule.cpp @@ -13,7 +13,10 @@ #include #include #include "JavaScriptCore/Completion.h" +#include "JavaScriptCore/JSModuleLoader.h" +#include "JavaScriptCore/JSModuleNamespaceObject.h" #include "JavaScriptCore/JSNativeStdFunction.h" +#include "JavaScriptCore/ModuleRegistryEntry.h" #include "JSCommonJSExtensions.h" #include "PathInlines.h" @@ -289,6 +292,8 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionResolveFileName, JSC::JSValue moduleName = callFrame->argument(0); JSC::JSValue fromValue = callFrame->argument(1); JSC::JSValue optionsValue = callFrame->argument(3); // 4th argument is options + bool fromIsModuleKey = false; + Strong referrerRoot; auto& names = builtinNames(vm); if (moduleName.isUndefinedOrNull()) { @@ -303,6 +308,7 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionResolveFileName, // fast path: it's a real CommonJS module object. auto* cjs = dynamicDowncast(fromValue)) { fromValue = cjs->filename(); + fromIsModuleKey = cjs->filenameIsModuleKey; } else if (fromValue.isObject()) { // slow path: userland code did something weird. Try filename first, then id auto* obj = fromValue.getObject(); @@ -328,6 +334,16 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionResolveFileName, } } + if (fromValue.isString()) { + auto filename = fromValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + auto referrer = moduleReferrerFromFilename(filename, fromIsModuleKey); + if (referrer != filename) { + referrerRoot.set(vm, jsString(vm, referrer)); + fromValue = referrerRoot.get(); + } + } + // Handle options.paths if provided JSC::JSValue pathsValue = JSC::jsUndefined(); if (optionsValue.isObject()) { @@ -471,6 +487,8 @@ PathResolveModule getParent(VM& vm, JSGlobalObject* global, JSValue maybe_parent RETURN_IF_EXCEPTION(scope, value); if (filename.isString()) { value.filename = filename.toString(global); + if (auto* module = dynamicDowncast(parent); module && filename == module->filename()) + value.filenameIsModuleKey = module->filenameIsModuleKey; } RELEASE_AND_RETURN(scope, value); } @@ -524,7 +542,7 @@ JSC::JSValue resolveLookupPaths(JSC::JSGlobalObject* globalObject, String reques auto filenameValue = parent.filename->value(globalObject); RETURN_IF_EXCEPTION(scope, {}); auto filename = Bun::toString(filenameValue); - auto paths = JSValue::decode(Resolver__nodeModulePathsJSValue(&filename, globalObject, true)); + auto paths = JSValue::decode(Resolver__nodeModulePathsJSValue(&filename, globalObject, true, parent.filenameIsModuleKey)); RELEASE_AND_RETURN(scope, paths); } else { auto array = JSC::constructEmptyArray(globalObject, nullptr, 0); @@ -535,7 +553,15 @@ JSC::JSValue resolveLookupPaths(JSC::JSGlobalObject* globalObject, String reques JSValue dirname; if (parent.filename) { - EncodedJSValue encodedFilename = JSValue::encode(parent.filename); + JSString* filename = parent.filename; + auto filenameValue = filename->value(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + unsigned pathLength = parent.filenameIsModuleKey ? moduleKeyPathLength(filenameValue) : filenameValue->length(); + if (pathLength != filenameValue->length()) { + filename = JSC::jsSubstring(globalObject, filename, 0, pathLength); + RETURN_IF_EXCEPTION(scope, {}); + } + EncodedJSValue encodedFilename = JSValue::encode(filename); #if OS(WINDOWS) dirname = JSValue::decode( Bun__Path__dirname(globalObject, true, &encodedFilename, 1)); @@ -856,6 +882,78 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionSyncBuiltinESMExports, (JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + auto* zigGlobalObject = defaultGlobalObject(globalObject); + + MarkedArgumentBuffer namespaces; + auto* moduleLoader = zigGlobalObject->moduleLoader(); + for (auto moduleName : builtinModuleNames) { + String moduleKey(moduleName); + if (!moduleKey.startsWith("node:"_s)) + moduleKey = makeString("node:"_s, moduleName); + auto key = Identifier::fromString(vm, moduleKey); + auto* entry = moduleLoader->registryEntry(key); + if (!entry) + continue; + auto* record = entry->record(); + if (!record || !record->moduleEnvironmentMayBeNull()) + continue; + + auto* namespaceObject = record->getModuleNamespace(globalObject); + if (scope.exception()) [[unlikely]] + break; + namespaces.append(namespaceObject); + } + RETURN_IF_EXCEPTION(scope, {}); + if (namespaces.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + + struct ExportUpdate { + JSModuleNamespaceObject* namespaceObject; + Identifier name; + }; + Vector updates; + MarkedArgumentBuffer values; + + // A throwing CommonJS getter must leave every live ESM binding unchanged. + for (JSValue namespaceValue : namespaces) { + auto* namespaceObject = uncheckedDowncast(namespaceValue); + JSValue exportsValue = namespaceObject->get(globalObject, vm.propertyNames->defaultKeyword); + RETURN_IF_EXCEPTION(scope, {}); + auto* exportsObject = exportsValue.getObject(); + if (!exportsObject) + continue; + + PropertyNameArrayBuilder names(vm, PropertyNameMode::Strings, PrivateSymbolMode::Exclude); + namespaceObject->methodTable()->getOwnPropertyNames(namespaceObject, globalObject, names, DontEnumPropertiesMode::Exclude); + RETURN_IF_EXCEPTION(scope, {}); + + for (auto& name : names) { + if (name == vm.propertyNames->defaultKeyword) + continue; + + PropertySlot slot(exportsObject, PropertySlot::InternalMethodType::GetOwnProperty); + bool hasOwn = exportsObject->methodTable()->getOwnPropertySlot(exportsObject, globalObject, name, slot); + RETURN_IF_EXCEPTION(scope, {}); + JSValue value = hasOwn ? slot.getValue(globalObject, name) : jsUndefined(); + RETURN_IF_EXCEPTION(scope, {}); + updates.append({ namespaceObject, name }); + values.append(value); + } + } + if (values.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + + for (size_t i = 0; i < updates.size(); ++i) { + updates[i].namespaceObject->overrideExportValue(globalObject, updates[i].name, values.at(i)); + RETURN_IF_EXCEPTION(scope, {}); + } + return JSC::JSValue::encode(JSC::jsUndefined()); } diff --git a/src/jsc/modules/NodeModuleModule.h b/src/jsc/modules/NodeModuleModule.h index 8d496eb9f4e1..b3775cc1b4c3 100644 --- a/src/jsc/modules/NodeModuleModule.h +++ b/src/jsc/modules/NodeModuleModule.h @@ -21,7 +21,7 @@ JSC_DECLARE_HOST_FUNCTION(jsFunctionIsModuleResolveFilenameSlowPathEnabled); JSC::JSValue createStreamIterEnabledFlag(Zig::GlobalObject*); void addNodeModuleConstructorProperties(JSC::VM &vm, Zig::GlobalObject *globalObject); -extern "C" JSC::EncodedJSValue Resolver__nodeModulePathsJSValue(const BunString* specifier, JSC::JSGlobalObject*, bool use_dirname); +extern "C" JSC::EncodedJSValue Resolver__nodeModulePathsJSValue(const BunString* specifier, JSC::JSGlobalObject*, bool use_dirname, bool split_query); extern "C" bool ModuleLoader__isBuiltin(const char* data, size_t len); struct PathResolveModule { @@ -29,6 +29,7 @@ struct PathResolveModule { JSString* filename = nullptr; /// Derive `paths` from `filename` if needed bool pathsArrayLazy = false; + bool filenameIsModuleKey = false; }; JSC::JSValue resolveLookupPaths(JSC::JSGlobalObject* globalObject, String request, PathResolveModule parent); diff --git a/src/jsc/resolver_jsc.rs b/src/jsc/resolver_jsc.rs index 2428a104b946..0f7ab7686e5a 100644 --- a/src/jsc/resolver_jsc.rs +++ b/src/jsc/resolver_jsc.rs @@ -19,14 +19,34 @@ fn node_module_paths_for_js(global: &JSGlobalObject, frame: &CallFrame) -> JsRes } let in_str = argument.to_bun_string(global)?; - Ok(node_module_paths_js_value(&in_str, global, false)) + Ok(node_module_paths_js_value(&in_str, global, false, false)) } #[unsafe(no_mangle)] extern "C" fn Resolver__propForRequireMainPaths(global: &JSGlobalObject) -> JSValue { crate::mark_binding!(); - node_module_paths_js_value(&BunString::static_("."), global, false) + node_module_paths_js_value(&BunString::static_("."), global, false, false) +} + +/// The `` of a `?query` module key. Twin of `moduleKeyPathLength` (PathInlines.h). +pub fn module_key_without_query(key: &[u8]) -> &[u8] { + // `\\?\C:\...` and `\\.\...` are paths, not queries. + let device_prefix_len = if cfg!(windows) + && key.len() >= 4 + && bun_paths::is_sep_any(key[0]) + && bun_paths::is_sep_any(key[1]) + && (key[2] == b'?' || key[2] == b'.') + && bun_paths::is_sep_any(key[3]) + { + 4 + } else { + 0 + }; + match strings::index_of_char_usize(&key[device_prefix_len..], b'?') { + Some(query_start) => &key[..device_prefix_len + query_start], + None => key, + } } // C++ callers pass a borrowed `const BunString*` (`Bun::toString`). @@ -35,12 +55,17 @@ extern "C" fn node_module_paths_js_value( in_str: &BunString, global: &JSGlobalObject, use_dirname: bool, + split_query: bool, ) -> JSValue { let mut list: Vec = Vec::new(); let utf8 = in_str.to_utf8(); let base_path: &[u8] = if use_dirname { - resolve_path::dirname::(utf8.slice()) + resolve_path::dirname::(if split_query { + module_key_without_query(utf8.slice()) + } else { + utf8.slice() + }) } else { utf8.slice() }; diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 95d4acfeb292..53caeac0058b 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -46,12 +46,43 @@ use std::thread::JoinHandle; use bun_core::{EncodedSlice, String as BunString, WTFStringImpl}; use bun_io::KeepAlive; +use bun_options_types::context::WorkerEvalMode; use crate::virtual_machine::{self, VirtualMachine, runtime_hooks}; use crate::{self as jsc, EncodedSliceJsc as _, JSGlobalObject, JSValue, JsError, LogJsc}; bun_core::define_scoped_log!(log, Worker, hidden); +fn eval_source_has_module_syntax(source_bytes: &[u8]) -> bool { + if source_bytes.is_empty() { + return false; + } + + let arena = bun_alloc::Arena::new(); + let mut ast_memory_allocator = bun_ast::ASTMemoryAllocator::borrowing(&arena); + let _ast_scope = ast_memory_allocator.enter(); + let source = bun_ast::Source::init_path_string(b"[worker eval]".as_slice(), source_bytes); + let define = bun_js_parser::Define::default(); + let options = bun_js_parser::ParserOptions::init( + bun_js_parser::options::JSX::Pragma::default(), + bun_ast::Loader::Tsx, + ); + let mut parse_log = bun_ast::Log::default(); + let Ok(parser) = bun_js_parser::Parser::init(options, &mut parse_log, &source, &define, &arena) + else { + return false; + }; + let Ok(bun_js_parser::Result::Ast(ast)) = parser.parse() else { + return false; + }; + matches!( + ast.exports_kind, + bun_ast::ExportsKind::Esm + | bun_ast::ExportsKind::EsmWithDynamicFallback + | bun_ast::ExportsKind::EsmWithDynamicFallbackFromCjs + ) +} + #[derive(bun_ptr::ThreadSafeRefCounted)] pub struct WebWorker { // ---- Immutable after `create()` (any thread) ---------------------------- @@ -84,7 +115,17 @@ pub struct WebWorker { inherit_exec_argv: bool, unresolved_specifier: Box<[u8]>, preloads: Vec>, + preload_require_start: usize, + preload_require_count: usize, + worker_preloads: Vec>, + worker_eval_preloads: Vec>, + worker_preload_require_start: usize, + worker_preload_require_count: usize, + worker_eval_mode: WorkerEvalMode, 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, @@ -96,6 +137,11 @@ pub struct WebWorker { /// ancestor) asks it to terminate. `None` before `start_vm()` publishes it /// and after `shutdown()` unpublishes it. vm_handle: bun_threading::Guarded>, + /// What the parent's `eventLoopUtilization()` reads (node reports all zeros outside this + /// window): published once the worker's loop has started, taken back in `shutdown()` before + /// the loop is destroyed, so a reader holding the lock always sees a live loop. Holds no VM + /// state: the stamps are copied out, and the loop is read only through its atomic counter. + elu: bun_threading::Guarded>, // ---- Parent-thread only --------------------------------------------------- /// Keep-alive on the parent's event loop: taken in `create()`, toggled by @@ -130,6 +176,22 @@ struct WorkerVmInit { transform_options: bun_options_types::schema::api::TransformOptions, env_loader: bun_dotenv::Loader, proxy_env_slots: jsc::rare_data::ProxyEnvSlots, + /// The worker's own `execArgv` parsed (node's per-Environment options), or the defaults when + /// it inherits the parent's. + exec_argv: virtual_machine::WorkerExecArgv, + has_own_exec_argv: bool, + /// Resolved per node_worker.cc: the parent's decision, re-derived from a custom `env`, then + /// overridden by the flags (own execArgv, else the parent's), which are also what children + /// inherit as `use_system_ca_flag`. + use_system_ca: Option, + use_system_ca_flag: Option, +} + +/// See [`WebWorker::elu`]. The stamps never change once the loop has started, so copies are exact. +struct EluSource { + loop_: *mut bun_uws::Loop, + loop_start_ns: u64, + loop_idle_base_ns: u64, } enum EntryOutcome { @@ -285,6 +347,7 @@ impl WebWorker { parent: *mut VirtualMachine, name_str: &BunString, specifier_str: &BunString, + eval_source_str: &BunString, error_message: &mut BunString, _parent_context_id: u32, this_context_id: u32, @@ -297,8 +360,17 @@ impl WebWorker { inherit_exec_argv: bool, exec_argv_ptr: *const WTFStringImpl, exec_argv_len: usize, + // `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, preload_modules_ptr: *const BunString, preload_modules_len: usize, + exec_argv_preload_modules_ptr: *const BunString, + exec_argv_preload_modules_len: usize, + exec_argv_eval_preload_count: usize, + exec_argv_bun_preload_count: usize, + exec_argv_require_preload_count: usize, + exec_argv_eval_mode: u8, ) -> *mut WebWorker { jsc::mark_binding(); log!("[{}] create", this_context_id); @@ -324,7 +396,14 @@ impl WebWorker { let preload_modules: &[BunString] = unsafe { bun_core::ffi::slice(preload_modules_ptr, preload_modules_len) }; - let mut preloads: Vec> = Vec::with_capacity(preload_modules_len); + // SAFETY: WorkerMessagingProxy's vector stays live through this call; + // the preloads are copied before the borrowed slice can escape. + let exec_argv_preload_modules: &[BunString] = unsafe { + bun_core::ffi::slice(exec_argv_preload_modules_ptr, exec_argv_preload_modules_len) + }; + + let mut preloads: Vec> = + Vec::with_capacity(preload_modules_len.saturating_add(exec_argv_preload_modules_len)); for module in preload_modules { let utf8_slice = module.to_utf8(); // node: builtin specifiers skip the file resolver — the worker-side @@ -351,25 +430,103 @@ impl WebWorker { // its own thread; the worker never dereferences `parent`. // SAFETY: `parent` is the calling thread's live VM. let parent_ref = unsafe { &*parent }; + let ( + worker_preloads, + worker_eval_preloads, + worker_preload_require_start, + worker_preload_require_count, + worker_eval_mode, + ) = if inherit_exec_argv { + ( + parent_ref.worker_preloads.clone(), + parent_ref.worker_eval_preloads.clone(), + parent_ref.worker_preload_require_start, + parent_ref.worker_preload_require_count, + parent_ref.worker_eval_mode, + ) + } else { + let worker_preloads: Vec> = exec_argv_preload_modules + .iter() + .map(|module| module.to_utf8().slice().to_vec().into_boxed_slice()) + .collect(); + let worker_eval_preloads = + worker_preloads[..exec_argv_eval_preload_count.min(worker_preloads.len())].to_vec(); + let worker_eval_mode = match exec_argv_eval_mode { + x if x == WorkerEvalMode::CommonJS as u8 => WorkerEvalMode::CommonJS, + x if x == WorkerEvalMode::Module as u8 => WorkerEvalMode::Module, + _ => WorkerEvalMode::Auto, + }; + ( + worker_preloads, + worker_eval_preloads, + exec_argv_bun_preload_count, + exec_argv_require_preload_count, + worker_eval_mode, + ) + }; + let mut preload_require_start = 0; + let mut preload_require_count = 0; + if is_node_worker { + let include_import_preloads = if !eval_mode { + true + } else { + match worker_eval_mode { + WorkerEvalMode::Auto => { + worker_preloads.len() > worker_eval_preloads.len() + && eval_source_has_module_syntax(eval_source_str.to_utf8().slice()) + } + WorkerEvalMode::CommonJS => false, + WorkerEvalMode::Module => true, + } + }; + let exec_arg_preloads = if include_import_preloads { + &worker_preloads + } else { + &worker_eval_preloads + }; + preloads.splice(0..0, exec_arg_preloads.iter().cloned()); + preload_require_start = worker_preload_require_start.min(exec_arg_preloads.len()); + preload_require_count = worker_preload_require_count.min( + exec_arg_preloads + .len() + .saturating_sub(preload_require_start), + ); + } let store_fd = parent_ref.transpiler.resolver.store_fd; let mut transform_options = (*parent_ref.transpiler.options.transform_options).clone(); - if !inherit_exec_argv { + // A worker's own `execArgv` carries node's per-Environment options (parsed with the + // RunCommand param table, hence the hook); without one it inherits the parent's. + let exec_argv: virtual_machine::WorkerExecArgv = if inherit_exec_argv { + Default::default() + } else { let hooks = runtime_hooks().expect("RuntimeHooks not installed"); - // SAFETY: caller passed valid (ptr,len) borrowed from C++ WorkerOptions; - // the hook only reads the slice. - let parsed = unsafe { - (hooks.parse_worker_exec_argv_flags)(bun_core::ffi::slice( - exec_argv_ptr, - exec_argv_len, - )) - }; - if let Some(flags) = parsed { - let parent_allows_addons = transform_options.allow_addons.unwrap_or(true); - transform_options.allow_addons = Some(parent_allows_addons && flags.allow_addons); - let parent_allows_ffi_cc = transform_options.allow_ffi_cc.unwrap_or(true); - transform_options.allow_ffi_cc = Some(parent_allows_ffi_cc && flags.allow_ffi_cc); + // SAFETY: caller passed valid (ptr,len) borrowed from the C++ WorkerOptions, alive + // for the proxy's lifetime; the hook only reads the slice. + unsafe { + (hooks.parse_worker_exec_argv)(bun_core::ffi::slice(exec_argv_ptr, exec_argv_len)) } + }; + // A Worker cannot re-enable what its parent disabled. + 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); + } + if let Some(allow_ffi_cc) = exec_argv.allow_ffi_cc { + let parent_allows = transform_options.allow_ffi_cc.unwrap_or(true); + transform_options.allow_ffi_cc = Some(parent_allows && allow_ffi_cc); } + // 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 (its own execArgv's, else the parent's) + // win. + let use_system_ca_flag = if inherit_exec_argv { + parent_ref.use_system_ca_flag + } else { + exec_argv.use_system_ca + }; + let use_system_ca_base = match env_use_system_ca { + -1 => parent_ref.use_system_ca, + v => Some(v == 1), + }; // The worker's `process.env` starts as a copy of the parent's now (as in // Node). Proxy-env values may be RefCountedEnvValue bytes owned by the // parent's proxy_env_storage: snapshot slots + map under its lock so @@ -391,6 +548,10 @@ impl WebWorker { transform_options, env_loader, proxy_env_slots, + exec_argv, + has_own_exec_argv: !inherit_exec_argv, + use_system_ca: use_system_ca_flag.or(use_system_ca_base), + use_system_ca_flag, }; // The construction ref: handed to C++ on success, dropped on failure. @@ -414,14 +575,24 @@ impl WebWorker { inherit_exec_argv, unresolved_specifier: spec_slice.slice().to_vec().into_boxed_slice(), preloads, + preload_require_start, + preload_require_count, + worker_preloads, + worker_eval_preloads, + worker_preload_require_start, + worker_preload_require_count, + worker_eval_mode, name: if name_str.is_empty() { bun_core::ZBox::default() } 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_handle: bun_threading::Guarded::new(None), + elu: bun_threading::Guarded::new(None), vm: Cell::new(core::ptr::null_mut()), parent_poll_ref: JsCell::new(KeepAlive::init()), join_handle: JsCell::new(None), @@ -533,6 +704,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. @@ -560,6 +737,30 @@ impl WebWorker { } } + /// The parent reading this worker's loop counters for `eventLoopUtilization()`: false outside + /// the window in which [`WebWorker::elu`] is published (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 { + let elu = this.elu.lock(); + let Some(src) = elu.as_ref() else { + return false; + }; + // SAFETY: published with a live loop and taken back before that loop is destroyed, both + // under this lock; `us_loop_idle_ns` only reads the loop's atomic counters. + let raw_idle_ns = unsafe { bun_uws::us_loop_idle_ns(src.loop_) }; + let Some(elapsed_ms) = VirtualMachine::loop_elapsed_ms_since(src.loop_start_ns) else { + return false; + }; + *out_idle_ms = VirtualMachine::loop_idle_ms_above(src.loop_idle_base_ns, raw_idle_ns); + *out_elapsed_ms = elapsed_ms; + true + } + /// 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")] @@ -588,6 +789,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] @@ -667,6 +873,10 @@ impl WebWorker { transform_options, env_loader, proxy_env_slots, + mut exec_argv, + has_own_exec_argv, + use_system_ca, + use_system_ca_flag, } = init; // worker-thread only field; no other thread reads `arena`. @@ -694,6 +904,8 @@ impl WebWorker { env_loader: NonNull::new(loader_ptr), store_fd: self.store_fd, graph: crate::virtual_machine::standalone_module_graph(), + use_system_ca, + use_system_ca_flag, ..Default::default() }, )?; @@ -715,6 +927,37 @@ 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 { + 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(bun_options_types::context::CpuProf::DEFAULT_INTERVAL), + thread_id: self.thread_id(), + }) + } else if !has_own_exec_argv { + 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 now (rather than at the end of startVM) so that: @@ -801,6 +1044,17 @@ impl WebWorker { // `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); + vm.as_mut() + .worker_preloads + .clone_from(&self.worker_preloads); + vm.as_mut() + .worker_eval_preloads + .clone_from(&self.worker_eval_preloads); + vm.as_mut().preload_require_start = self.preload_require_start; + vm.as_mut().preload_require_count = self.preload_require_count; + vm.as_mut().worker_preload_require_start = self.worker_preload_require_start; + vm.as_mut().worker_preload_require_count = self.worker_preload_require_count; + vm.as_mut().worker_eval_mode = self.worker_eval_mode; // Resolve the entry point on the worker thread (the parent only stored // the raw specifier). The returned slice is BORROWED — every exit from @@ -860,6 +1114,17 @@ 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(); + // SAFETY: `event_loop()` is this VM's live loop; `init_worker` installed its uSockets loop. + let loop_ = unsafe { (*vm.event_loop()).usockets_loop() }; + // Taken back in `shutdown()` step 1, before teardown destroys that loop. + *self.elu.lock() = Some(EluSource { + loop_, + loop_start_ns: vm.loop_start_ns.load(Ordering::Acquire), + loop_idle_base_ns: vm.loop_idle_base_ns.load(Ordering::Acquire), + }); let promise = match vm.as_mut().load_entry_point_for_web_worker(path) { Ok(p) => p, Err(_) => { @@ -1010,6 +1275,9 @@ impl WebWorker { // ---- 1. Unpublish vm ------------------------------------------------ drop(self.vm_handle.lock().take()); + // A parent mid-read holds this lock, so the loop it is reading outlives the read; the + // teardown below is what destroys the loop. + *self.elu.lock() = None; let vm_ptr = self.vm.replace(core::ptr::null_mut()); // ---- 2. User exit handlers ----------------------------------------- diff --git a/src/libuv_sys/libuv.rs b/src/libuv_sys/libuv.rs index 9175e67e2677..a22db321cfe9 100644 --- a/src/libuv_sys/libuv.rs +++ b/src/libuv_sys/libuv.rs @@ -2855,6 +2855,12 @@ unsafe extern "C" { path: *const c_char, cb: uv_fs_cb, ) -> ReturnCode; + pub fn uv_utf16_to_wtf8( + utf16: *const u16, + utf16_len: isize, + wtf8_ptr: *mut *mut c_char, + wtf8_len_ptr: *mut usize, + ) -> ReturnCode; pub fn uv_fs_stat( loop_: *mut Loop, req: *mut fs_t, diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 505d9fc30e3b..926fed9b5357 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -14,6 +14,15 @@ use crate::compile_target::CompileTarget; use crate::global_cache::GlobalCache; use crate::offline_mode::OfflineMode; +#[repr(u8)] +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub enum WorkerEvalMode { + #[default] + Auto, + CommonJS, + Module, +} + // Every `Box<[u8]>` / `Vec>` struct field below is a proc-lifetime // CLI string: populated once from argv/bunfig during startup and never freed. @@ -43,6 +52,10 @@ pub struct ContextData { pub no_exit_on_error: bool, pub preloads: Vec>, + pub worker_eval_preloads: Vec>, + pub worker_preload_require_start: usize, + pub worker_preload_require_count: usize, + pub worker_eval_mode: WorkerEvalMode, pub has_loaded_global_config: bool, } @@ -83,6 +96,10 @@ impl Default for ContextData { sequential: false, no_exit_on_error: false, preloads: Vec::new(), + worker_eval_preloads: Vec::new(), + worker_preload_require_start: 0, + worker_preload_require_count: 0, + worker_eval_mode: WorkerEvalMode::Auto, has_loaded_global_config: false, } } @@ -597,6 +614,12 @@ pub struct CpuProf { pub json_format: bool, } +impl CpuProf { + /// Sampling interval in microseconds without `--cpu-prof-interval`; a Worker's execArgv falls + /// back to it the same way. + pub const DEFAULT_INTERVAL: u32 = 1000; +} + impl Default for CpuProf { // See `ContextData::default` — folded into the single startup call site. #[inline(always)] @@ -605,7 +628,7 @@ impl Default for CpuProf { enabled: false, name: Box::default(), dir: Box::default(), - interval: 1000, + interval: Self::DEFAULT_INTERVAL, md_format: false, json_format: false, } diff --git a/src/resolver/data_url.rs b/src/resolver/data_url.rs index 915324cb5da7..2e3eac5540fb 100644 --- a/src/resolver/data_url.rs +++ b/src/resolver/data_url.rs @@ -166,8 +166,34 @@ impl<'a> DataURL<'a> { Ok(parsed) } - pub fn decode_mime_type(&self) -> bun_http_types::MimeType::MimeType { - bun_http_types::MimeType::MimeType::init(self.mime_type, false, None) + /// Returns the category of this URL's normalized MIME essence. + pub fn mime_type_category(&self) -> bun_http_types::MimeType::Category { + use bun_http_types::MimeType::Category; + let essence = match strings::index_of_char(self.mime_type, b';') { + Some(i) => &self.mime_type[..i as usize], + None => self.mime_type, + } + .trim_ascii(); + let mut lowered = [0u8; 64]; + let Some(lowered) = lowered.get_mut(..essence.len()) else { + return Category::Other; + }; + lowered.copy_from_slice(essence); + lowered.make_ascii_lowercase(); + + Category::init(lowered) + } + + /// Returns the runtime loader implied by this URL's MIME essence. + pub fn loader(&self) -> Option { + use bun_http_types::MimeType::Category; + + match self.mime_type_category() { + Category::Javascript => Some(bun_ast::Loader::Js), + Category::Css => Some(bun_ast::Loader::Css), + Category::Json => Some(bun_ast::Loader::Json), + _ => None, + } } /// Decodes the data from the data URL. Always returns an owned slice. diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 31dd498e7972..a74c75b958a2 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1242,12 +1242,8 @@ impl<'a> Resolver<'a> { Ok(Some(data_url)) => { // "import 'data:text/javascript,console.log(123)';" // "@import 'data:text/css,body{background:white}';" - let mime = data_url.decode_mime_type(); use ::bun_http_types::MimeType::Category; - if matches!( - mime.category, - Category::Javascript | Category::Css | Category::Json | Category::Text - ) { + if data_url.loader().is_some() || data_url.mime_type_category() == Category::Text { if let Some(debug) = self.debug_logs.as_mut() { debug.add_note(b"Putting this path in the \"dataurl\" namespace".to_vec()); } diff --git a/src/runtime/api/BunObject.rs b/src/runtime/api/BunObject.rs index d35e8556611b..7f642a835f61 100644 --- a/src/runtime/api/BunObject.rs +++ b/src/runtime/api/BunObject.rs @@ -1107,7 +1107,9 @@ fn resolve_with_args( let mut query_string = BunString::EMPTY; let decoded_specifier; + let mut file_url_suffix = BunString::EMPTY; let specifier_for_resolve = if specifier.starts_with_ascii(b"file://") { + file_url_suffix = bun_url::suffix_from_file_url(specifier); decoded_specifier = bun_url::path_from_file_url(specifier); &decoded_specifier } else { @@ -1120,6 +1122,7 @@ fn resolve_with_args( from, Some(&mut query_string), mode, + !specifier.starts_with_ascii(b"file://"), )? { Ok(path) => path, Err(err) if err.as_class_ref::().is_some() => { @@ -1129,10 +1132,30 @@ fn resolve_with_args( Err(err) => return Err(ctx.throw_value(err)), }; - if !query_string.is_empty() { + // CommonJS cache keys must distinguish a literal '?' in the resolved path + // from the query suffix, including when a relative import reaches that path. + // Bun.resolve's directory-based public APIs still return filesystem paths. + let encoded_module_key = IS_FILE_PATH + && !mode.is_esm() + && result_value.index_of_ascii_char(b'?').is_some() + && bun_paths::is_absolute(result_value.to_utf8().slice()); + let result_value = if encoded_module_key { + bun_url::file_url_from_string(&result_value) + } else { + result_value + }; + + if !query_string.is_empty() || !file_url_suffix.is_empty() { let mut arraylist: Vec = Vec::with_capacity(1024); // Vec writes are infallible. - let _ = write!(&mut arraylist, "{}{}", result_value, query_string); + let _ = write!(&mut arraylist, "{}", result_value); + if file_url_suffix.is_empty() { + let _ = write!(&mut arraylist, "{}", query_string); + } else if !encoded_module_key && file_url_suffix.starts_with_ascii(b"#") { + let _ = write!(&mut arraylist, "?{}", file_url_suffix); + } else { + let _ = write!(&mut arraylist, "{}", file_url_suffix); + } return Ok(Resolved::Found(bun_string_jsc::create_utf8_for_js( ctx, &arraylist, diff --git a/src/runtime/bake/production.rs b/src/runtime/bake/production.rs index 5be3077ba7b4..1387ef240c22 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; @@ -134,6 +140,14 @@ pub fn build_command(ctx: Context) -> crate::Result<()> { // preload/argv are `Vec>`; clone because the VM owns its // fields. Startup-only, so the copies are not hot. vm.preload.clone_from(&ctx.preloads); + vm.worker_preloads.clone_from(&ctx.preloads); + vm.worker_eval_preloads + .clone_from(&ctx.worker_eval_preloads); + vm.worker_preload_require_start = ctx.worker_preload_require_start; + vm.worker_preload_require_count = ctx.worker_preload_require_count; + vm.worker_eval_mode = ctx.worker_eval_mode; + vm.preload_require_start = ctx.worker_preload_require_start; + vm.preload_require_count = ctx.worker_preload_require_count; vm.argv.clone_from(&ctx.passthrough); vm.arena = NonNull::new(&raw mut arena); // vm.allocator = arena.arena() — dropped per §Allocators diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 6688c44f2559..444effab5c7f 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -268,6 +268,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"), @@ -287,6 +290,10 @@ const RUNTIME_PARAMS_: &[ParamType] = &[ parse_param!( "--unhandled-rejections One of \"strict\", \"throw\", \"warn\", \"none\", or \"warn-with-error-code\"" ), + // Node uses this to choose CommonJS or ESM for eval/stdin. Bun's eval + // loader already accepts either syntax; retaining the option lets eval + // Workers inherit the matching preload semantics. + parse_param!("--input-type "), parse_param!( "--console-depth Set the default depth for console.log object inspection (default: 2)" ), @@ -736,7 +743,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(); } @@ -768,6 +775,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 @@ -1023,6 +1061,25 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result { + bun_options_types::context::WorkerEvalMode::CommonJS + } + Some(value) if value == b"module" || value == b"module-typescript" => { + bun_options_types::context::WorkerEvalMode::Module + } + _ => bun_options_types::context::WorkerEvalMode::Auto, + }; + ctx.worker_eval_preloads.clone_from(&ctx.preloads); + ctx.worker_eval_preloads.extend( + preloads + .iter() + .chain(preloads2.iter()) + .map(|preload| Box::<[u8]>::from(*preload)), + ); + let total_preloads = ctx.preloads.len() + preloads.len() + preloads2.len() @@ -1463,7 +1520,12 @@ 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 826be93d3e65..5154c03085d4 100644 --- a/src/runtime/cli/repl_command.rs +++ b/src/runtime/cli/repl_command.rs @@ -77,6 +77,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), @@ -91,6 +92,13 @@ impl ReplCommand { // SAFETY: vm valid as above; preload/argv are disjoint from `b`'s transpiler borrow. unsafe { (*vm).preload = core::mem::take(&mut ctx.preloads); + (*vm).worker_preloads.clone_from(&(*vm).preload); + (*vm).worker_eval_preloads = core::mem::take(&mut ctx.worker_eval_preloads); + (*vm).worker_preload_require_start = ctx.worker_preload_require_start; + (*vm).worker_preload_require_count = ctx.worker_preload_require_count; + (*vm).worker_eval_mode = ctx.worker_eval_mode; + (*vm).preload_require_start = ctx.worker_preload_require_start; + (*vm).preload_require_count = ctx.worker_preload_require_count; (*vm).argv = core::mem::take(&mut ctx.passthrough); // `vm.dns_result_order` is a `u8` (see VirtualMachine.rs); set // post-init like run_command.rs since InitOptions doesn't carry it. diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index c0a303130aa4..733cd7f454ba 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -956,6 +956,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), @@ -971,6 +972,13 @@ Full documentation is available at https://bun.com/docs/cli/run // `vm.preload`/`vm.argv` are `Vec>` on both sides; // hand the CLI's vectors over wholesale (process-lifetime, never freed). vm.preload = std::mem::take(&mut ctx.preloads); + vm.worker_preloads.clone_from(&vm.preload); + vm.worker_eval_preloads = std::mem::take(&mut ctx.worker_eval_preloads); + vm.worker_preload_require_start = ctx.worker_preload_require_start; + vm.worker_preload_require_count = ctx.worker_preload_require_count; + vm.worker_eval_mode = ctx.worker_eval_mode; + vm.preload_require_start = ctx.worker_preload_require_start; + vm.preload_require_count = ctx.worker_preload_require_count; vm.argv = std::mem::take(&mut ctx.passthrough); // `InitOptions` has no `store_fd` field, so set it on the resolver directly. vm.transpiler.resolver.store_fd = ctx.debug.hot_reload != cli::command::HotReload::None; @@ -1157,6 +1165,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. @@ -1174,6 +1183,13 @@ Full documentation is available at https://bun.com/docs/cli/run } vm.preload = std::mem::take(&mut ctx.preloads); + vm.worker_preloads.clone_from(&vm.preload); + vm.worker_eval_preloads = std::mem::take(&mut ctx.worker_eval_preloads); + vm.worker_preload_require_start = ctx.worker_preload_require_start; + vm.worker_preload_require_count = ctx.worker_preload_require_count; + vm.worker_eval_mode = ctx.worker_eval_mode; + vm.preload_require_start = ctx.worker_preload_require_start; + vm.preload_require_count = ctx.worker_preload_require_count; vm.argv = std::mem::take(&mut ctx.passthrough); // `vm.main` is a BACKREF (`*const [u8]`) into `entry_path`'s heap @@ -1302,18 +1318,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 }); @@ -1323,7 +1336,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()) }; @@ -1425,6 +1438,7 @@ impl Run<'_> { } } + vm.defer_loop_start(true); match vm.load_entry_point(entry) { Ok(promise) => { // SAFETY: `promise` is a live GC cell returned by the module loader. @@ -1489,6 +1503,10 @@ impl Run<'_> { } // ── core run-loop ────────────────────────────────────────────────── + // Node's loopStart: the entry point's synchronous evaluation is over, whether or not the + // evaluate hook saw it begin (a `Module.runMain` override runs the entry itself). + vm.defer_loop_start(false); + vm.mark_loop_started(); if vm.is_watcher_enabled() { vm.report_exception_in_hot_reloaded_module_if_needed(); loop { diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index f3ae3966e5fb..8d50cc974a51 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -1916,6 +1916,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,feature_flags,preserve_symlinks, @@ -1934,6 +1935,14 @@ impl TestCommand { vm.argv = core::mem::take(&mut ctx.passthrough); // Clone (not take): build_worker_argv reads ctx.preloads to forward --preload. vm.preload = ctx.preloads.clone(); + vm.worker_preloads.clone_from(&vm.preload); + vm.worker_eval_preloads + .clone_from(&ctx.worker_eval_preloads); + vm.worker_preload_require_start = ctx.worker_preload_require_start; + vm.worker_preload_require_count = ctx.worker_preload_require_count; + vm.worker_eval_mode = ctx.worker_eval_mode; + vm.preload_require_start = ctx.worker_preload_require_start; + vm.preload_require_count = ctx.worker_preload_require_count; vm.transpiler.options.rewrite_jest_for_tests = true; bun_http::EXPERIMENTAL_HTTP2_CLIENT_FROM_CLI.store( ctx.runtime_options.experimental_http2_fetch, diff --git a/src/runtime/dispatch_js2native.rs b/src/runtime/dispatch_js2native.rs index 6b38b97e2e04..b890a581e433 100644 --- a/src/runtime/dispatch_js2native.rs +++ b/src/runtime/dispatch_js2native.rs @@ -58,17 +58,50 @@ 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 = vm.loop_idle_ms(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 5d1ff53722d5..48304e85ec69 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -28,7 +28,6 @@ use bun_jsc::module_loader::{ArenaResetGuard, FetchFlags, TranspileArgs, Transpi use bun_jsc::resolved_source::Bytecode; use bun_jsc::virtual_machine::{ InitOptions, RuntimeHooks, RuntimeState as OpaqueRuntimeState, SweepResult, VirtualMachine, - WorkerExecArgvFlags, }; use bun_jsc::{ AnyPromise, ErrorableResolvedSource, JSGlobalObject, JSInternalPromise, JSModuleLoader, @@ -279,6 +278,7 @@ pub(crate) unsafe fn runtime_state_of(vm: *mut VirtualMachine) -> *mut RuntimeSt /// `RuntimeState.ssl_ctx_cache` (this crate). The cached `SSL_CTX*` is held /// for the VM's lifetime so the weak-cache entry never tombstones. pub(crate) fn default_client_ssl_ctx(vm: &VirtualMachine) -> *mut bun_uws::SslCtx { + let use_system_ca = vm.tls_use_system_ca_option(); let rare = vm.as_mut().rare_data(); if rare.default_client_ssl_ctx.is_none() { let mut err = bun_uws::create_bun_socket_error_t::none; @@ -297,8 +297,14 @@ pub(crate) fn default_client_ssl_ctx(vm: &VirtualMachine) -> *mut bun_uws::SslCt // 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: {}", @@ -765,6 +771,10 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS // SAFETY: per fn contract. let n = unsafe { &*vm }.preload.len(); + // SAFETY: per fn contract; these scalar fields are immutable during loading. + let require_start = unsafe { &*vm }.preload_require_start; + // SAFETY: per fn contract; the live VM owns this immutable preload count. + let require_end = require_start.saturating_add(unsafe { &*vm }.preload_require_count); for i in 0..n { // SAFETY: `i < n`; the `Box<[u8]>` allocation is stable across the // `resolve_and_auto_install` call below (which only touches @@ -784,13 +794,18 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS bun_core::String::from_bytes(normalized) } else { // ── resolve ───────────────────────────────────────────────────── + let import_kind = if i >= require_start && i < require_end { + ImportKind::Require + } else { + ImportKind::Stmt + }; // SAFETY: per fn contract; `top_level_dir` is the `'static` fs // singleton field. let mut result = match unsafe { (*vm).transpiler.resolver.resolve_and_auto_install( &*top_level_dir, normalized, - ImportKind::Stmt, + import_kind, global_cache, ) } { @@ -1526,7 +1541,7 @@ static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { console_print_runtime_object, load_standalone_sourcemap, apply_standalone_runtime_flags, - parse_worker_exec_argv_flags, + parse_worker_exec_argv, stop_cron_for_vm_teardown, cron_clear_all_reload, retroactively_report_discovered_tests, @@ -1566,19 +1581,24 @@ unsafe fn apply_standalone_runtime_flags( crate::run_main::apply_standalone_runtime_flags(unsafe { &mut *transpiler }, graph); } -/// Scan a Worker's `execArgv` for `--no-addons` and `--no-ffi-cc`. Like the -/// CLI parser, the scan stops at the first positional. -/// +/// 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_flags( +/// 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 { - let mut flags = WorkerExecArgvFlags { - allow_addons: true, - allow_ffi_cc: true, - }; +) -> 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 no_ffi_cc = 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; @@ -1586,19 +1606,59 @@ unsafe fn parse_worker_exec_argv_flags( // SAFETY: per fn contract — `arg` is a live `WTFStringImpl*`. let owned = unsafe { &*arg }.to_owned_slice_z(); let bytes = owned.as_bytes(); + 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" { - flags.allow_addons = false; + no_addons = true; } else if bytes == b"--no-ffi-cc" { - flags.allow_ffi_cc = false; + no_ffi_cc = 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()); } } - Some(flags) + // Override both unconditionally: the caller ANDs them with the parent's values. + out.allow_addons = Some(!no_addons); + out.allow_ffi_cc = Some(!no_ffi_cc); + out } /// `jsc.API.cron.CronJob.clearAllForVM(vm, .teardown)` — @@ -3869,7 +3929,7 @@ fn force_loader_from_api_u8(api_loader: u8) -> Option { /// `bun_ast::LoaderHashTable` (= `StringArrayHashMap`). fn loader_for_path(path: &Fs::Path<'_>, loaders: &bun_ast::LoaderHashTable) -> Option { if path.is_data_url() { - return Some(Loader::Dataurl); + return Some(bun_bundler::options::data_url_loader(path.text)); } let name = path.name(); let ext = name.ext; @@ -3902,6 +3962,7 @@ fn loader_for_path(path: &Fs::Path<'_>, loaders: &bun_ast::LoaderHashTable) -> O unsafe fn normalize_specifier_for_loader<'a>( jsc_vm: *mut VirtualMachine, slice_: &'a [u8], + preserve_path_delimiters: bool, ) -> (&'a [u8], &'a [u8], &'a [u8]) { let mut slice = slice_; if slice.is_empty() { @@ -3924,7 +3985,9 @@ unsafe fn normalize_specifier_for_loader<'a>( } let specifier = slice; let mut query: &[u8] = b""; - if let Some(i) = bun_core::strings::index_of_char_usize(slice, b'?') { + if !preserve_path_delimiters + && let Some(i) = bun_core::strings::index_of_char_usize(slice, b'?') + { let i = i as usize; query = &slice[i..]; slice = &slice[..i]; @@ -3955,11 +4018,17 @@ unsafe fn get_loader_and_virtual_source<'a>( virtual_source_to_use: &'a mut Option, blob_to_deinit: &mut Option, type_attribute_str: Option<&[u8]>, + preserve_path_delimiters: bool, ) -> crate::Result> { let (normalized_file_path_from_specifier, specifier, query) = // SAFETY: per fn contract. - unsafe { normalize_specifier_for_loader(jsc_vm, specifier_str) }; - let mut path = Fs::Path::init(normalized_file_path_from_specifier); + unsafe { normalize_specifier_for_loader(jsc_vm, specifier_str, preserve_path_delimiters) }; + let mut path = + if bun_core::strings::has_prefix_comptime(normalized_file_path_from_specifier, b"data:") { + Fs::Path::init_with_namespace(normalized_file_path_from_specifier, b"dataurl") + } else { + Fs::Path::init(normalized_file_path_from_specifier) + }; // SAFETY: per fn contract — `transpiler.options` is a value field of the VM. let mut loader: Option = @@ -4170,6 +4239,7 @@ pub unsafe extern "C" fn Bun__transpileFile( allow_promise: bool, is_commonjs_require: bool, force_loader: u8, + preserve_path_delimiters: bool, ) -> *mut c_void { use bun_jsc::resolved_source::Tag as ResolvedSourceTag; @@ -4198,6 +4268,7 @@ pub unsafe extern "C" fn Bun__transpileFile( &mut virtual_source_to_use, &mut blob_to_deinit, type_attribute_str, + preserve_path_delimiters, ) } { Ok(lr) => lr, @@ -4253,6 +4324,9 @@ pub unsafe extern "C" fn Bun__transpileFile( // ── module_type sniff from extension / package.json ───────────────────── let module_type: ModuleType = 'brk: { + if lr.path.is_data_url() { + break 'brk ModuleType::Unknown; + } let ext = lr.path.name().ext; // regex /\.[cm][jt]s$/ if ext.len() == b".cjs".len() { @@ -4301,6 +4375,8 @@ pub unsafe extern "C" fn Bun__transpileFile( ) }; if !had_blob + // Async completion only has the filesystem specifier, not the encoded module key. + && !preserve_path_delimiters && allow_promise && (has_loaded || is_in_preload) && concurrent_loader.is_java_script_like() diff --git a/src/runtime/node/node_fs.rs b/src/runtime/node/node_fs.rs index 828858d06994..a3289a1b2de8 100644 --- a/src/runtime/node/node_fs.rs +++ b/src/runtime/node/node_fs.rs @@ -441,18 +441,21 @@ fn openat_os_path(dirfd: FD, path: &OSPathSliceZ, flags: i32, mode: Mode) -> May sys::openat_windows(dirfd, path.as_slice(), flags, mode) } -/// Check whether a directory exists at `(fd, path)` — dispatches on path element width. On -/// Windows `OSPathSliceZ` is already `&WStr`, so forward to the wide overload -/// instead of narrowing to UTF-8 and re-widening. POSIX is a forwarder. +/// Match mkdir's path semantics when checking an existing directory. #[inline] -fn directory_exists_at_os_path(dir: FD, path: &OSPathSliceZ) -> Maybe { +fn directory_exists_os_path(path: &OSPathSliceZ) -> Maybe { #[cfg(not(windows))] { - sys::directory_exists_at(dir, path) + sys::directory_exists_at(FD::INVALID, path) } #[cfg(windows)] { - sys::directory_exists_at_w(dir, path.as_slice()) + // Win32 resolves relative dot components using the logical cwd, including junctions. + match Syscall::stat_w(path) { + Ok(st) => Ok(sys::S::ISDIR(st.st_mode as _)), + Err(err) if err.get_errno() == E::ENOENT => Ok(false), + Err(err) => Err(err), + } } } @@ -5470,7 +5473,7 @@ impl NodeFS { // it is unclear if macOS lies about if the existing item is // a directory or not, so it is checked. E::EISDIR | E::EEXIST => { - return match directory_exists_at_os_path(FD::INVALID, path) { + return match directory_exists_os_path(path) { Err(_) => Err(sys::Error { errno: err.errno, syscall: sys::Tag::mkdir, @@ -5562,9 +5565,7 @@ impl NodeFS { // On Windows, this may happen if trying to mkdir replacing a file #[cfg(windows)] { - if let Ok(res) = - directory_exists_at_os_path(FD::INVALID, parent) - { + if let Ok(res) = directory_exists_os_path(parent) { // is a directory. break. if !res { // SAFETY: `working_mem` is not used after this return; the @@ -7385,45 +7386,52 @@ impl NodeFS { { let mut outbuf = bun_paths::path_buffer_pool::get(); let inbuf = &mut self.sync_error_buf; - // SAFETY: single-threaded init flag (resolver/fs.rs). - debug_assert!( - bun_resolver::fs::INSTANCE_LOADED.load(core::sync::atomic::Ordering::Relaxed) - ); - let path_slice = args.path.slice(); - // SAFETY: instance() returns the leaked singleton; INSTANCE_LOADED checked above. - let fs = FileSystem::get(); - let parts = [fs.top_level_dir, path_slice]; - let inbuf_len = inbuf.len(); - let Some(joined) = fs.abs_buf_checked(&parts, &mut inbuf[..inbuf_len - 1]) else { - return Err(sys::Error { - errno: E::ENAMETOOLONG as _, - syscall: sys::Tag::realpath, - path: args.path.slice().into(), - ..Default::default() - }); - }; - let path_len = joined.len(); - inbuf[path_len] = 0; - let path = ZStr::from_buf(&inbuf[..], path_len); - - #[cfg(any(target_os = "linux", target_os = "android"))] - let flags = sys::O::PATH; // O_PATH is faster - #[cfg(not(any(target_os = "linux", target_os = "android")))] - let flags = sys::O::RDONLY | sys::O::NONBLOCK | sys::O::NOCTTY; - - let fd = match sys::open(path, flags, 0) { - Err(err) => return Err(err.with_path(path)), - Ok(fd_) => fd_, + let path = if variant == RealpathVariant::Emulated { + debug_assert!( + bun_resolver::fs::INSTANCE_LOADED.load(core::sync::atomic::Ordering::Relaxed) + ); + // SAFETY: instance() returns the process-lifetime resolver singleton. + let fs = FileSystem::get(); + let cwd = if path_slice.starts_with(b"/") { + &b""[..] + } else { + fs.top_level_dir + }; + let mut spill = Vec::new(); + let joined = paths::resolve_path::join_spill::( + &mut spill, + &[cwd, path_slice], + ); + if joined.len() >= inbuf.len() { + return Err(sys::Error { + errno: E::ENAMETOOLONG as _, + syscall: sys::Tag::realpath, + path: args.path.slice().into(), + ..Default::default() + }); + } + let path_len = joined.len(); + inbuf[..path_len].copy_from_slice(joined); + inbuf[path_len] = 0; + ZStr::from_buf(&inbuf[..], path_len) + } else { + if path_slice.len() >= inbuf.len() { + return Err(sys::Error { + errno: E::ENAMETOOLONG as _, + syscall: sys::Tag::realpath, + path: args.path.slice().into(), + ..Default::default() + }); + } + args.path.slice_z(inbuf) }; - let _close = scopeguard::guard(fd, |fd| fd.close()); - - let buf = match Syscall::get_fd_path(fd, &mut outbuf) { + // Resolve without opening and closing the target, which would + // release process-owned POSIX locks. + let buf = match Syscall::realpath(path, &mut outbuf) { Err(err) => return Err(err.with_path(path)), Ok(buf_) => buf_, }; - - let _ = variant; if args.encoding == Encoding::Utf8 { if let PathLike::String(s) = &args.path { if strings::eql_long(s.slice(), buf, true) { @@ -9522,30 +9530,6 @@ pub(crate) fn zig_delete_tree( treat_as_dir = true; continue 'handle_entry; } - #[cfg(target_os = "macos")] - Err(e @ E::EACCES) => { - // Same ancestor-rmdir retry as the directory sites: - // node reports the containing directory's ENOTEMPTY on - // macOS when a file child cannot be unlinked. EPERM is - // NOT converted -- on macOS it can mean "target is a - // directory" and must keep flowing to the caller. - let ancestor = &stack[top_idx]; - let ancestor_name: &[u8] = if ancestor.name_is_borrowed { - sub_path - } else { - &ancestor.name - }; - if matches!( - dt_delete_dir( - sys::Dir::borrow(&ancestor.parent_dir), - ancestor_name - ), - Err(E::ENOTEMPTY | E::EEXIST) - ) { - return Err(dt_err(E::ENOTEMPTY)); - } - return Err(dt_err(e)); - } // "EPERM because it's a directory" is OS-dependent // (Linux returns EISDIR; macOS returns EPERM). We only // get errno, so forward EPERM as PermissionDenied — diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index 207ec3f71c16..18f4525356af 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -670,14 +670,6 @@ mod _impl { } #[cfg(not(windows))] { - // The posix implementation of uv_os_homedir first checks the HOME - // environment variable, then falls back to reading the passwd entry. - if let Some(home) = env_var::HOME.get() { - if !home.is_empty() { - return Ok(BunString::from_bytes(home)); - } - } - // From libuv: // > Calling sysconf(_SC_GETPW_R_SIZE_MAX) would get the suggested size, but it // > is frequently 1024 or 4096, so we can just use that directly. The pwent diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index 619f925b98d3..d80beaa26a4d 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -312,18 +312,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/node/path.rs b/src/runtime/node/path.rs index 57e9b0816c3c..6e4a3f17d59a 100644 --- a/src/runtime/node/path.rs +++ b/src/runtime/node/path.rs @@ -1,6 +1,6 @@ use crate::jsc::rare_data::PathBuf as RarePathBuf; use crate::jsc::{ - JSGlobalObject, JSStringView, JSValue, JsResult, StringJsc as _, SysErrorJsc as _, + JSGlobalObject, JSStringView, JSValue, JsError, JsResult, StringJsc as _, SysErrorJsc as _, bun_string_jsc, }; use crate::node::validators::{validate_object, validate_string}; @@ -2372,7 +2372,13 @@ fn relative_posix_t<'a, T: PathCharCwd>( // Trim leading forward slashes. // Backed by from_buf. - let from_orig = resolve_posix_t(&[from], from_buf, tmp_buf)?; + let from_orig = resolve_posix_t( + &[from], + from_buf, + tmp_buf, + MAX_PATH_SIZE_UPPER, + posix_cwd_t::, + )?; let from_orig_len = from_orig.len(); // Backed by buf. // Borrowck: resolve into buf, then operate via raw indices. @@ -2381,7 +2387,7 @@ fn relative_posix_t<'a, T: PathCharCwd>( // resolved value. let to_orig_len = { let (ptr, len) = { - let r = resolve_posix_t(&[to], buf, tmp_buf)?; + let r = resolve_posix_t(&[to], buf, tmp_buf, MAX_PATH_SIZE_UPPER, posix_cwd_t::)?; (r.as_ptr(), r.len()) }; if ptr != buf.as_ptr() { @@ -2517,7 +2523,13 @@ fn relative_windows_t<'a, T: PathCharCwd>( } // Backed by from_buf. - let from_orig = resolve_windows_t(&[from], from_buf, tmp_buf)?; + let from_orig = resolve_windows_t( + &[from], + from_buf, + tmp_buf, + MAX_PATH_SIZE_UPPER, + get_cwd_t::, + )?; let from_orig_len = from_orig.len(); // Backed by buf. // Borrowck: resolve into buf, then operate via raw indices. @@ -2526,7 +2538,7 @@ fn relative_windows_t<'a, T: PathCharCwd>( // resolved value. let to_orig_len = { let (ptr, len) = { - let r = resolve_windows_t(&[to], buf, tmp_buf)?; + let r = resolve_windows_t(&[to], buf, tmp_buf, MAX_PATH_SIZE_UPPER, get_cwd_t::)?; (r.as_ptr(), r.len()) }; if ptr != buf.as_ptr() { @@ -2784,11 +2796,17 @@ fn relative( /// Based on Node v21.6.1 path.posix.resolve: /// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L1095 -fn resolve_posix_t<'a, T: PathCharCwd>( +fn resolve_posix_t<'a, T: PathCharCwd, E, F>( paths: &[&[T]], buf: &'a mut [T], buf2: &mut [T], -) -> MaybeSlice<'a, T> { + cwd_buffer_capacity: usize, + mut get_cwd: F, +) -> Result<&'a [T], E> +where + E: From, + F: for<'b> FnMut(&'b mut [T]) -> Result<&'b mut [T], E>, +{ // Backed by expandable buf2 because resolvedPath may be long. // We use buf2 here because resolvePosixT is called by other methods and using // buf2 here avoids stepping on others' toes. @@ -2807,12 +2825,17 @@ fn resolve_posix_t<'a, T: PathCharCwd>( // in this scope; copy into buf2 before reusing. // Sized to the larger of the two T variants. let mut tmp_buf: [T; MAX_PATH_SIZE_UPPER]; + let mut tmp_spill: Vec; let path: &[T] = if i_i64 >= 0 { paths[usize::try_from(i_i64).expect("int cast")] } else { - // cwd is limited to MAX_PATH_BYTES. - tmp_buf = [T::default(); MAX_PATH_SIZE_UPPER]; - &*posix_cwd_t(&mut tmp_buf)? + if cwd_buffer_capacity > MAX_PATH_SIZE_UPPER { + tmp_spill = vec![T::default(); cwd_buffer_capacity]; + &*get_cwd(&mut tmp_spill)? + } else { + tmp_buf = [T::default(); MAX_PATH_SIZE_UPPER]; + &*get_cwd(&mut tmp_buf)? + } }; // validateString of `path` is performed in pub fn resolve. let len = path.len(); @@ -2885,14 +2908,27 @@ fn resolve_posix_t<'a, T: PathCharCwd>( /// Based on Node v21.6.1 path.win32.resolve: /// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L162 -fn resolve_windows_t<'a, T: PathCharCwd>( +fn resolve_windows_t<'a, T: PathCharCwd, E, F>( paths: &[&[T]], buf: &'a mut [T], buf2: &mut [T], -) -> MaybeSlice<'a, T> { + cwd_buffer_capacity: usize, + mut get_cwd: F, +) -> Result<&'a [T], E> +where + E: From, + F: for<'b> FnMut(&'b mut [T]) -> Result<&'b mut [T], E>, +{ let is_sep_t = is_sep_windows_t::; // Sized to the larger of the two T variants. - let mut tmp_buf = [T::default(); MAX_PATH_SIZE_UPPER + 1]; + let mut tmp_stack = [T::default(); MAX_PATH_SIZE_UPPER + 3]; + let mut tmp_spill; + let tmp_buf: &mut [T] = if cwd_buffer_capacity > MAX_PATH_SIZE_UPPER { + tmp_spill = vec![T::default(); cwd_buffer_capacity + 3]; + &mut tmp_spill + } else { + &mut tmp_stack + }; // Backed by tmpBuf. // Borrowck: track resolved_device length into tmp_buf. @@ -2942,7 +2978,7 @@ fn resolve_windows_t<'a, T: PathCharCwd>( path_len = p.len(); } else if resolved_device_len == 0 { // cwd is limited to MAX_PATH_BYTES. - cwd_len = get_cwd_t(&mut tmp_buf[..])?.len(); + cwd_len = get_cwd(&mut tmp_buf[..])?.len(); path_ptr = tmp_buf.as_ptr(); path_len = cwd_len; } else { @@ -3032,7 +3068,7 @@ fn resolve_windows_t<'a, T: PathCharCwd>( } else { // cwd is limited to MAX_PATH_BYTES. Store it AFTER the device: // tmp_buf[0..resolved_device_len] backs resolvedDevice. - cwd_len = get_cwd_t(&mut tmp_buf[resolved_device_len..])?.len(); + cwd_len = get_cwd(&mut tmp_buf[resolved_device_len..])?.len(); path_ptr = tmp_buf[resolved_device_len..].as_ptr(); path_len = cwd_len; // We must set envPath here so that it doesn't hit the null check just below. @@ -3141,7 +3177,8 @@ fn resolve_windows_t<'a, T: PathCharCwd>( return Err(bun_sys::Error::from_code( bun_sys::E::ENAMETOOLONG, bun_sys::Tag::TODO, - )); + ) + .into()); } // SAFETY: src/dst within live buffers; ptr::copy handles overlap. unsafe { @@ -3161,7 +3198,8 @@ fn resolve_windows_t<'a, T: PathCharCwd>( return Err(bun_sys::Error::from_code( bun_sys::E::ENAMETOOLONG, bun_sys::Tag::TODO, - )); + ) + .into()); } // SAFETY: src/dst within live buffers; ptr::copy handles overlap. unsafe { @@ -3307,62 +3345,138 @@ fn resolve_windows_t<'a, T: PathCharCwd>( Ok(l::(CHAR_STR_DOT)) } -#[cfg(unix)] unsafe extern "C" { - safe fn Process__getCachedCwd(global: &JSGlobalObject) -> JSValue; + safe fn Process__getPathCwd(global: &JSGlobalObject, posix: bool) -> JSValue; } -fn resolve_posix_js_t( - global_object: &JSGlobalObject, - paths: &[&[T]], - buf: &mut [T], - buf2: &mut [T], -) -> JsResult { - match resolve_posix_t(paths, buf, buf2) { - Ok(r) => create_js_string_t::(global_object, r), - Err(e) => Ok(e.to_js(global_object)), +enum ResolveJsError { + System(bun_sys::Error), + JavaScript(JsError), + CwdRequired, + CwdBufferTooSmall(usize), +} + +impl From for ResolveJsError { + fn from(error: bun_sys::Error) -> Self { + Self::System(error) } } -fn resolve_windows_js_t( +impl From for ResolveJsError { + fn from(error: JsError) -> Self { + Self::JavaScript(error) + } +} + +fn copy_cached_cwd<'a>( + cached: &Option>, + buf: &'a mut [u8], + cwd_capacity: usize, +) -> Result<&'a mut [u8], ResolveJsError> { + let Some(bytes) = cached else { + return Err(ResolveJsError::CwdRequired); + }; + if bytes.len() > cwd_capacity { + return Err(ResolveJsError::CwdBufferTooSmall(bytes.len())); + } + if bytes.len() > buf.len() { + return Err(bun_sys::Error::from_code(bun_sys::E::ENAMETOOLONG, bun_sys::Tag::TODO).into()); + } + buf[..bytes.len()].copy_from_slice(bytes.slice()); + let cwd = &mut buf[..bytes.len()]; + + Ok(cwd) +} + +fn js_string_to_wtf8( global_object: &JSGlobalObject, - paths: &[&[T]], - buf: &mut [T], - buf2: &mut [T], -) -> JsResult { - match resolve_windows_t(paths, buf, buf2) { - Ok(r) => create_js_string_t::(global_object, r), - Err(e) => Ok(e.to_js(global_object)), + value: JSValue, +) -> JsResult> { + let view = value.to_js_string_view(global_object)?; + if view.is_8bit() { + return Ok(view.to_utf8().into_owned()); + } + + let units = view.utf16(); + let mut bytes = Vec::with_capacity(units.len() * 3); + let mut index = 0; + while index < units.len() { + let (codepoint, width) = strings::decode_wtf16_raw(&units[index..]); + index += usize::from(width); + let mut encoded = [0u8; 4]; + let encoded_len = strings::encode_wtf8_rune(&mut encoded, codepoint); + bytes.extend_from_slice(&encoded[..encoded_len]); } + Ok(Utf8Bytes::Owned(bytes)) +} + +fn path_cwd_to_wtf8(global_object: &JSGlobalObject, posix: bool) -> JsResult> { + let value = crate::jsc::call_zero_is_throw(global_object, || { + Process__getPathCwd(global_object, posix) + })?; + js_string_to_wtf8(global_object, value) } -fn resolve_js_t( +fn create_wtf8_path_string(global_object: &JSGlobalObject, bytes: &[u8]) -> JsResult { + match strings::wtf8_to_utf16_alloc(bytes) { + Some(utf16) => bun_core::String::clone_utf16(&utf16).into_js(global_object), + None => create_js_string_t::(global_object, bytes), + } +} + +fn resolve_js( global_object: &JSGlobalObject, - pool: &mut RarePathBuf, is_windows: bool, - paths: &[&[T]], + paths: &[&[u8]], + cached_cwd: Option>, ) -> JsResult { - // Adding 8 bytes when Windows for the possible UNC root. - let mut buf_len: usize = if is_windows { 8 } else { 0 }; - for path in paths { - buf_len += if buf_len > 0 && !path.is_empty() { - path.len() + 1 - } else { - path.len() + let mut cwd_capacity = max_path_size::(); + let mut cached_cwd = cached_cwd; + loop { + // Adding 8 bytes when Windows for the possible UNC root. + let mut buf_len: usize = if is_windows { 8 } else { 0 }; + for path in paths { + buf_len += if buf_len > 0 && !path.is_empty() { + path.len() + 1 + } else { + path.len() + }; + } + buf_len += cwd_capacity + 1; + buf_len = buf_len.max(path_size::()); + // +2 to account for separator and null terminator during path resolution. + // Carve buf/buf2 from one pooled slab. + let resolved = { + let pool = &mut global_object.bun_vm().as_mut().rare_data().path_buf; + let mut scratch = PathScratch::::new(pool, (buf_len + 2) * 2); + let (buf, buf2) = scratch.slice().split_at_mut(buf_len + 2); + let path = if is_windows { + resolve_windows_t(paths, buf, buf2, cwd_capacity, |cwd| { + copy_cached_cwd(&cached_cwd, cwd, cwd_capacity) + }) + } else { + resolve_posix_t(paths, buf, buf2, cwd_capacity, |cwd| { + copy_cached_cwd(&cached_cwd, cwd, cwd_capacity) + }) + }; + match path { + Ok(path) => { + create_wtf8_path_string(global_object, path).map_err(ResolveJsError::JavaScript) + } + Err(error) => Err(error), + } }; - } - // When no path is absolute, the CWD (up to MAX_PATH_SIZE bytes) is prepended - // with a separator. Account for this to prevent buffer overflow. - buf_len += max_path_size::() + 1; - buf_len = buf_len.max(path_size::()); - // +2 to account for separator and null terminator during path resolution. - // Carve buf/buf2 from one pooled slab. - let mut scratch = PathScratch::::new(pool, (buf_len + 2) * 2); - let (buf, buf2) = scratch.slice().split_at_mut(buf_len + 2); - if is_windows { - resolve_windows_js_t(global_object, paths, buf, buf2) - } else { - resolve_posix_js_t(global_object, paths, buf, buf2) + match resolved { + Ok(path) => return Ok(path), + Err(ResolveJsError::CwdRequired) => { + let cwd = path_cwd_to_wtf8(global_object, !is_windows)?; + cwd_capacity = cwd_capacity.max(cwd.len()); + cached_cwd = Some(cwd); + } + Err(ResolveJsError::CwdBufferTooSmall(required)) => cwd_capacity = required, + Err(ResolveJsError::System(e)) => return Ok(e.to_js(global_object)), + Err(ResolveJsError::JavaScript(e)) => return Err(e), + } } } @@ -3406,22 +3520,43 @@ fn resolve( let owned: SmallVec<[Utf8Bytes<'_>; 8]> = views.iter().map(JSStringView::to_utf8).collect(); let paths: SmallVec<[&[u8]; 8]> = owned.iter().rev().map(Utf8Bytes::slice).collect(); - #[cfg(unix)] - { - if !is_windows { - // Micro-optimization #1: avoid creating a new string when passing no arguments or only empty strings. - // Micro-optimization #2: path.resolve(".") and path.resolve("./") === process.cwd() - if paths.is_empty() || (paths.len() == 1 && (paths[0] == b"." || paths[0] == b"./")) { - // Throws when `getcwd` fails (for example, a deleted cwd). - return crate::jsc::call_zero_is_throw(global_object, || { - Process__getCachedCwd(global_object) - }); + let current_directory_arg = + args_len == 0 || (args_len == 1 && (paths.is_empty() || paths[0] == b".")); + let mut cached_cwd = None; + if current_directory_arg { + let value = crate::jsc::call_zero_is_throw(global_object, || { + Process__getPathCwd(global_object, !is_windows) + })?; + let view = value.to_js_string_view(global_object)?; + let bytes = view.to_utf8(); + if is_windows { + let starts_with_separator = bytes + .slice() + .first() + .is_some_and(|byte| *byte == CHAR_FORWARD_SLASH || *byte == CHAR_BACKWARD_SLASH); + if args_len == 0 || starts_with_separator { + #[cfg(windows)] + return Ok(value); + #[cfg(not(windows))] + { + let mut cwd = js_string_to_wtf8(global_object, value)?.into_vec(); + for byte in &mut cwd { + if *byte == CHAR_FORWARD_SLASH { + *byte = CHAR_BACKWARD_SLASH; + } + } + return create_wtf8_path_string(global_object, &cwd); + } + } + cached_cwd = Some(js_string_to_wtf8(global_object, value)?); + } else { + if bytes.slice().first() == Some(&CHAR_FORWARD_SLASH) { + return Ok(value); } } } - let pool = &mut global_object.bun_vm().as_mut().rare_data().path_buf; - resolve_js_t::(global_object, pool, is_windows, &paths) + resolve_js(global_object, is_windows, &paths, cached_cwd) } /// Based on Node v21.6.1 path.win32.toNamespacedPath: @@ -3434,7 +3569,8 @@ fn to_namespaced_path_windows_t<'a, T: PathCharCwd>( // validateString of `path` is performed in pub fn toNamespacedPath. // Backed by buf. // Borrowck: capture length, then re-borrow buf. - let resolved_len = resolve_windows_t(&[path], buf, buf2)?.len(); + let resolved_len = + resolve_windows_t(&[path], buf, buf2, MAX_PATH_SIZE_UPPER, get_cwd_t::)?.len(); let len = resolved_len; if len <= 2 { diff --git a/src/runtime/server/NodeHTTPResponse.rs b/src/runtime/server/NodeHTTPResponse.rs index 2d6a500b45a0..6b344fa83af1 100644 --- a/src/runtime/server/NodeHTTPResponse.rs +++ b/src/runtime/server/NodeHTTPResponse.rs @@ -17,7 +17,6 @@ use crate::server::jsc::{ self, CallFrame, ErrorCode, JSGlobalObject, JSValue, JsResult, StrongOptional, VirtualMachine, }; use crate::server::{AnyServer, AnyServerTag, HTTPStatusText, ServerWebSocket}; -use crate::webcore::AutoFlusher; bun_core::declare_scope!(NodeHTTPResponse, visible); @@ -75,8 +74,6 @@ pub struct NodeHTTPResponse { pending_pinned_write_owner: JsCell>, pub(crate) upgrade_context: JsCell, - - pub(crate) auto_flusher: JsCell, } bitflags! { @@ -313,19 +310,6 @@ fn on_drain_shim(this: *mut NodeHTTPResponse, off: u64, resp: uws::AnyResponse) unsafe { (*this.cast_const()).on_drain(off, resp) } } -// R-2: `HasAutoFlusher` (which requires `fn auto_flusher(&mut self)`) is no -// longer implemented here — the deferred-task registration is inlined in -// `register_auto_flush` / `unregister_auto_flush` below so the whole path is -// `&self`. The `DeferredRepeatingTask` trampoline that the trait would have -// generated is local. Body discharges its own preconditions; a safe -// `extern "C" fn` coerces to the `DeferredRepeatingTask` pointer at `post_task`. -extern "C" fn on_auto_flush_trampoline(ctx: *mut c_void) -> bool { - // SAFETY: `ctx` is the `*const NodeHTTPResponse` registered by - // `register_auto_flush`; `DeferredTaskQueue::run` feeds it back unchanged - // on the JS thread. `on_auto_flush` takes `&self`. - unsafe { (*(ctx.cast_const().cast::())).on_auto_flush() } -} - /// Unpack the `AnyServer` tagged-pointer u64 handed across FFI from C++. /// /// The packed repr is bits 0..49 = ptr, @@ -745,8 +729,6 @@ impl NodeHTTPResponse { } let mut server = self.server; self.poll_ref.with_mut(|r| r.unref(vm)); - self.unregister_auto_flush(); - server.on_request_complete(); if had_async_promise { @@ -2322,52 +2304,6 @@ impl NodeHTTPResponse { self.write_or_end::(global_object, arguments, JSValue::ZERO) } - fn on_auto_flush(&self) -> bool { - let flags = self.flags.get(); - if !flags.contains(Flags::SOCKET_CLOSED) && !flags.contains(Flags::UPGRADED) { - if let Some(raw_response) = self.raw_response.get() { - raw_response.uncork(); - } - } - self.auto_flusher.get().registered.set(false); - self.deref(); - false - } - - // R-2: inlined `AutoFlusher::register_deferred_microtask_with_type_unchecked` - // — that helper now takes `&T`, but this type has its own - // `on_auto_flush_trampoline` (extra `self.ref_()`) so the inline body - // stays. - fn register_auto_flush(&self) { - if self.auto_flusher.get().registered.get() { - return; - } - self.ref_(); - debug_assert!(!self.auto_flusher.get().registered.get()); - self.auto_flusher.get().registered.set(true); - let ctx = ptr::NonNull::new(self.as_ctx_ptr().cast::()); - let found_existing = vm_get() - .event_loop_ref() - .deferred_tasks - .post_task(ctx, on_auto_flush_trampoline); - debug_assert!(!found_existing); - } - - fn unregister_auto_flush(&self) { - if !self.auto_flusher.get().registered.get() { - return; - } - debug_assert!(self.auto_flusher.get().registered.get()); - let ctx = ptr::NonNull::new(self.as_ctx_ptr().cast::()); - let removed = vm_get() - .event_loop_ref() - .deferred_tasks - .unregister_task(ctx); - debug_assert!(removed); - self.auto_flusher.get().registered.set(false); - self.deref(); - } - pub(crate) fn flush_headers( &self, _global: &JSGlobalObject, @@ -2376,11 +2312,7 @@ impl NodeHTTPResponse { let flags = self.flags.get(); if !flags.contains(Flags::SOCKET_CLOSED) && !flags.contains(Flags::UPGRADED) { if let Some(raw_response) = self.raw_response.get() { - // Don't flush immediately; queue a microtask to uncork the socket. - raw_response.flush_headers(false); - if raw_response.is_corked() { - self.register_auto_flush(); - } + raw_response.flush_headers(true); } } @@ -2533,12 +2465,6 @@ impl NodeHTTPResponse { self.armed_this_value.set(JSValue::ZERO); } - #[inline] - fn ref_(&self) { - // SAFETY: `self` is live; only the interior-mutable count is touched. - unsafe { bun_ptr::RefCount::::ref_(self.as_ctx_ptr()) }; - } - #[inline] pub(crate) fn deref(&self) { // SAFETY: `self` is the live heap allocation; every field is @@ -2673,7 +2599,6 @@ pub(crate) unsafe extern "C" fn NodeHTTPResponse__createForJS( bytes_written: Cell::new(0), pending_pinned_write: Cell::new(PendingPinnedWrite::default()), pending_pinned_write_owner: JsCell::new(crate::node::StringOrBuffer::EMPTY), - auto_flusher: JsCell::new(AutoFlusher::default()), })); // SAFETY: `response` was just allocated and leaked; we hold the only reference. diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index b96201e5dce9..e9f0d3c71e7c 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -1590,15 +1590,14 @@ impl NewServer { } /// What the `stop()` promise (node:http: the `'close'` event) and the - /// loop unref wait for. Bun.serve waits for open HTTP connections too; - /// node:http's `server.close()` reports closed without them (Node's own - /// `net.Server` waits for every connection — pre-existing divergence), - /// so there they only pin the wrapper via [`Self::is_drained`]. + /// loop unref wait for. Accepted connections count before a TLS handshake + /// completes, so a stopped listener cannot report closed while one can + /// still dispatch. pub(crate) fn is_closed(&self) -> bool { self.pending_requests.get() == 0 && !self.has_listener() && !self.has_active_web_sockets() - && (self.config.is_node_http_server || !self.has_active_connections()) + && !self.has_active_connections() } /// Nothing is left that can dispatch a handler: [`Self::is_closed`] and diff --git a/src/runtime/server/server.classes.ts b/src/runtime/server/server.classes.ts index f42964bf7f55..9cdcce227296 100644 --- a/src/runtime/server/server.classes.ts +++ b/src/runtime/server/server.classes.ts @@ -28,6 +28,10 @@ function generate(name) { fn: "doReload", length: 2, }, + _setNodeHTTPSSecureContext: { + fn: "doSetNodeHTTPSSecureContext", + length: 2, + }, "@@dispose": { fn: "dispose", length: 0, diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 5a3f6ff0a1e0..8323b30ce30e 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -9,6 +9,7 @@ use crate::bake::dev_server::DevServer; use crate::bake::framework_router as FrameworkRouter; use crate::bake::{self as bake}; use crate::node::types::PathLikeExt as _; +use crate::socket::{SSLConfig, SSLConfigFromJs as _}; use crate::webcore::BlobExt; use crate::webcore::body::Value as BodyValue; use crate::webcore::fetch as Fetch; @@ -1506,6 +1507,54 @@ where self.on_reload(global, callframe) } + #[bun_jsc::host_fn(method)] + pub(crate) fn do_set_node_https_secure_context( + &mut self, + global: &JSGlobalObject, + callframe: &CallFrame, + ) -> JsResult { + if !SSL { + return Err(global.throw_invalid_arguments(format_args!( + "Cannot set a TLS context on a non-TLS server", + ))); + } + let options = callframe + .arguments() + .first() + .copied() + .ok_or_else(|| global.throw_not_enough_arguments("setSecureContext", 1, 0))?; + let Some(config) = SSLConfig::from_js(global.bun_vm_ref(), global, options)? else { + return Err(global + .throw_invalid_arguments(format_args!("setSecureContext requires TLS options",))); + }; + let additional_ca_config = match callframe.arguments().get(1).copied() { + Some(value) if !value.is_empty_or_undefined_or_null() => { + SSLConfig::from_js(global.bun_vm_ref(), global, value)? + } + _ => None, + }; + let native_options = config.as_usockets(); + let additional_ca_options = additional_ca_config + .as_ref() + .map(SSLConfig::as_usockets) + .unwrap_or_default(); + let Some(app) = self.app else { + return Err(global.throw_invalid_arguments(format_args!( + "Cannot set a TLS context after the server has stopped", + ))); + }; + // SAFETY: app is the live SSL NewApp owned by this running server. + if !bun_opaque::opaque_deref_mut(app) + .set_secure_context(&native_options, &additional_ca_options) + { + return Err( + global.throw_invalid_arguments(format_args!("Failed to set the TLS context",)) + ); + } + self.config.ssl_config = Some(config); + Ok(JSValue::UNDEFINED) + } + /// `pub const doFetch = onFetch` #[inline] pub(crate) fn do_fetch( diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 3823142abe5f..aa87ac927505 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.as_ref() { @@ -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 3c0861e32e18..b0e8503ac76e 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) safe fn SSL_CTX_get_cert_store(ctx: &SSL_CTX) -> *mut X509_STORE; // 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 us_ssl_ctx_has_user_ca(ctx: *mut SSL_CTX) -> c_int; pub(crate) fn X509_STORE_free(store: *mut X509_STORE); // X509_STORE_CTX lifecycle for issuer lookups; `new` allocates, @@ -559,7 +559,9 @@ pub(super) fn get_peer_certificate( let mut shared_store: *mut boringssl::X509_STORE = core::ptr::null_mut(); let ssl_ctx = ffi::SSL_get_SSL_CTX(boringssl::SSL::opaque_ref(ssl_ptr)); if store.is_null() || ffi::us_ssl_ctx_has_user_ca(ssl_ctx) == 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/timer/ImmediateObject.rs b/src/runtime/timer/ImmediateObject.rs index c0a744cc1636..4e06ea8c8fb1 100644 --- a/src/runtime/timer/ImmediateObject.rs +++ b/src/runtime/timer/ImmediateObject.rs @@ -14,10 +14,19 @@ impl ImmediateObject { pub(crate) fn init( global: &JSGlobalObject, id: i32, + async_hooks_id: u64, callback: JSValue, arguments: JSValue, ) -> JSValue { - Self::init_with(global, id, Kind::SetImmediate, 0, callback, arguments) + Self::init_with( + global, + id, + async_hooks_id, + Kind::SetImmediate, + 0, + callback, + arguments, + ) } /// Thin forwarder to diff --git a/src/runtime/timer/TimeoutObject.rs b/src/runtime/timer/TimeoutObject.rs index 5fd26f857fea..714b84b79ae7 100644 --- a/src/runtime/timer/TimeoutObject.rs +++ b/src/runtime/timer/TimeoutObject.rs @@ -13,12 +13,21 @@ impl TimeoutObject { pub(crate) fn init( global: &JSGlobalObject, id: i32, + async_hooks_id: u64, kind: Kind, interval: u32, callback: JSValue, arguments: JSValue, ) -> JSValue { - Self::init_with(global, id, kind, interval, callback, arguments) + Self::init_with( + global, + id, + async_hooks_id, + kind, + interval, + callback, + arguments, + ) } #[bun_jsc::host_fn(method)] diff --git a/src/runtime/timer/Timer.rs b/src/runtime/timer/Timer.rs index b840af4df82b..35b4161b9069 100644 --- a/src/runtime/timer/Timer.rs +++ b/src/runtime/timer/Timer.rs @@ -199,6 +199,7 @@ impl All { let all = timer_all_mut(); let id = all.last_id; all.last_id = all.last_id.wrapping_add(1); + let async_hooks_id = all.next_async_hooks_id(); let countdown_int = all.js_value_to_countdown(global, countdown, CountdownOverflowBehavior::Clamp, true)?; @@ -206,6 +207,7 @@ impl All { Ok(TimeoutObject::init( global, id, + async_hooks_id, Kind::SetTimeout, countdown_int, wrapped_promise, @@ -223,11 +225,13 @@ impl All { let all = timer_all_mut(); let id = all.last_id; all.last_id = all.last_id.wrapping_add(1); + let async_hooks_id = all.next_async_hooks_id(); let wrapped_callback = callback.with_async_context_if_needed(global); Ok(ImmediateObject::init( global, id, + async_hooks_id, wrapped_callback, arguments, )) @@ -244,6 +248,7 @@ impl All { let all = timer_all_mut(); let id = all.last_id; all.last_id = all.last_id.wrapping_add(1); + let async_hooks_id = all.next_async_hooks_id(); let wrapped_callback = callback.with_async_context_if_needed(global); let countdown_int = @@ -251,6 +256,7 @@ impl All { Ok(TimeoutObject::init( global, id, + async_hooks_id, Kind::SetTimeout, countdown_int, wrapped_callback, @@ -269,6 +275,7 @@ impl All { let all = timer_all_mut(); let id = all.last_id; all.last_id = all.last_id.wrapping_add(1); + let async_hooks_id = all.next_async_hooks_id(); let wrapped_callback = callback.with_async_context_if_needed(global); let countdown_int = @@ -276,6 +283,7 @@ impl All { Ok(TimeoutObject::init( global, id, + async_hooks_id, Kind::SetInterval, countdown_int, wrapped_callback, @@ -553,6 +561,16 @@ pub fn clear_interval_export(global: &JSGlobalObject, id: JSValue) -> JsResult JsResult { + Ok(JSValue::js_number( + timer_all_mut().next_async_hooks_id() as f64 + )) + } + /// Node.js has some tests that check whether timers fire at the right time. They check this /// with the internal binding `getLibuvNow()`, which returns an integer in milliseconds. This /// works because `getLibuvNow()` is also the clock that their timers implementation uses to diff --git a/src/runtime/timer/mod.rs b/src/runtime/timer/mod.rs index 97b1e578bffb..805220ed365e 100644 --- a/src/runtime/timer/mod.rs +++ b/src/runtime/timer/mod.rs @@ -120,6 +120,7 @@ macro_rules! impl_timer_object { pub fn init_with( global: &::bun_jsc::JSGlobalObject, id: i32, + async_hooks_id: u64, kind: super::Kind, interval: u32, callback: ::bun_jsc::JSValue, @@ -145,9 +146,22 @@ macro_rules! impl_timer_object { // owned here; `internals.init()` writes every field. unsafe { (*payload).internals.init( - js_value, global, id, kind, interval, callback, arguments, + js_value, + global, + id, + async_hooks_id, + kind, + interval, + callback, + arguments, ); } + super::timer_object_internals::emit_async_hooks_timer_init( + global, + js_value, + async_hooks_id, + kind, + ); if global.bun_vm().as_mut().is_inspector_enabled() { ::bun_jsc::Debugger::did_schedule_async_call( global, @@ -583,6 +597,7 @@ pub(crate) use wtf_timer::WTFTimer; pub(crate) struct All { pub(crate) last_id: i32, + pub(crate) last_async_hooks_id: u64, pub(crate) thread_id: std::thread::ThreadId, pub(crate) timers: TimerHeap, pub(crate) active_timer_count: i32, @@ -609,6 +624,7 @@ impl All { pub(crate) fn init() -> Self { Self { last_id: 1, + last_async_hooks_id: 1, thread_id: std::thread::current().id(), timers: TimerHeap::default(), active_timer_count: 0, @@ -628,6 +644,16 @@ impl All { } } + pub(crate) fn next_async_hooks_id(&mut self) -> u64 { + const MAX_SAFE_INTEGER: u64 = (1 << 53) - 1; + self.last_async_hooks_id = if self.last_async_hooks_id == MAX_SAFE_INTEGER { + 2 + } else { + self.last_async_hooks_id + 1 + }; + self.last_async_hooks_id + } + #[inline] fn assert_js_thread(&self) { debug_assert!( diff --git a/src/runtime/timer/timer_object_internals.rs b/src/runtime/timer/timer_object_internals.rs index 4760250bf099..c07f7cbae982 100644 --- a/src/runtime/timer/timer_object_internals.rs +++ b/src/runtime/timer/timer_object_internals.rs @@ -30,6 +30,7 @@ use super::{ pub struct TimerObjectInternals { /// Identifier for this timer that is exposed to JavaScript (by `+timer`). pub(crate) id: i32, + async_hooks_id: Cell, pub(crate) interval: Cell, pub this_value: JsCell, pub(crate) flags: Cell, @@ -53,6 +54,7 @@ impl Default for TimerObjectInternals { fn default() -> Self { Self { id: -1, + async_hooks_id: Cell::new(0), interval: Cell::new(0), this_value: JsCell::new(JsRef::empty()), flags: Cell::new(Flags::default()), @@ -79,6 +81,39 @@ unsafe extern "C" { callback: JSValue, arguments: JSValue, ) -> bool; + safe fn Bun__AsyncHooks__emitTimerLifecycle( + global_object: *mut JSGlobalObject, + timer: JSValue, + async_hooks_id: u64, + event: AsyncHooksTimerLifecycleEvent, + ); +} + +#[repr(u8)] +enum AsyncHooksTimerLifecycleEvent { + TimeoutInit = 0, + ImmediateInit = 1, + Destroy = 2, +} + +const ASYNC_HOOKS_DESTROYED: u64 = 1 << 63; + +pub(crate) fn emit_async_hooks_timer_init( + global: &JSGlobalObject, + timer: JSValue, + async_hooks_id: u64, + kind: Kind, +) { + Bun__AsyncHooks__emitTimerLifecycle( + global.as_ptr(), + timer, + async_hooks_id, + if kind == Kind::SetImmediate { + AsyncHooksTimerLifecycleEvent::ImmediateInit + } else { + AsyncHooksTimerLifecycleEvent::TimeoutInit + }, + ); } /// Typed result of `@fieldParentPtr("internals", self)` discriminated by @@ -93,6 +128,21 @@ enum TimerParent { } impl TimerObjectInternals { + fn emit_async_hooks_destroy(&self, timer: JSValue, global: *mut JSGlobalObject) { + let async_hooks_id = self.async_hooks_id.get(); + if async_hooks_id & ASYNC_HOOKS_DESTROYED != 0 { + return; + } + self.async_hooks_id + .set(async_hooks_id | ASYNC_HOOKS_DESTROYED); + Bun__AsyncHooks__emitTimerLifecycle( + global, + timer, + async_hooks_id, + AsyncHooksTimerLifecycleEvent::Destroy, + ); + } + /// `@fieldParentPtr("internals", self)` — the single `container_of` site. /// Every other helper (`event_loop_timer`, `ref_`, `deref`, `init`, /// `event_loop_timer_state`) routes through this so the `from_field_ptr!` @@ -278,6 +328,7 @@ impl TimerObjectInternals { timer: JSValue, global: &JSGlobalObject, id: i32, + async_hooks_id: u64, kind: Kind, interval: u32, callback: JSValue, @@ -289,6 +340,7 @@ impl TimerObjectInternals { *self = Self { id, + async_hooks_id: Cell::new(async_hooks_id), flags: { let mut f = Flags::default(); f.set_kind(kind); @@ -416,6 +468,7 @@ impl TimerObjectInternals { // above pins the parent across re-entrancy. let result = unsafe { Self::run(this, global_this, timer, callback, arguments, async_id, vm) }; + s.emit_async_hooks_destroy(timer, global_this); // `Self::run` has no early return so the deref ordering below is // preserved. After the second `deref()` `*this` may be // freed; do not touch it past this block. @@ -537,6 +590,7 @@ impl TimerObjectInternals { async_id.async_id(), ); } + s.emit_async_hooks_destroy(this_object, global_this); s.set_enable_keeping_event_loop_alive(vm, false); s.update_flags(|f| f.set_has_cleared_timer(true)); s.this_value.with_mut(|r| r.downgrade()); @@ -677,6 +731,7 @@ impl TimerObjectInternals { }; if is_timer_done { + s.emit_async_hooks_destroy(this_object, global_this); s.set_enable_keeping_event_loop_alive(vm, false); // The timer will not be re-entered into the event loop at this point. s.deref(); @@ -983,6 +1038,18 @@ impl TimerObjectInternals { return Ok(this_value); } + let previous_async_hooks_id = self.async_hooks_id.get(); + let restarted_async_hooks_id = if previous_async_hooks_id & ASYNC_HOOKS_DESTROYED != 0 { + let state = crate::jsc_hooks::runtime_state(); + debug_assert!(!state.is_null(), "RuntimeState not installed"); + // SAFETY: `state` is the live per-thread RuntimeState. + let id = unsafe { (*state).timer.next_async_hooks_id() }; + self.async_hooks_id.set(id); + Some(id) + } else { + None + }; + self.this_value .with_mut(|r| r.set_strong(this_value, global_object)); self.reschedule( @@ -991,6 +1058,19 @@ impl TimerObjectInternals { global_object.as_ptr(), ); + if let Some(async_hooks_id) = restarted_async_hooks_id { + if self.event_loop_timer_state() == EventLoopTimerState::ACTIVE { + emit_async_hooks_timer_init( + global_object, + this_value, + async_hooks_id, + self.flags.get().kind(), + ); + } else { + self.async_hooks_id.set(previous_async_hooks_id); + } + } + Ok(this_value) } @@ -1056,6 +1136,10 @@ impl TimerObjectInternals { /// `set_enable_keeping_event_loop_alive` which already uses the raw-ptr /// contract. `vm.timer` resolved via `runtime_state()` (jsc/runtime crate cycle). pub(crate) fn cancel(&self, vm: *mut VirtualMachine) { + if let Some(timer) = self.this_value.get().try_get() { + // SAFETY: `vm` is the live per-thread VM (caller contract). + self.emit_async_hooks_destroy(timer, unsafe { (*vm).global }); + } self.set_enable_keeping_event_loop_alive(vm, false); self.update_flags(|f| f.set_has_cleared_timer(true)); diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 35f29a558840..a761b536345c 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -668,6 +668,15 @@ fn fetch_impl( break 'extract_ssl_config ssl_config; }; + // 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/sys/lib.rs b/src/sys/lib.rs index 6701a0b46b1e..d6f129c8235b 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -7114,10 +7114,7 @@ pub enum ExistsAtType { Directory, } /// Windows tail — `NtQueryAttributesFile` against an -/// OBJECT_ATTRIBUTES built from an already NT-prefixed wide path. Shared by the -/// UTF-8 (`exists_at_type`) and UTF-16 (`exists_at_type_w`) entry points so the -/// width dispatch does not -/// duplicate the syscall body. +/// OBJECT_ATTRIBUTES built from an already NT-prefixed wide path. #[cfg(windows)] fn exists_at_type_nt(dir: Fd, mut path: &[u16]) -> Maybe { use bun_windows_sys::externs as w; @@ -7186,15 +7183,6 @@ pub fn exists_at_type(dir: Fd, sub: &ZStr) -> Maybe { exists_at_type_nt(dir, path) } } -/// Wide-path arm of `exists_at_type`. Takes an already-wide path (Windows -/// `OSPathSliceZ`) and routes through -/// `toNTPath16` instead of re-widening from UTF-8. -#[cfg(windows)] -pub(crate) fn exists_at_type_w(dir: Fd, sub: &[u16]) -> Maybe { - let mut wbuf = bun_paths::w_path_buffer_pool::get(); - let path = bun_paths::string_paths::to_nt_path16(&mut wbuf.0[..], sub).as_slice(); - exists_at_type_nt(dir, path) -} /// `directoryExistsAt(dir, sub)`. ENOENT → `Ok(false)`. pub fn directory_exists_at(dir: impl AsFd, sub: &ZStr) -> Maybe { let dir = dir.as_fd(); @@ -7204,18 +7192,6 @@ pub fn directory_exists_at(dir: impl AsFd, sub: &ZStr) -> Maybe { Err(e) => Err(e), } } -/// `directoryExistsAt` — wide-path (`u16`) overload for Windows -/// `OSPathSliceZ` callers (mkdir-recursive, cpSync auto-detect). Avoids -/// a UTF-16 → UTF-8 → UTF-16 round-trip. -#[cfg(windows)] -pub fn directory_exists_at_w(dir: Fd, sub: &[u16]) -> Maybe { - match exists_at_type_w(dir, sub) { - Ok(t) => Ok(t == ExistsAtType::Directory), - Err(e) if e.get_errno() == E::ENOENT => Ok(false), - Err(e) => Err(e), - } -} - // ── fcntl / nonblocking / dup ── /// `fcntl(fd, F_GETFL, 0)`. diff --git a/src/sys/sys_uv.rs b/src/sys/sys_uv.rs index 9cf9c4ef936b..91e3fb844ccc 100644 --- a/src/sys/sys_uv.rs +++ b/src/sys/sys_uv.rs @@ -517,6 +517,20 @@ pub fn stat(path: &ZStr) -> Result { } } +/// Preserve Windows filename code units while using libuv's Win32 stat semantics. +pub fn stat_w(path: &bun_core::WStr) -> Result { + let mut buf = bun_paths::path_buffer_pool::get(); + let mut ptr = buf.as_mut_ptr().cast::(); + let mut len = buf.len() - 1; + // SAFETY: path covers its reported length; buf has len bytes plus libuv's NUL terminator. + let rc = + unsafe { uv::uv_utf16_to_wtf8(path.as_ptr(), path.len() as isize, &mut ptr, &mut len) }; + if let Some(errno) = rc.errno() { + return Err(Error::new(errno, Tag::stat)); + } + stat(ZStr::from_buf(&buf[..], len)) +} + pub fn lstat(path: &ZStr) -> Result { let mut req = FsReq::new(); // SAFETY: synchronous libuv fs call; req lives on the stack for the duration. diff --git a/src/url/lib.rs b/src/url/lib.rs index a066d6c39854..fb101a7fe7a2 100644 --- a/src/url/lib.rs +++ b/src/url/lib.rs @@ -73,6 +73,7 @@ pub mod whatwg { safe fn URL__getHref(input: &String) -> String; safe fn URL__getFileURLString(input: &String) -> String; safe fn URL__pathFromFileURL(input: &String) -> String; + safe fn URL__suffixFromFileURL(input: &String) -> String; safe fn URL__getHrefJoin(base: &String, relative: &String) -> String; fn URL__originLength(latin1_slice: *const u8, len: usize) -> usize; } @@ -91,6 +92,9 @@ pub mod whatwg { pub fn path_from_file_url(str: &String) -> String { URL__pathFromFileURL(str) } + pub fn suffix_from_file_url(str: &String) -> String { + URL__suffixFromFileURL(str) + } /// Returns the origin (`scheme://host[:port]`) prefix of `slice` as a borrowed /// subslice, or `None` if `slice` does not parse as a valid WHATWG URL. /// @@ -181,6 +185,7 @@ pub mod whatwg { // `bun_url::join(...)` / `bun_url::href_from_string(...)` (install, http, bake, js_parser). pub use whatwg::{ file_url_from_string, href_from_string, join, origin_from_slice, path_from_file_url, + suffix_from_file_url, }; // URL is a pure view struct — every field is a slice into `href` (or a diff --git a/src/uws/lib.rs b/src/uws/lib.rs index 9832300d1b48..20b11d224947 100644 --- a/src/uws/lib.rs +++ b/src/uws/lib.rs @@ -430,7 +430,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(), @@ -1269,8 +1273,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 crate::SslCtx) -> 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; @@ -1303,7 +1310,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/App.rs b/src/uws_sys/App.rs index deedc2d0c298..8eb40354f74b 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -150,6 +150,23 @@ impl App { c::uws_app_clear_routes(Self::SSL_FLAG, self.as_raw()) } + pub fn set_secure_context( + &mut self, + opts: &BunSocketContextOptions, + additional_ca: &BunSocketContextOptions, + ) -> bool { + // SAFETY: self is a live app and the CA pointers remain valid for the duration of the call. + unsafe { + c::uws_app_set_secure_context( + Self::SSL_FLAG, + self.as_raw(), + *opts, + additional_ca.ca, + additional_ca.ca_count, + ) != 0 + } + } + pub(crate) fn publish_with_options( &mut self, topic: &[u8], @@ -655,6 +672,13 @@ pub mod c { ); pub(crate) safe fn uws_app_clear_routes(ssl_flag: c_int, app: &mut uws_app_t); + pub(crate) fn uws_app_set_secure_context( + ssl_flag: c_int, + app: &mut uws_app_t, + options: BunSocketContextOptions, + additional_ca: *const *const c_char, + additional_ca_count: c_uint, + ) -> c_int; } #[repr(C)] 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 8ebe83821e5b..588c016badc6 100644 --- a/src/uws_sys/Loop.rs +++ b/src/uws_sys/Loop.rs @@ -508,6 +508,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; #[cfg(not(windows))] pub(super) fn us_loop_run_bun_tick( loop_: *mut Loop, @@ -530,7 +532,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/Response.rs b/src/uws_sys/Response.rs index 1c9e2e9dd96e..e3d31db136fb 100644 --- a/src/uws_sys/Response.rs +++ b/src/uws_sys/Response.rs @@ -154,10 +154,6 @@ impl Response { c::uws_res_flush_headers(Self::ssl_flag(), self.as_raw(), flush_immediately) } - pub(crate) fn is_corked(&mut self) -> bool { - c::uws_res_is_corked(Self::ssl_flag(), self.as_raw()) - } - pub(crate) fn state(&self) -> State { // SAFETY: `Response` and `c::uws_res` are layout-identical opaque // ZSTs (both `UnsafeCell<[u8; 0]>`); the reborrow is a no-op cast. @@ -180,10 +176,6 @@ impl Response { c::uws_res_prepare_for_sendfile(Self::ssl_flag(), self.as_raw()) } - pub(crate) fn uncork(&mut self) { - c::uws_res_uncork(Self::ssl_flag(), self.as_raw()) - } - pub(crate) fn pause(&mut self) { c::uws_res_pause(Self::ssl_flag(), self.as_raw()) } @@ -793,14 +785,6 @@ impl AnyResponse { any_dispatch!(self, |r| r.flush_headers(flush_immediately)) } - pub fn is_corked(self) -> bool { - any_dispatch!(self, |r| r.is_corked()) - } - - pub fn uncork(self) { - any_dispatch!(self, |r| r.uncork()) - } - pub fn get_buffered_amount(self) -> u64 { any_dispatch!(self, |r| r.get_buffered_amount()) } @@ -1174,7 +1158,6 @@ pub mod c { port: &mut i32, is_ipv6: &mut bool, ) -> usize; - pub(crate) safe fn uws_res_uncork(ssl: i32, res: &mut uws_res); pub(crate) fn uws_res_end( ssl: i32, res: *mut uws_res, @@ -1187,7 +1170,6 @@ pub mod c { res: &mut uws_res, flush_immediately: bool, ); - pub(crate) safe fn uws_res_is_corked(ssl: i32, res: &mut uws_res) -> bool; pub(crate) safe fn uws_res_pause(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_resume(ssl: i32, res: &mut uws_res); pub(crate) safe fn uws_res_write_continue(ssl: i32, res: &mut uws_res); diff --git a/src/uws_sys/SocketContext.rs b/src/uws_sys/SocketContext.rs index 865596e96e4b..a6697b7808b9 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, } } } @@ -258,6 +262,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/src/uws_sys/h2.rs b/src/uws_sys/h2.rs index d0270c3c8d1a..8959ef670057 100644 --- a/src/uws_sys/h2.rs +++ b/src/uws_sys/h2.rs @@ -129,10 +129,6 @@ impl Response { pub fn request_body_ended(&self) -> bool { c::uws_h2_res_request_body_ended(self) } - pub(crate) fn is_corked(&self) -> bool { - false - } - pub(crate) fn uncork(&mut self) {} pub(crate) fn is_connect_request(&self) -> bool { false } diff --git a/src/uws_sys/h3.rs b/src/uws_sys/h3.rs index caabd299d636..cc9bed086930 100644 --- a/src/uws_sys/h3.rs +++ b/src/uws_sys/h3.rs @@ -181,10 +181,6 @@ impl Response { pub(crate) fn is_closed(&self) -> bool { false } - pub(crate) fn is_corked(&self) -> bool { - false - } - pub(crate) fn uncork(&mut self) {} pub(crate) fn is_connect_request(&self) -> bool { false } diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 5c1e40665e62..54da4b93236c 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -62,6 +62,14 @@ extern "C" } } + int uws_app_set_secure_context(int ssl, uws_app_t *app, struct us_bun_socket_context_options_t options, const char *const *additional_ca, unsigned int additional_ca_count) + { + if (!ssl) return 0; + uWS::SocketContextOptions socket_context_options; + memcpy(&socket_context_options, &options, sizeof(uWS::SocketContextOptions)); + return ((uWS::SSLApp *)app)->setSecureContext(socket_context_options, additional_ca, additional_ca_count); + } + void uws_app_get(int ssl, uws_app_t *app, const char *pattern_ptr, size_t pattern_len, uws_method_handler handler, void *user_data) { std::string_view pattern = std::string_view(pattern_ptr, pattern_len); @@ -1511,20 +1519,6 @@ size_t uws_req_get_header(uws_req_t *res, const char *lower_case_header, return (struct us_loop_t *)uWS::Loop::get(existing_native_loop); } - void uws_res_uncork(int ssl, uws_res_r res) - { - if (ssl) - { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->uncork(); - } - else - { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - uwsRes->uncork(); - } - } - void us_socket_mark_needs_more_not_ssl(uws_res_r res) { us_socket_r s = (us_socket_t *)res; @@ -1638,16 +1632,6 @@ __attribute__((callback (corker, ctx))) } } - bool uws_res_is_corked(int ssl, uws_res_r res) { - if (ssl) { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - return uwsRes->isCorked(); - } else { - uWS::HttpResponse *uwsRes = (uWS::HttpResponse *)res; - return uwsRes->isCorked(); - } - } - bool uws_res_is_connect_request(int ssl, uws_res_r res) { if (ssl) { 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..